Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_pF7kQ2wNx9LmR4tVb8sYc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Keep Korean word boundaries with textWrap pretty, reset generated heading margins, keep extra-fill asset paths distinct, label missing-only devupJson as additions-only, flag hidden component masters, and report on-disk facts for every asset output path.",
"date": "2026-11-10T00:00:00.000000000Z"
}
2 changes: 2 additions & 0 deletions crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ pub(crate) fn property_derivations(
"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" | "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."),
"textWrap" => (vec!["characters", "textAutoResize", "maxLines", "width"], "Korean soft-wrapping text adds textWrap pretty beside keep-all so line breaks stay on word boundaries without large trailing gaps; it is omitted for whiteSpace pre/nowrap (text-wrap would re-enable wrapping) and for explicit local/theme textWrap."),
"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")),
Expand Down Expand Up @@ -154,6 +155,7 @@ pub(crate) fn property_derivations(
"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."),
"my" if *stage == "semantic" => (vec!["name", "parentId"], "A named heading layer becomes an HTML heading; its user-agent block margins are reset to 0px because the drawn Figma text has no such margin."),
"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" =>
Expand Down
13 changes: 13 additions & 0 deletions crates/devup-mcp-devup-ui/src/codegen/semantics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,18 @@ pub(super) fn push_semantic_props(
};
if let Some(tag) = tag {
string_prop(props, "as", tag);
// Headings carry user-agent block margins (e.g. ~0.67em on h1) that the
// drawn Figma text never had; neutralize them unless a margin is drawn.
if is_heading(tag)
&& !props
.iter()
.any(|(name, _)| matches!(name.as_str(), "m" | "my" | "mt" | "mb"))
{
string_prop(props, "my", "0px");
}
}
}

fn is_heading(tag: &str) -> bool {
matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
}
23 changes: 18 additions & 5 deletions crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
/// `{nodeId}:fills:{index}`, so the reference keeps the node's name and, past
/// the first fill, its index — a lone fill keeps the plain
/// `/images/{name}.png` the `<Image>` element already emits, so the two agree
/// on the same asset.
/// on the same asset. See `fill_file_stem` for the extra-fill suffix.
fn image_fill_source(
snapshot: &Snapshot,
node: &RawNode,
Expand All @@ -18,8 +18,8 @@ fn image_fill_source(
let source = if fill_index == 0 {
asset_source(snapshot, node, "images", "png", per_node)
} else {
let stem = asset_stem(snapshot, node, per_node);
format!("/images/{stem}-{fill_index}.png")
let stem = fill_file_stem(&asset_stem(snapshot, node, per_node), fill_index);
format!("/images/{stem}.png")
};
if source.contains(' ') {
format!("'{source}'")
Expand Down Expand Up @@ -64,8 +64,21 @@ pub fn asset_path(snapshot: &Snapshot, node_id: &str, per_node: bool) -> Option<
Some(asset_source(snapshot, node, folder, extension, per_node))
}

/// The file stem for a node's extra image fill (`fill_index > 0`):
/// `{stem}--fill-{fill_index}`, with the same 0-based index as the manifest's
/// `{nodeId}:fills:{index}`.
///
/// A plain `-{index}` suffix collided with the `-2`, `-3` that `asset_stem`
/// gives a different asset sharing the base name: fill 2 of `hero` and the
/// second `hero` asset were both `hero-2.png`. Every stem is kebab-case with
/// hyphens collapsed, so no stem (deduplicated or not) contains `--`, and an
/// extra-fill path cannot equal any other asset's path.
fn fill_file_stem(stem: &str, fill_index: usize) -> String {
format!("{stem}--fill-{fill_index}")
}

/// Where the code draws one of a node's image fills from: `/images/x.png`
/// for the first fill and `/images/x-2.png` past it, the same name
/// for the first fill and `/images/x--fill-1.png` for the second, the same name
/// `image_fill_source` writes into the code but without the quoting a CSS
/// `url()` puts around a name with a space in it.
///
Expand All @@ -85,7 +98,7 @@ pub fn image_fill_path(
Some(if fill_index == 0 {
format!("/images/{stem}.png")
} else {
format!("/images/{stem}-{fill_index}.png")
format!("/images/{}.png", fill_file_stem(&stem, fill_index))
})
}

Expand Down
22 changes: 18 additions & 4 deletions crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,39 @@ fn segments_contain_korean(view: &TypedNode<'_>) -> bool {
})
}

fn theme_has_word_break(value: &Value) -> bool {
fn theme_has_key(value: &Value, key: &str) -> bool {
match value {
Value::Array(values) => values.iter().any(theme_has_word_break),
Value::Object(value) => value.contains_key("wordBreak"),
Value::Array(values) => values.iter().any(|value| theme_has_key(value, key)),
Value::Object(value) => value.contains_key(key),
_ => false,
}
}

/// Korean copy wraps at word boundaries even in measured fixed-width text.
/// `textWrap="pretty"` lets the browser rebalance word-boundary breaks so
/// keep-all does not leave a large trailing gap or a single orphaned word.
/// `text-wrap` is a CSS shorthand that also resets `text-wrap-mode` to
/// `wrap`, so it is never added to text whose `whiteSpace` (`pre`/`nowrap`)
/// deliberately disables soft wrapping: it would re-enable wrapping there.
fn push_korean_wrap_policy(view: &TypedNode<'_>, theme: Option<&Value>, props: &mut Vec<Prop>) {
if !segments_contain_korean(view)
|| props.iter().any(|(name, _)| name == "wordBreak")
|| theme.is_some_and(theme_has_word_break)
|| theme.is_some_and(|value| theme_has_key(value, "wordBreak"))
{
return;
}
string_prop(props, "wordBreak", "keep-all");
if !props.iter().any(|(name, _)| name == "overflowWrap") {
string_prop(props, "overflowWrap", "break-word");
}
if !props.iter().any(|(name, _)| name == "textWrap")
&& !props.iter().any(|(name, value)| {
name == "whiteSpace"
&& matches!(value, PropValue::String(mode) if mode == "pre" || mode == "nowrap")
})
&& !theme.is_some_and(|value| theme_has_key(value, "textWrap"))
{
string_prop(props, "textWrap", "pretty");
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,66 @@ mod wrapping_policy_tests {
assert_eq!(props.len(), 1);
assert!(matches!(&props[0].1, PropValue::String(value) if value == "break-all"));
}
}

fn props_for(characters: &str, existing: Vec<Prop>) -> Vec<Prop> {
let snapshot: devup_mcp_figma::Snapshot = serde_json::from_value(serde_json::json!({
"fileKey": "test", "version": null, "diagnostics": [], "nodes": {"t": {"id": "t", "type": "TEXT", "fields": {"name": "Copy", "characters": characters}}},
"roots": ["t"]
}))
.unwrap();
let mut props = existing;
push_korean_wrap_policy(&snapshot.nodes["t"].typed_view(), None, &mut props);
props
}

fn value_of<'a>(props: &'a [Prop], key: &str) -> Option<&'a str> {
props.iter().find(|(name, _)| name == key).map(|(_, value)| {
let PropValue::String(value) = value;
value.as_str()
})
}

#[test]
fn korean_wrapping_text_keeps_word_boundaries_and_pretty_wrap() {
let props = props_for("진단 결과를 바탕으로 개선을 제안합니다", Vec::new());
assert_eq!(value_of(&props, "wordBreak"), Some("keep-all"));
assert_eq!(value_of(&props, "overflowWrap"), Some("break-word"));
assert_eq!(value_of(&props, "textWrap"), Some("pretty"));
}

#[test]
fn pretty_wrap_never_reenables_wrapping_for_pre_or_nowrap_text() {
for mode in ["pre", "nowrap"] {
let props = props_for(
"편집 설정",
vec![("whiteSpace".into(), PropValue::String(mode.into()))],
);
assert_eq!(value_of(&props, "wordBreak"), Some("keep-all"), "{mode}");
assert_eq!(value_of(&props, "textWrap"), None, "{mode}");
}
let props = props_for(
"편집 설정",
vec![("whiteSpace".into(), PropValue::String("pre-wrap".into()))],
);
assert_eq!(value_of(&props, "textWrap"), Some("pretty"));
}

#[test]
fn explicit_local_or_theme_text_wrap_is_preserved() {
let props = props_for(
"한국어 문장",
vec![("textWrap".into(), PropValue::String("balance".into()))],
);
assert_eq!(value_of(&props, "textWrap"), Some("balance"));
let snapshot: devup_mcp_figma::Snapshot = serde_json::from_value(serde_json::json!({
"fileKey": "test", "version": null, "diagnostics": [], "nodes": {"t": {"id": "t", "type": "TEXT", "fields": {"name": "Copy", "characters": "한국어 문장"}}},
"roots": ["t"]
}))
.unwrap();
let theme = serde_json::json!([{"textWrap": "balance"}]);
let mut props = Vec::new();
push_korean_wrap_policy(&snapshot.nodes["t"].typed_view(), Some(&theme), &mut props);
assert_eq!(value_of(&props, "textWrap"), None);
assert_eq!(value_of(&props, "wordBreak"), Some("keep-all"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ impl<'a> Visit<'a> for Collector<'_> {
&& 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."));
"Korean content explicitly uses wordBreak normal and can split within a word.".into(), "Use keep-all with textWrap pretty so lines break on word boundaries without large trailing gaps; handle long URLs and tokens with overflowWrap break-word."));
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/devup-mcp-devup-ui/tests/asset_boundaries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ fn separate_image_fills_do_not_claim_the_same_file() {
);

assert!(tsx.contains("/images/card.png"), "{tsx}");
assert!(tsx.contains("/images/card-1.png"), "{tsx}");
assert!(tsx.contains("/images/card--fill-1.png"), "{tsx}");
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/devup-mcp-devup-ui/tests/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ fn generates_deterministic_devup_ui_tsx() {
" fontSize=\"16px\"\n",
" lineHeight=\"24px\"\n",
" overflowWrap=\"break-word\"\n",
" textWrap=\"pretty\"\n",
" wordBreak=\"keep-all\"\n",
" >\n",
" 안녕 {\"<\"}Devup{\">\"}\n",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ fn cropped_export_background_maps_to_its_actual_fill_and_asset() {
let mut wrong = output.clone();
wrong.tsx = wrong
.tsx
.replace("/images/portrait-1.png", "/images/unrelated.png");
.replace("/images/portrait--fill-1.png", "/images/unrelated.png");
let report =
devup_mcp_devup_ui::provenance::validate_fidelity(&snapshot, "picture", &wrong).unwrap();
assert!(!report.assets.complete());
Expand Down
49 changes: 49 additions & 0 deletions crates/devup-mcp-devup-ui/tests/image_asset_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,52 @@ fn distinct_svg_variants_do_not_share_a_filename_when_per_node_is_off() {
asset_path(&snapshot, "b", false)
);
}

/// Feedback 006: extra fills were `{stem}-{index}` while a different asset
/// with the same base name was deduplicated to `{stem}-2`, so fill 2 of the
/// first `hero` and the second `hero` asset both became `hero-2.png`.
#[test]
fn extra_fill_paths_cannot_collide_with_deduplicated_asset_names() {
let snapshot: Snapshot = serde_json::from_value(json!({"fileKey":"fixture","roots":["root"],"diagnostics":[],"nodes":{
"root":{"id":"root","type":"FRAME","fields":{"name":"Main","childrenIds":["1:1","1:2","1:3"]}},
"1:1":{"id":"1:1","type":"RECTANGLE","fields":{"name":"Hero","parentId":"root","isAsset":true,"fills":[
{"type":"IMAGE","imageHash":"photo-a"},{"type":"IMAGE","imageHash":"photo-b"},{"type":"IMAGE","imageHash":"photo-c"}]}},
"1:2":{"id":"1:2","type":"RECTANGLE","fields":{"name":"Hero","parentId":"root","isAsset":true,"fills":[{"type":"IMAGE","imageHash":"photo-d"}]}},
"1:3":{"id":"1:3","type":"RECTANGLE","fields":{"name":"Hero","parentId":"root","isAsset":true,"fills":[{"type":"IMAGE","imageHash":"photo-e"}]}}
}})).unwrap();
for per_node in [false, true] {
let mut paths = Vec::new();
for fill in 0..3 {
paths.push(image_fill_path(&snapshot, "1:1", fill, per_node).unwrap());
}
paths.push(image_fill_path(&snapshot, "1:2", 0, per_node).unwrap());
paths.push(image_fill_path(&snapshot, "1:3", 0, per_node).unwrap());
assert_eq!(
paths,
[
"/images/hero.png",
"/images/hero--fill-1.png",
"/images/hero--fill-2.png",
"/images/hero-2.png",
"/images/hero-3.png",
],
"per_node={per_node}"
);
let unique = paths.iter().collect::<std::collections::BTreeSet<_>>();
assert_eq!(unique.len(), paths.len(), "{paths:?}");
// The code refers to exactly the paths the manifest reports.
let tsx = generate_component(
&snapshot,
"root",
&CodegenOptions {
asset_names_per_node: per_node,
..Default::default()
},
)
.unwrap()
.tsx;
for path in &paths[1..3] {
assert!(tsx.contains(path.as_str()), "{path}: {tsx}");
}
}
}
58 changes: 58 additions & 0 deletions crates/devup-mcp-devup-ui/tests/semantic_html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,61 @@ fn responsive_inlining_retains_concrete_text_and_style_overrides() {
}
assert!(expanded.referenced_components().is_empty());
}

#[test]
fn semantic_headings_reset_user_agent_block_margins() {
let nodes = json!({
"page":{"id":"page","type":"FRAME","fields":{"name":"Screen","childrenIds":["h1","h2","body"],"layoutMode":"VERTICAL"}},
"h1":{"id":"h1","type":"TEXT","fields":{"name":"Page title","characters":"Research","fontSize":48}},
"h2":{"id":"h2","type":"TEXT","fields":{"name":"Heading 2","characters":"Results","fontSize":40}},
"body":{"id":"body","type":"TEXT","fields":{"name":"Body","characters":"Plain copy","fontSize":16}}
});
let output = generate_component(
&snapshot(nodes, json!(["page"])),
"page",
&CodegenOptions::default(),
)
.unwrap();
for tag in ["h1", "h2"] {
// Formatted JSX puts props on separate lines; inspect the element's
// whole opening-tag prop block (from `<` up to the closing `>`).
let marker = format!("as=\"{tag}\"");
let at = output
.tsx
.find(&marker)
.unwrap_or_else(|| panic!("{}", output.tsx));
let start = output.tsx[..at].rfind('<').unwrap();
let end = at + output.tsx[at..].find('>').unwrap();
let element = &output.tsx[start..end];
assert!(element.contains("my=\"0px\""), "{tag}: {}", output.tsx);
}
assert_eq!(
output.tsx.matches("my=\"0px\"").count(),
2,
"{}",
output.tsx
);
// The reset is not a silent addition: each emitted margin carries a
// verified source derivation from the semantic stage.
let derived = output
.diagnostics
.iter()
.filter(|d| {
d.code == "DEVUP_CODEGEN_PROPERTY_EVIDENCE"
&& d.details.as_ref().is_some_and(|details| {
details["generatedProperty"] == "my=\"0px\""
&& details["stage"] == "semantic"
&& details["propertyMappingVerified"] == true
})
})
.count();
assert_eq!(derived, 2, "{:?}", output.diagnostics);
assert!(
!output
.diagnostics
.iter()
.any(|d| d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED"),
"{:?}",
output.diagnostics
);
}
Loading
Loading