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
5 changes: 5 additions & 0 deletions .changepacks/changepack_log_hard_break_whitespace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"changes": { "crates/devup-mcp-devup-ui/Cargo.toml": "Minor" },
"note": "The painted text is the text again, in one more place. A space the designer typed before an explicit line break is collapsed away by CSS, so a paragraph that reads one way in Figma read another way on screen. W8 established that painted text must equal characters and reported the remaining collapse as an explicit finding because no emission it had could avoid it; pre-wrap avoids it for a bounded case, so that case moves from reported to fixed and the diagnostic stays for everything still unavoidable. The condition is narrow on purpose: TEXT whose inline width Figma fixes while the height grows, with no positive maxLines, no list options, no tabs, and a space actually adjacent to a break. HUG text is excluded because pre-wrap counts trailing spaces in max-content width and would change intrinsic sizing, and clamped text keeps its existing projection. Korean word breaking is untouched. Four plugin goldens gain six whiteSpace attributes, each reviewed against its collected fixture, because the plugin drops a space the design contains - the opposite of the keep-all case, where matching Figma would have produced worse Korean and the compensation was deliberately kept. The source map gains derived-hard-break-whitespace, whose description states that it preserves source whitespace without claiming intrinsic HUG sizing, glyph parity or identical wrapping, and one lossy fidelity impact is retired rather than left double-counted alongside the diagnostic. Measured against Figma's reference PNG, about mobile improves from 5.51 to 5.43 percent, about tablet from 3.01 to 2.93, landing mobile from 4.99 to 4.78 and notice mobile from 3.84 to 3.74, with the other eleven screens unchanged.",
"date": "2026-09-13T22:10:00+09:00"
}
1 change: 1 addition & 0 deletions crates/devup-mcp-devup-ui/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ 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;
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ 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_hard_break_spaces(&node.typed_view()) => (vec!["characters", "styledTextSegments", "textAutoResize", "maxLines"], "Spaces adjacent to explicit breaks in non-list, unclamped text with fixed inline width use pre-wrap. This preserves source whitespace without changing Korean word-breaking policy; it does not establish glyph or wrapping parity."),
"textDecoration" => (vec!["textDecoration", "styledTextSegments"], "Text UNDERLINE/STRIKETHROUGH maps to underline/line-through."),
"textTransform" => (vec!["textCase", "styledTextSegments"], "Text case projection follows push_text_props."),
"as" | "my" => (vec!["styledTextSegments", "listOptions", "paragraphSpacing"], "Text list/paragraph projection uses the emitted semantic tag and spacing policy."),
Expand Down
64 changes: 52 additions & 12 deletions crates/devup-mcp-devup-ui/src/codegen/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ pub(super) fn push_text_props(
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
Expand Down Expand Up @@ -615,20 +618,10 @@ fn push_edge_whitespace(run: &[char], into: &mut String) {
pub(super) fn whitespace_collapse_diagnostic(
view: &TypedNode<'_>,
) -> Option<devup_mcp_figma::Diagnostic> {
if view.node_type() != "TEXT" {
if view.node_type() != "TEXT" || preserves_hard_break_spaces(view) {
return None;
}
let characters = 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()
});
let characters = source_characters(view);
let collapses = characters
.split(['\r', '\n', '\u{2028}', '\u{2029}'])
.any(|line| {
Expand Down Expand Up @@ -656,3 +649,50 @@ pub(super) fn whitespace_collapse_diagnostic(
..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 == ' '))
}
19 changes: 17 additions & 2 deletions crates/devup-mcp-devup-ui/src/provenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ impl SourceMap {
.map(|end| source[at..at + 7 + end].to_owned())
})
}
("derived-hard-break-whitespace", _, Some(source)) => Some(source.trim().into()),
(_, "characters", Some(_)) => Some("children".into()),
(_, _, Some(source)) if !source.trim().is_empty() => Some(source.trim().into()),
_ => None,
Expand Down Expand Up @@ -1580,7 +1581,12 @@ pub(crate) fn finalize_tsx(
.find_map(|(id, token)| (token == value).then(|| id.clone()))
})
.flatten();
let resolution = if *prop == "boxShadow"
let resolution = if *prop == "whiteSpace"
&& value == "pre-wrap"
&& crate::codegen::preserves_hard_break_spaces(&node.typed_view())
{
"derived-hard-break-whitespace"
} else if *prop == "boxShadow"
&& crate::codegen::asset_kind(snapshot, node).is_none()
&& crate::codegen::single_outside_stroke(&node.typed_view()).is_some()
{
Expand Down Expand Up @@ -1609,7 +1615,16 @@ pub(crate) fn finalize_tsx(
} else {
"raw-fallback"
};
let source_property = if *prop == "minW"
let source_property = if *prop == "whiteSpace"
&& value == "pre-wrap"
&& crate::codegen::preserves_hard_break_spaces(&node.typed_view())
{
if node.typed_view().string("characters").is_some() {
"characters"
} else {
"styledTextSegments"
}
} else if *prop == "minW"
&& value == "0"
&& node.typed_view().number("minWidth").is_none()
&& node.typed_view().string("layoutSizingHorizontal") == Some("FILL")
Expand Down
1 change: 1 addition & 0 deletions crates/devup-mcp-devup-ui/src/provenance/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub fn resolution_semantics() -> Value {
"raw-fallback":"Value/policy mapping, not fidelity; ABSOLUTE can separately be verified.",
"derived-lone-child-center":"Horizontal/vertical SPACE_BETWEEN becomes center for exactly one visible non-ABSOLUTE child; child membership, visibility and positioning determine the count. This mapping does not claim pixel parity.",
"derived-single-edge-outside-stroke":"One solid, square-cornered OUTSIDE edge on a non-asset auto-layout node paints as a zero-blur translated box shadow without consuming layout space; side weights and stroke paint determine the translation and color. Existing effects are composed after the stroke. This is a stroke projection, not an assertion that Figma supplied a shadow effect, a claim about exported-asset composition, or a claim of pixel parity.",
"derived-hard-break-whitespace":"Source spaces adjacent to explicit line breaks select pre-wrap for non-list, unclamped text whose inline width is fixed and height is automatic. Characters come from the node or collected styled segments. This preserves source whitespace without changing Korean keep-all; it does not claim intrinsic HUG sizing, glyph parity or identical wrapping.",
"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.",
Expand Down
170 changes: 170 additions & 0 deletions crates/devup-mcp-devup-ui/tests/hard_break_whitespace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component};
use devup_mcp_figma::Snapshot;
use serde_json::json;

fn text(characters: &str, resize: &str) -> Snapshot {
serde_json::from_value(json!({
"fileKey":"test", "version":"1", "roots":["text"], "diagnostics":[],
"nodes":{"text":{"id":"text","type":"TEXT","fields":{
"characters":characters,"styledTextSegments":[{"characters":characters}],
"textAutoResize":resize,"textTruncation":"DISABLED",
"width":197,"textAlignHorizontal":"CENTER"
},"extra":{},"fieldErrors":{}}}
}))
.unwrap()
}

#[test]
fn explicit_break_spaces_survive_at_fixed_inline_width() {
for width in [197, 523] {
for characters in [
"First line \nSecond",
"First \r\nSecond",
"First \u{2028}Second",
"First\u{2029} Second",
"첫 줄 \n둘째 줄",
] {
let mut snapshot = text(characters, "HEIGHT");
snapshot
.nodes
.get_mut("text")
.unwrap()
.fields
.insert("width".into(), json!(width));
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 characters.contains('첫') {
assert!(output.tsx.contains("wordBreak=\"keep-all\""));
}
}
}
}

#[test]
fn preserving_break_spaces_has_derived_character_provenance() {
let snapshot = text("First \nSecond", "HEIGHT");
let output = generate_component(&snapshot, "text", &CodegenOptions::default()).unwrap();
assert!(
output
.source_map
.entries
.iter()
.any(
|entry| entry.generated_property.as_deref() == Some("whiteSpace=\"pre-wrap\"")
&& entry.property.as_deref() == Some("characters")
&& entry.resolution == "derived-hard-break-whitespace"
),
"{:#?}",
output.source_map
);
}

#[test]
fn segment_boundary_spaces_use_segment_provenance() {
let mut snapshot = text("", "HEIGHT");
let fields = &mut snapshot.nodes.get_mut("text").unwrap().fields;
fields.remove("characters");
fields.insert(
"styledTextSegments".into(),
json!([
{"characters":"First "}, {"characters":"\nSecond"}
]),
);
let output = generate_component(&snapshot, "text", &CodegenOptions::default()).unwrap();
assert!(
output.tsx.contains("whiteSpace=\"pre-wrap\""),
"{}",
output.tsx
);
assert!(
output
.source_map
.entries
.iter()
.any(
|entry| entry.generated_property.as_deref() == Some("whiteSpace=\"pre-wrap\"")
&& entry.property.as_deref() == Some("styledTextSegments")
&& entry.resolution == "derived-hard-break-whitespace"
)
);
}

#[test]
fn unrelated_whitespace_and_constrained_text_keep_existing_policy() {
for characters in [
"Single trailing ",
" leading",
"Two spaces",
"First\nSecond",
] {
let output = generate_component(
&text(characters, "HEIGHT"),
"text",
&CodegenOptions::default(),
)
.unwrap();
assert!(!output.tsx.contains("whiteSpace=\"pre-wrap\""));
}
for max_lines in [1, 2] {
let mut snapshot = text("First \nSecond", "HEIGHT");
snapshot
.nodes
.get_mut("text")
.unwrap()
.fields
.insert("maxLines".into(), json!(max_lines));
let output = generate_component(&snapshot, "text", &CodegenOptions::default()).unwrap();
assert!(!output.tsx.contains("whiteSpace=\"pre-wrap\""));
assert!(
output
.diagnostics
.iter()
.any(|d| d.code == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE")
);
}
for resize in ["NONE", "WIDTH_AND_HEIGHT", ""] {
let output = generate_component(
&text("First \nSecond", resize),
"text",
&CodegenOptions::default(),
)
.unwrap();
assert!(!output.tsx.contains("whiteSpace=\"pre-wrap\""));
}
for list in [false, true] {
let mut snapshot = text("First \nSecond", "HEIGHT");
if list {
snapshot.nodes.get_mut("text").unwrap().fields.insert(
"styledTextSegments".into(),
json!([
{"characters":"First \nSecond", "listOptions":{"type":"UNORDERED"}}
]),
);
} else {
snapshot
.nodes
.get_mut("text")
.unwrap()
.fields
.insert("characters".into(), json!("First\t \nSecond"));
}
let output = generate_component(&snapshot, "text", &CodegenOptions::default()).unwrap();
assert!(!output.tsx.contains("whiteSpace=\"pre-wrap\""));
assert!(
output
.diagnostics
.iter()
.any(|d| d.code == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE")
);
}
}
30 changes: 29 additions & 1 deletion crates/devup-mcp-devup-ui/tests/jsx_text_fidelity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ fn jsx_existing_fixture_texts_round_trip() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut count = 0;
let mut css_collapsing = 0;
let mut css_preserved = 0;
let mut failures = Vec::new();
for path in json_files(&root.join("../../fixtures/devup-figma-plugin/cases"))
.into_iter()
Expand Down Expand Up @@ -405,6 +406,24 @@ fn jsx_existing_fixture_texts_round_trip() {
{
css_collapsing += 1;
}
if output.tsx.contains("whiteSpace=\"pre-wrap\"") {
assert_eq!(view.string("textAutoResize"), Some("HEIGHT"));
assert!(
output.source_map.entries.iter().any(|entry| entry
.generated_property
.as_deref()
== Some("whiteSpace=\"pre-wrap\"")
&& entry.resolution == "derived-hard-break-whitespace")
);
assert!(
!output
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code
== "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE")
);
css_preserved += 1;
}
count += 1;
if actual != design_breaks(&expected) {
failures.push(format!(
Expand All @@ -416,7 +435,16 @@ fn jsx_existing_fixture_texts_round_trip() {
}
}
assert!(count > 400, "representative sweep shrank: {count}");
assert_eq!(css_collapsing, 35, "measured CSS-collapse population");
assert_eq!(
css_collapsing + css_preserved,
35,
"original whitespace population"
);
assert_eq!(css_collapsing, 5, "remaining CSS-collapse population");
assert_eq!(
css_preserved, 30,
"fixed-inline-width text now preserves its spaces"
);
assert!(
failures.is_empty(),
"{count} texts, {} failures:\n{}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function AStoryFProofread() {
textAlign="center"
typography="h3"
w="100%"
whiteSpace="pre-wrap"
wordBreak="keep-all"
>
이야기가 글로 <br />정리되었어요
Expand Down Expand Up @@ -121,6 +122,7 @@ export function AStoryFProofread() {
textAlign="center"
typography="caption"
w="100%"
whiteSpace="pre-wrap"
wordBreak="keep-all"
>
<Text color="$primary" fontWeight="600" typography="captionSemibold">
Expand Down Expand Up @@ -163,6 +165,7 @@ export function AStoryFProofread() {
fontWeight="400"
lineHeight="26px"
typography="bodyXs"
whiteSpace="pre-wrap"
wordBreak="keep-all"
>
각 단락을 선택해서 고유명사를 수정하거나, <br />의도와 다르게 작성된 이야기를 원하는 내용으로 직접 바꿀 수 있습니다.{" "}
Expand Down
Loading