diff --git a/.changepacks/changepack_log_resolved_text_weights.json b/.changepacks/changepack_log_resolved_text_weights.json new file mode 100644 index 0000000..9706649 --- /dev/null +++ b/.changepacks/changepack_log_resolved_text_weights.json @@ -0,0 +1,8 @@ +{ + "changes": { + "crates/devup-mcp-devup-ui/Cargo.toml": "Minor", + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "Stop losing bold. A text node whose segments share a typography token can still override the weight per run, and both text emitters suppressed fontWeight whenever a token was present, so alternating 400/700 runs came out uniform. The token name carries no resolved metrics, so nothing downstream could recover them. Missing bold changes glyph advances and therefore wrapping, which is how a lost weight became a layout defect: on the about screens one 36px line wrapped that the design does not wrap, and every section below inherited the displacement. Emitting the resolved weight alongside the token brings the rendered page height to within one pixel of the design at both wider widths, from 37 pixels over, and the measured divergence from Figma's own PNG falls from 6.90 to 4.06 percent at 992 and from 4.19 to 2.41 percent at 1920, paid for by 0.02 percent at 360. Segment-sourced weights are attributed to styledTextSegments in the source map while explicit node weights keep their node-property mapping, so the provenance says which one actually supplied the number. Korean keeps word-break: keep-all. Removing it was implemented and measured because Figma breaks Korean within words and matching that narrows the pixel gap, but it narrows it by 0.21 percent on one screen while chopping every Korean word in every generated screen and breaking 38 attributes across 17 plugin byte-parity goldens; the pixel metric is a proxy for correctness and this is where the two disagree. Figma's Korean line breaking is a limitation to compensate for rather than a specification to reproduce, which is the same call the plugin makes in its own text renderer, and a test now locks the behaviour so a later fidelity pass cannot quietly reverse it. One golden changes, for a segment that explicitly carries weight 400.", + "date": "2026-09-13T03:10:00+09:00" +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/text.rs b/crates/devup-mcp-devup-ui/src/codegen/text.rs index ae9830a..9612f1c 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/text.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/text.rs @@ -56,9 +56,9 @@ pub(super) fn push_text_props( if let Some(font_size) = value("fontSize").and_then(Value::as_f64) { string_prop(props, "fontSize", px(font_size)); } - if typography.is_none() - && let Some(weight) = value("fontWeight").and_then(Value::as_f64) - { + // 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 typography.is_none() @@ -219,6 +219,16 @@ fn line_height(value: Option<&Value>, font_size: Option) -> Option } } +/// 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) @@ -384,9 +394,7 @@ fn typography_props( if let Some(value) = segment.get("fontSize").and_then(Value::as_f64) { string_prop(&mut props, "fontSize", px(value)); } - if typography.is_none() - && let Some(value) = segment.get("fontWeight").and_then(Value::as_f64) - { + if let Some(value) = segment.get("fontWeight").and_then(Value::as_f64) { string_prop(&mut props, "fontWeight", format_number(value)); } if typography.is_none() diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index 3d50b79..530036c 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -1585,11 +1585,19 @@ pub(crate) fn finalize_tsx( } else { "raw-fallback" }; + let source_property = if *prop == "fontWeight" + && node.node_type == "TEXT" + && node.typed_view().number(property).is_none() + { + "styledTextSegments" + } else { + property + }; entries.push(generated_entry( range.start + open_relative + start, range.start + open_relative + end, &node_id, - property, + source_property, variable_id, style_id, resolution, @@ -2014,7 +2022,7 @@ fn add_text_entries( let mut cursor = 0; for (characters, segment) in text_segments { if let Some((start, end)) = find_text_span(source, characters, cursor) { - // A rich-text wrapper can now carry its own integer advance. + // 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. if let Some(segment) = segment @@ -2024,7 +2032,7 @@ fn add_text_entries( && source[open_start + close + 1..start].trim().is_empty() { let opening = &source[open_start..open_start + close]; - for field in ["fontSize", "lineHeight"] { + for field in ["fontSize", "lineHeight", "fontWeight"] { if segment.get(field).is_none() { continue; } diff --git a/crates/devup-mcp-devup-ui/tests/codegen.rs b/crates/devup-mcp-devup-ui/tests/codegen.rs index dc7406a..8d0832e 100644 --- a/crates/devup-mcp-devup-ui/tests/codegen.rs +++ b/crates/devup-mcp-devup-ui/tests/codegen.rs @@ -274,7 +274,7 @@ fn nested_text_style_uses_typography() { assert!(output.tsx.contains("{\" \"}왔어?
다음 줄")); assert!(output.tsx.contains("fontSize=\"16px\"")); assert!(output.tsx.contains("lineHeight=\"normal\"")); - assert!(!output.tsx.contains("fontWeight=\"600\"")); + assert!(output.tsx.contains("fontWeight=\"600\"")); } #[test] diff --git a/crates/devup-mcp-devup-ui/tests/rich_text_weight.rs b/crates/devup-mcp-devup-ui/tests/rich_text_weight.rs new file mode 100644 index 0000000..ef04aa7 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/rich_text_weight.rs @@ -0,0 +1,92 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +/// Korean text keeps `word-break: keep-all`, and this test exists to stop it +/// being removed again as a fidelity optimisation. Figma breaks Korean +/// mid-word, so deleting this measurably narrows the pixel gap - by 0.21 +/// percent on one screen - while chopping every Korean word in the generated +/// screen and breaking 38 plugin byte-parity goldens. Figma's behaviour is a +/// limitation to compensate for, not a specification to reproduce; the plugin +/// makes the same call in its own text renderer. +#[test] +fn korean_characters_keep_words_whole() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"test", "version":"1", "roots":["t"], "diagnostics":[], + "nodes":{"t":{"id":"t","type":"TEXT","fields":{ + "characters":"정신건강간호사입니다", "width":100, + "layoutSizingHorizontal":"FIXED", "textAutoResize":"HEIGHT", + "styledTextSegments":[{"characters":"정신건강간호사입니다"}] + },"extra":{},"fieldErrors":{}}} + })) + .unwrap(); + let output = generate_component(&snapshot, "t", &CodegenOptions::default()).unwrap(); + assert!( + output.tsx.contains("wordBreak=\"keep-all\""), + "{}", + output.tsx + ); +} + +/// The weight fix must not start emitting the constraint on text that has no +/// Korean in it. +#[test] +fn latin_only_text_gets_no_word_break_constraint() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"test", "version":"1", "roots":["t"], "diagnostics":[], + "nodes":{"t":{"id":"t","type":"TEXT","fields":{ + "characters":"Mental health nurse", "width":100, + "layoutSizingHorizontal":"FIXED", "textAutoResize":"HEIGHT", + "styledTextSegments":[{"characters":"Mental health nurse"}] + },"extra":{},"fieldErrors":{}}} + })) + .unwrap(); + let output = generate_component(&snapshot, "t", &CodegenOptions::default()).unwrap(); + assert!(!output.tsx.contains("wordBreak"), "{}", output.tsx); +} + +#[test] +fn shared_typography_token_preserves_resolved_weight_and_segment_provenance() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"test", "version":"1", "roots":["t"], "diagnostics":[], + "nodes":{"t":{"id":"t","type":"TEXT","fields":{ + "characters":"Bold regular text continues", + "styledTextSegments":[ + {"characters":"Bold", "textStyleId":"body", "fontWeight":700}, + {"characters":" regular text continues", "textStyleId":"body", "fontWeight":400} + ] + },"extra":{},"fieldErrors":{}}} + })) + .unwrap(); + let options = CodegenOptions { + text_style_tokens: [("body".into(), "body".into())].into(), + ..CodegenOptions::default() + }; + let output = generate_component(&snapshot, "t", &options).unwrap(); + assert!(output.tsx.contains("fontWeight=\"700\""), "{}", output.tsx); + assert!(output.tsx.contains("fontWeight=\"400\""), "{}", output.tsx); + assert!( + output + .source_map + .entries + .iter() + .any( + |entry| entry.generated_property.as_deref() == Some("fontWeight=\"400\"") + && entry.property.as_deref() == Some("styledTextSegments") + ), + "{:#?}", + output.source_map + ); + assert!( + output + .source_map + .entries + .iter() + .any( + |entry| entry.generated_property.as_deref() == Some("fontWeight=\"700\"") + && entry.property.as_deref() == Some("styledTextSegments") + ), + "{:#?}", + output.source_map + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap index 5532a08..616dc45 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap @@ -24,6 +24,7 @@ export function AStoryFProofread() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -126,6 +132,7 @@ export function AStoryFProofread() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -327,6 +350,7 @@ export function AStoryFProofread() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -120,6 +126,7 @@ export function AStoryFProofread() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -321,6 +344,7 @@ export function AStoryFProofread() { \n 작은 시장, 큰 사랑\n ", + "generatedSource": " \n 작은 시장, 큰 사랑\n ", "sourceTruncated": false, "propertyMappingVerified": false }, @@ -106,7 +106,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": { diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap index c621b33..3e535cf 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap @@ -129,6 +129,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"700\"", + "nodeId": "3879:35520", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"39px\"", "nodeId": "3879:35520", @@ -271,6 +277,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35523", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "3879:35523", @@ -329,6 +341,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "3879:35524", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"29px\"", "nodeId": "3879:35524", @@ -497,6 +515,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35528", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "3879:35528", @@ -555,6 +579,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "3879:35529", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"29px\"", "nodeId": "3879:35529", @@ -801,6 +831,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35535", + "property": "styledTextSegments", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"24px\"", "nodeId": "3879:35535", @@ -846,6 +882,12 @@ expression: output.source_map "variableId": "VariableID:1:995", "resolution": "variable-token" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "3879:35535", + "property": "styledTextSegments", + "resolution": "raw-fallback" + }, { "generatedProperty": "typography=\"captionSemibold\"", "nodeId": "3879:35535", @@ -891,6 +933,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35536", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"22px\"", "nodeId": "3879:35536", @@ -1016,6 +1064,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "3879:35538", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "3879:35538", @@ -1068,6 +1122,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35539", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "3879:35539", @@ -1322,6 +1382,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35545;1690:32933", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "I3879:35545;1690:32933", @@ -1435,6 +1501,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35545;1690:32947", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"22px\"", "nodeId": "I3879:35545;1690:32947", @@ -1542,6 +1614,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"700\"", + "nodeId": "3879:35547", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"31px\"", "nodeId": "3879:35547", @@ -1673,6 +1751,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35549;1690:32933", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "I3879:35549;1690:32933", @@ -1786,6 +1870,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35549;1690:32947", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"22px\"", "nodeId": "I3879:35549;1690:32947", @@ -1893,6 +1983,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35551", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"32px\"", "nodeId": "3879:35551", @@ -2030,6 +2126,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35553;1690:32933", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "I3879:35553;1690:32933", @@ -2143,6 +2245,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35553;1690:32947", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"22px\"", "nodeId": "I3879:35553;1690:32947", @@ -2250,6 +2358,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35555", + "property": "styledTextSegments", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"32px\"", "nodeId": "3879:35555", @@ -2295,6 +2409,12 @@ expression: output.source_map "variableId": "VariableID:98:3276", "resolution": "variable-token" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "3879:35555", + "property": "styledTextSegments", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"31px\"", "nodeId": "3879:35555", @@ -2327,6 +2447,12 @@ expression: output.source_map "variableId": "VariableID:98:3276", "resolution": "variable-token" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "3879:35555", + "property": "styledTextSegments", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"31px\"", "nodeId": "3879:35555", @@ -2451,6 +2577,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35557;1690:32933", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "I3879:35557;1690:32933", @@ -2564,6 +2696,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35557;1690:32947", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"22px\"", "nodeId": "I3879:35557;1690:32947", @@ -2671,6 +2809,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35559", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"32px\"", "nodeId": "3879:35559", @@ -2808,6 +2952,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35561;1690:32933", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"26px\"", "nodeId": "I3879:35561;1690:32933", @@ -2921,6 +3071,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "I3879:35561;1690:32947", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"22px\"", "nodeId": "I3879:35561;1690:32947", @@ -3028,6 +3184,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"400\"", + "nodeId": "3879:35563", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"32px\"", "nodeId": "3879:35563", @@ -3240,6 +3402,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "3879:35566", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"31px\"", "nodeId": "3879:35566", @@ -3339,6 +3507,12 @@ expression: output.source_map "property": "fontSize", "resolution": "raw-fallback" }, + { + "generatedProperty": "fontWeight=\"600\"", + "nodeId": "3879:35568", + "property": "fontWeight", + "resolution": "raw-fallback" + }, { "generatedProperty": "lineHeight=\"31px\"", "nodeId": "3879:35568", diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap index ee1a56b..a81074a 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap @@ -34,6 +34,7 @@ export function Wquw151Frame387935503() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -130,6 +136,7 @@ export function Wquw151Frame387935518() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -341,6 +364,7 @@ export function Wquw151Frame387935518() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -189,6 +198,7 @@ export function Wquw151Frame387935569() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -400,6 +426,7 @@ export function Wquw151Frame387935569() { - + 책 전체 에 적용할 문체를 설정해주세요. @@ -598,6 +634,7 @@ export function Wquw151Frame387935569() { - + 이번 이야기 에만 적용할 교정 방법을 설정해주세요. @@ -720,6 +764,7 @@ export function Wquw151Frame387935569() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -189,6 +198,7 @@ export function Wquw151Frame387935652() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -400,6 +426,7 @@ export function Wquw151Frame387935652() { - + 책 전체 에 적용할 문체를 설정해주세요. @@ -598,6 +634,7 @@ export function Wquw151Frame387935652() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -189,6 +198,7 @@ export function Wquw151Frame387935729() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -400,6 +426,7 @@ export function Wquw151Frame387935729() { - + 이번 이야기 에만 적용할 교정 방법을 설정해주세요. @@ -598,6 +634,7 @@ export function Wquw151Frame387935729() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -189,6 +198,7 @@ export function Wquw151Frame387935887() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -400,6 +426,7 @@ export function Wquw151Frame387935887() { - + 이번 이야기 에만 적용할 교정 방법을 설정해주세요. @@ -689,6 +731,7 @@ export function Wquw151Frame387935887() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -189,6 +198,7 @@ export function Wquw151Frame387935973() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -400,6 +426,7 @@ export function Wquw151Frame387935973() { - + 이번 이야기 에만 적용할 교정 방법을 설정해주세요. @@ -689,6 +731,7 @@ export function Wquw151Frame387935973() { 선택하신 - + {" "}{"'"}담담체 {"'"}를
이야기 전체에 적용하시겠습니까?
글을 다시 정리하므로
시간이 조금 소요될 수 있습니다.
기존에 정리된 글은 사라집니다. @@ -797,6 +845,7 @@ export function Wquw151Frame387935973() { - + [여기] 를 눌러 이야기에
어울리는 사진을 추가해보세요.
사진은 선택사항이며,
나중에 천천히 추가하셔도 괜찮습니다. @@ -109,6 +113,7 @@ export function Wquw151Frame387936144() { - + TIP “엄마!”

나는 반가운 마음에 큰 소리로 어머니를 불렀다. 어머니는 미역이 담긴 비닐봉지를 고르고 계셨는데, 내 목소리에 깜짝 놀라시더니 고개를 돌려 나를 보셨다. 순간 어머니의 얼굴에 번져가는 웃음이 얼마나 따뜻하고 아름다웠는지 모른다. 놀라움이 가득한 표정에서 환한 미소로 변해가는 그 표정을, 나는 아직도 잊을 수 없다.

“우리{" "} - + [1. 이름] {" "}왔어?”

어머니는 장바구니를 들어 올리며 내 앞까지 걸어오셨다. 얼굴에는 하루의 피로가 묻어 있었지만, 나를 보는 눈빛은 그 어떤 보석보다 반짝였다. 나는 어머니가 드신 장바구니를 대신 들어드리려고 손을 뻗었지만, 어머니는 살짝 웃으며 고개를 저으셨다.

“괜찮아. 엄마가 들 수 있어.{" "} - + [1. 이름] {" "}학교 끝났어? 배고프지?” @@ -320,6 +341,7 @@ export function Wquw151Frame387936144() { >(json!({ "characters":"ab", "textTruncation":"DISABLED", - "styledTextSegments":[{"characters":"a","fontSize":16,"fontWeight":400},{"characters":"b","fontSize":16,"fontWeight":700}] + "styledTextSegments":[{"characters":"a","fontSize":16,"fontName":{"family":"Arial"}},{"characters":"b","fontSize":16,"fontName":{"family":"serif"}}] })).unwrap()); let output = generate_component(&data.snapshot, "1:1", &CodegenOptions::default()).unwrap(); assert!( @@ -3336,7 +3336,7 @@ mod w1_regressions { .is_some_and(|issues| issues.iter().any(|d| d["code"] == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" && d["nodeId"] == "1:1" - && d["property"] == "fontWeight" + && d["property"] == "fontFamily" && d["fidelityImpact"] == "none")) ); } diff --git a/docs/about-vertical-geometry.md b/docs/about-vertical-geometry.md new file mode 100644 index 0000000..34d664c --- /dev/null +++ b/docs/about-vertical-geometry.md @@ -0,0 +1,187 @@ +# About text geometry: resolved weights, and why Korean keeps `keep-all` + +Base: `757dc4a`, measured on 2026-09-13 in the main checkout's render harness. + +Two independent text defects were found on the `about` screens. One is fixed +here. The other was implemented, measured, and **deliberately rejected** - it +narrows the pixel gap by chopping Korean words in half. Both are recorded, +because the rejected one is cheap to rediscover and looks like a win from the +metric alone. + +## What shipped: resolved weights survive a typography token + +`bands.mjs`, `drift.mjs`, `crop.mjs`, `boxes.mjs` and `elements.mjs` localised +the difference before any generator change. The wider page's displacement +begins at the Solution paragraph rather than accumulating down the page. On +tablet the 619px-wide text measured 288px tall against the collected 253px box, +and the crop shows `통해` wrapping onto an extra line immediately before an +explicit line separator. Everything below inherits that displacement. + +The collected segments share a text-style ID but explicitly alternate between +weights 400 and 700, and both text-property emitters suppressed `fontWeight` +whenever a typography token was present. The token name carries no resolved +metrics, so the bold runs vanished, and missing bold changes glyph advances and +therefore wrapping. Restoring the resolved weights removes one 36px line on each +wider screen and brings the rendered page height to within 1px of the design: + +| About width | Design height | Baseline height | With resolved weights | +| ---: | ---: | ---: | ---: | +| 360 | 7240 | 7240 | 7240 | +| 992 | 5619 | 5656 (+37) | 5620 (+1) | +| 1920 | 4757 | 4794 (+37) | 4758 (+1) | + +That also rules out a repeated padding contribution as the source of the ~36px +overshoot. The advance and inside-stroke rules from `line-box-displacement.md` +were not touched. No viewport, breakpoint, frame ID, capture height or tuned +numeric constant participates. + +## What was rejected: removing `word-break: keep-all` for Korean + +Mobile has a second, real difference. Figma breaks Korean **within** words; +the generated screen breaks only at spaces, because codegen detects Korean +characters and emits `wordBreak="keep-all"`. Deleting that heuristic was +implemented and measured. It works, by the metric: + +| About width | Baseline | Weights only | Weights + keep-all removed | +| ---: | ---: | ---: | ---: | +| 360 | 7.44% | 7.46% | 7.25% | +| 992 | 6.90% | 4.06% | 3.98% | +| 1920 | 4.19% | 2.41% | 2.41% | + +It is not shipped. The owner's decision, and the reasoning: + +**Figma's Korean line breaking is a limitation to compensate for, not a +specification to reproduce.** 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. Matching Figma's PNG more closely here means generating a +screen whose Korean is chopped mid-word - worse code that scores better. The +plugin settles this the same way, deliberately and with a comment, in its own +text renderer: + +```ts +// Add wordBreak: keep-all for Korean text +if (hasKorean) { + defaultProps.wordBreak = 'keep-all' +} +``` + +The price of removing it was measured precisely: **0.21pp on one screen and +0.08pp on another**, against 38 deleted attributes across 17 plugin +byte-parity goldens and every Korean line break in every generated screen. The +pixel metric is a proxy for correctness, not correctness itself, and this is the +case where the two point in opposite directions. + +`korean_characters_keep_words_whole` in +`crates/devup-mcp-devup-ui/tests/rich_text_weight.rs` locks the decision so the +next fidelity pass cannot quietly reverse it, and the rationale sits on +`segments_contain_korean` in `codegen/text.rs`. + +The wider candidate pages remain 1px taller than the design. No correction was +introduced for that residual. + +## Measurement identity and integrity + +All named binaries were built in the worker worktree's own `target/debug`: + +| Binary | SHA-256 | Role | +| --- | --- | --- | +| `devup-mcp-w14-baseline.exe` | `2CAB1F5AB767179B2D3553BE1F9704A24C20158BDC16DACD2B9B380FAE4E0D67` | Unmodified production at 757dc4a | +| `devup-mcp.exe`, first candidate | `E1E3B90A89476C010C2B8AC2244BC84FF462BF203426D9B52ECE95D469B5FC4C` | Weight preservation only - **this is what shipped** | +| `devup-mcp-w14-weight-wrap.exe` | `784775AF837E71727529AC6D84BC78C3D0E0D856ECF6685F2AB949276E1A3A58` | Weight preservation plus keep-all removal - **rejected** | +| `devup-mcp-w14-final.exe` | `B63D38FD537C6C890E316109E6877C4070F07ACEF84E3C2D1400DB89341181CB` | Same emission, completed segment provenance | + +Each measurement used a fresh `python scripts/acquire.py GROUP` process with +`DEVUP_MCP_BIN` pointing at the named binary, followed immediately by +`node scripts/render.mjs`. A group was rendered before the next was acquired. +No acquire log contains `quality=None` or a nonempty `missing` list. Every +before/after reference PNG is byte-identical, and the before/after theme hashes +printed by render are identical for every screen. All eight non-`about` actual +PNGs are byte-identical before and after, so their unchanged percentages are not +rounding over a smaller regression. + +| Screen | Theme hash, before = after | Baseline | Shipped candidate | +| --- | --- | ---: | ---: | +| about-422-3376 | 67d6de70e679 | 7.44% | 7.46% | +| about-422-3180 | 67d6de70e679 | 6.90% | 4.06% | +| about-422-2987 | 67d6de70e679 | 4.19% | 2.41% | +| landing-833-3640 | e18d9d7e25b4 | 4.99% | 4.99% | +| landing-833-3322 | 87ef9f58fdb8 | 2.47% | 2.47% | +| landing-832-2975 | 3ae50e9a165f | 1.50% | 1.50% | +| popup-422-5682 | d541f2ae9049 | 3.64% | 3.64% | +| popup-422-5705 | d541f2ae9049 | 2.06% | 2.06% | +| popup-422-5728 | d541f2ae9049 | 0.85% | 0.85% | +| grid-429-1966 | a1a8437993a0 | 2.96% | 2.96% | +| keyframes-458-2021 | a1a8437993a0 | 6.71% | 6.71% | + +Mobile's +0.02pp is retained in the record rather than smoothed away. It is the +cost of correct bold runs, and the wider screens pay it back many times over. + +### Reproduced independently + +The coordinator repeated the paired measurement in the main checkout with its +own binaries, built from `main` at `1575b58` and from this branch, and obtained +the same theme hash and the same figures to the decimal: + +| About width | Baseline `1575b58` | Candidate | Baseline height | Candidate height | +| ---: | ---: | ---: | ---: | ---: | +| 360 | 7.44% | 7.46% | 7240 | 7240 | +| 992 | 6.90% | 4.06% | 5656 | 5620 | +| 1920 | 4.19% | 2.41% | 4794 | 4758 | + +`landing` (4.99 / 2.47 / 1.50), `popup` (3.64 / 2.06 / 0.85), `grid` (2.96) and +`keyframes` (6.71) are unchanged between the two runs. Two environments +reaching identical figures is what makes the delta credible; neither +environment's *absolute* level is yet reproducible across sessions, which is +why no threshold moved. + +### The harness does not reproduce across sessions + +The supplied `about` baseline of 11.24 / 6.92 / 4.37 was **not** reproduced by +an unmodified build of the same commit. Popup's supplied `f26ad027d6de` theme +was likewise not reproduced: isolated acquisition repeatedly produced +`d541f2ae9049` and 3.64 / 2.06 / 0.85. Landing, grid and keyframes reproduce +exactly. + +Isolating each group into a fresh acquisition process - the coordinator's first +hypothesis - does **not** resolve it. Two sessions on the same commit with the +same binary can therefore print different theme hashes and different absolute +percentages, which means no absolute figure in `thresholds.json` is currently +backed by a reproducible measurement. + +Acceptance was consequently changed mid-task to paired deltas against the +measurer's own unmodified baseline, with the before/after theme hash required to +match. `thresholds.json` is deliberately **left unchanged** here: lowering an +entry to a number one environment produced would assert a reproducibility that +does not exist yet. Harness determinism is tracked separately. + +## Tests first + +Recorded RED output, in the main harness `out/`: + +* `w14-weight-red.log` - 0 passed, 1 failed: token-bearing text omitted both resolved weights. +* `w14-weight-provenance-red.log` - 1 passed, 1 failed: the outer default weight pointed at a node property instead of its actual styled segment. + +Explicit legacy node weights keep their node-property mapping; segment-sourced +weights map to `styledTextSegments`. The strict projection mapping-gap test now +uses differing font families, which is a remaining real gap, and keeps its +strict rejection and diagnostic assertions. The old typography test now expects +its explicitly supplied weight 600. `korean_characters_keep_words_whole` and +`latin_only_text_gets_no_word_break_constraint` fix the wrapping behaviour in +both directions. + +## Reviewed plugin byte-parity goldens + +Exactly **one** of 268 goldens changes: + +| Golden stem | Reviewed reason | +| --- | --- | +| upstream-codegen-109-e2824ad5be | Token-bearing segment explicitly carries weight 400; emit it | + +Its snapshot checksum in `fixtures/devup-figma-plugin/manifest.json` was synced. +No corpus input, count or consistency assertion was weakened. Keeping `keep-all` +is what holds the other 16 goldens at parity; the rejected candidate would have +changed all 17. + +The WQUW proofread and frame snapshots change only by gaining weights, with +`wordBreak="keep-all"` intact throughout. Their source-map entries and weight +ownership follow the actual emission. diff --git a/fixtures/devup-figma-plugin/manifest.json b/fixtures/devup-figma-plugin/manifest.json index 83ac446..ed1749a 100644 --- a/fixtures/devup-figma-plugin/manifest.json +++ b/fixtures/devup-figma-plugin/manifest.json @@ -1579,7 +1579,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-109-e2824ad5be.snap", - "sha256": "ef653c1dff43907bd0735cb13407486bba3596df87ac0bc328c27d67ccb7ddc1" + "sha256": "a91faf7ec1cfcc54c0418c362c74446dc25b49995471d36ce498bb9bcc679524" }, { "path": "snapshots/codegen/upstream-codegen-110-f2505cce4b.snap", diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-109-e2824ad5be.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-109-e2824ad5be.snap index e990980..1ea876d 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-109-e2824ad5be.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-109-e2824ad5be.snap @@ -7,6 +7,7 @@ expression: actual boxSize="100%" color="#F00" fontSize="16px" + fontWeight="400" lineHeight="1.5px" typography="typographyHeading" >