Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_resolved_text_weights.json
Original file line number Diff line number Diff line change
@@ -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"
}
20 changes: 14 additions & 6 deletions crates/devup-mcp-devup-ui/src/codegen/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -219,6 +219,16 @@ fn line_height(value: Option<&Value>, font_size: Option<f64>) -> Option<String>
}
}

/// 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)
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 11 additions & 3 deletions crates/devup-mcp-devup-ui/src/provenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion crates/devup-mcp-devup-ui/tests/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ fn nested_text_style_uses_typography() {
assert!(output.tsx.contains("{\" \"}왔어?<br />다음 줄"));
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]
Expand Down
92 changes: 92 additions & 0 deletions crates/devup-mcp-devup-ui/tests/rich_text_weight.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading