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_line_box_displacement.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": "Make the generated screen stand where Figma draws it. Figma advances each line by round(fontSize * ratio) and the browser accumulates the fraction instead, so a percentage line height drifted a little on every line and compounded through hugging parents; the emitted advance is now the whole-pixel length rather than a ratio, and all seven measured text blocks match their design height exactly. Because a pixel advance is only valid for the size it was computed from, the resolved font size now travels with it, and a percentage line height whose font size is missing or bound to a mode-dependent variable is refused with a typed error naming the node or text style rather than emitted as a number that would be wrong at another size. The second half of the divergence was an inside stroke: Figma's inside stroke consumes no layout space, and the existing padding compensation cannot absorb a CSS border when the design's own padding is narrower than the stroke, so every bordered auto-layout container grew by twice its stroke weight and pushed the sections below it down. That case now paints inward with an outline at a negative offset, taking no layout space at all, and the padding compensation stands down for it; the choice keys on stroke alignment, layout mode, and stroke weight against padding, never on a viewport width, so it survives a design redrawn at another size. Source map and provenance follow the new emission: outline and outlineOffset carry their stroke provenance, and a rich-text wrapper's own advance is attributed to the segment whose text follows it. Twenty-four plugin goldens change, each enumerated with its reason in docs/line-box-displacement.md; the corpus consistency test and the manifest checksums are unchanged in force. Measured on the landing screen against Figma's own PNG, the three widths move from 11.41 / 3.10 / 1.84 percent to 4.99 / 2.47 / 1.50 percent, and the rendered height now equals the design height at all three.",
"date": "2026-09-12T23:55:00+09:00"
}
1 change: 1 addition & 0 deletions crates/devup-mcp-devup-ui/src/codegen/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,7 @@ fn render_node(
asset_names_per_node: context.asset_names_per_node,
},
);
text::validate_line_metrics(&view, &context.text_style_tokens)?;
text::push_text_props(
&view,
&context.text_style_tokens,
Expand Down
1 change: 1 addition & 0 deletions crates/devup-mcp-devup-ui/src/codegen/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,7 @@ fn push_padding(snapshot: &Snapshot, node: &RawNode, props: &mut Vec<Prop>) {
// 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"
&& !super::style::inside_stroke_uses_outline(&view)
&& view.node_type() != "LINE"
&& let Some(weight) = view.number("strokeWeight").filter(|weight| *weight > 0.0)
&& view
Expand Down
18 changes: 17 additions & 1 deletion crates/devup-mcp-devup-ui/src/codegen/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,19 @@ fn push_radius(view: &TypedNode<'_>, props: &mut Vec<Prop>) {
}
}

/// 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))
})
}

fn push_strokes(
view: &TypedNode<'_>,
props: &mut Vec<Prop>,
Expand Down Expand Up @@ -1535,7 +1548,10 @@ fn push_strokes(
}
return;
}
if align == "INSIDE" {
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)));
Expand Down
96 changes: 82 additions & 14 deletions crates/devup-mcp-devup-ui/src/codegen/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ pub(super) fn push_text_props(
{
string_prop(props, "fontStyle", "italic");
}
if typography.is_none()
&& let Some(font_size) = value("fontSize").and_then(Value::as_f64)
{
if let Some(font_size) = value("fontSize").and_then(Value::as_f64) {
string_prop(props, "fontSize", px(font_size));
}
if typography.is_none()
Expand All @@ -68,9 +66,10 @@ pub(super) fn push_text_props(
{
string_prop(props, "letterSpacing", letter_spacing);
}
if typography.is_none()
&& let Some(line_height) = line_height(value("lineHeight"))
{
if let Some(line_height) = line_height(
value("lineHeight"),
value("fontSize").and_then(Value::as_f64),
) {
string_prop(props, "lineHeight", line_height);
}
if typography.is_none() {
Expand Down Expand Up @@ -203,7 +202,7 @@ fn letter_spacing(value: Option<&Value>) -> Option<String> {
}
}

fn line_height(value: Option<&Value>) -> Option<String> {
fn line_height(value: Option<&Value>, font_size: Option<f64>) -> Option<String> {
let value = value?;
if let Some(number) = value.as_f64() {
return Some(px(number));
Expand All @@ -214,7 +213,8 @@ fn line_height(value: Option<&Value>) -> Option<String> {
Some("PERCENT") => object
.get("value")
.and_then(Value::as_f64)
.map(|number| format_number((number / 10.0).round() / 10.0)),
.zip(font_size)
.map(|(number, size)| px((size * number / 100.0).round())),
_ => object.get("value").and_then(Value::as_f64).map(px),
}
}
Expand Down Expand Up @@ -381,9 +381,7 @@ fn typography_props(
{
string_prop(&mut props, "fontStyle", "italic");
}
if typography.is_none()
&& let Some(value) = segment.get("fontSize").and_then(Value::as_f64)
{
if let Some(value) = segment.get("fontSize").and_then(Value::as_f64) {
string_prop(&mut props, "fontSize", px(value));
}
if typography.is_none()
Expand All @@ -396,9 +394,10 @@ fn typography_props(
{
string_prop(&mut props, "letterSpacing", value);
}
if typography.is_none()
&& let Some(value) = line_height(segment.get("lineHeight"))
{
if let Some(value) = line_height(
segment.get("lineHeight"),
segment.get("fontSize").and_then(Value::as_f64),
) {
string_prop(&mut props, "lineHeight", value);
}
if typography.is_none() {
Expand All @@ -422,6 +421,75 @@ fn record_used_color(color: &str, used_tokens: &mut BTreeSet<String>) {
}
}

/// 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<String, String>,
) -> 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 invalid = (percent
&& (size.and_then(Value::as_f64).is_none()
|| bound.and_then(|v| v.get("fontSize")).is_some()))
|| (styled
&& size.is_some()
&& line_height(height, size.and_then(Value::as_f64)).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<String, String>,
Expand Down
39 changes: 36 additions & 3 deletions crates/devup-mcp-devup-ui/src/provenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1952,6 +1952,8 @@ const PROP_SOURCES: &[(&str, &str)] = &[
("minH", "minHeight"),
("minW", "minWidth"),
("opacity", "opacity"),
("outline", "strokes"),
("outlineOffset", "strokeWeight"),
("overflow", "clipsContent"),
("overflow", "overflowDirection"),
("overflowX", "overflowDirection"),
Expand Down Expand Up @@ -1998,19 +2000,50 @@ fn add_text_entries(
segment
.get("characters")
.and_then(serde_json::Value::as_str)
.map(|characters| (characters, Some(segment)))
})
.filter(|characters| !characters.is_empty())
.filter(|(characters, _)| !characters.is_empty())
.collect::<Vec<_>>();
if text_segments.is_empty()
&& let Some(characters) = view
.string("characters")
.filter(|characters| !characters.is_empty())
{
text_segments.push(characters);
text_segments.push((characters, None));
}
let mut cursor = 0;
for characters in text_segments {
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.
// Attribute ownership belongs to the segment whose text directly
// follows this opening, rather than the node's default metrics.
if let Some(segment) = segment
&& let Some(open_start) = source[..start].rfind("<Text")
&& source[..open_start].contains("<Text")
&& let Some(close) = source[open_start..start].find('>')
&& source[open_start + close + 1..start].trim().is_empty()
{
let opening = &source[open_start..open_start + close];
for field in ["fontSize", "lineHeight"] {
if segment.get(field).is_none() {
continue;
}
let needle = format!("{field}=\"");
if let Some(attr_start) = find_prop(opening, &needle)
&& let Some(attr_end) = opening[attr_start + needle.len()..].find('"')
{
entries.push(generated_entry(
range.start + open_start + attr_start,
range.start + open_start + attr_start + needle.len() + attr_end + 1,
node_id,
"styledTextSegments",
None,
None,
"raw-fallback",
));
}
}
}
entries.push(generated_entry(
range.start + start,
range.start + end,
Expand Down
26 changes: 25 additions & 1 deletion crates/devup-mcp-devup-ui/src/theme/devup_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,27 @@ pub fn generate_devup_json(
let level = level.min(5);
match style.style_type.as_str() {
"TEXT" => {
if style
.value
.pointer("/lineHeight/unit")
.and_then(Value::as_str)
== Some("PERCENT")
&& (style
.value
.get("fontSize")
.and_then(Value::as_f64)
.is_none()
|| style.value.pointer("/boundVariables/fontSize").is_some())
{
return Err(DevupError::new(
ErrorCode::DevupThemeConflict,
format!(
"Text style '{}' needs a fixed numeric font size for an integer percentage line advance; variable or missing sizes are not representable.",
style.id
),
false,
));
}
let slots = typography_slots.entry(token.clone()).or_default();
// The first style seen for a slot keeps it, as in the plugin.
if slots[level].is_none() {
Expand Down Expand Up @@ -671,7 +692,10 @@ fn typography_value(style: &Value, variable_names: &BTreeMap<&str, String>) -> V
let value = line_height.get("value").and_then(Value::as_f64);
let written = match (unit, value) {
(Some("AUTO"), _) => Some(Value::String("normal".to_owned())),
(Some("PERCENT"), Some(percent)) => Some(Value::from((percent / 10.0).round() / 10.0)),
(Some("PERCENT"), Some(percent)) => style
.get("fontSize")
.and_then(Value::as_f64)
.map(|size| Value::String(format_px((size * percent / 100.0).round()))),
(Some(_), Some(pixels)) => Some(Value::String(format_px(pixels))),
_ => None,
};
Expand Down
6 changes: 5 additions & 1 deletion crates/devup-mcp-devup-ui/tests/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ fn nested_text_style_uses_typography() {
"start": 0,
"end": 3,
"textStyleId": "S:body",
"lineHeight": {"unit": "AUTO"},
"fontName": {"family": "Pretendard", "style": "Regular"},
"fontSize": 16,
"fontWeight": 400,
Expand All @@ -209,6 +210,7 @@ fn nested_text_style_uses_typography() {
"start": 3,
"end": 10,
"textStyleId": "S:bodySemibold",
"lineHeight": {"unit": "AUTO"},
"fontName": {"family": "Pretendard", "style": "SemiBold"},
"fontSize": 16,
"fontWeight": 600,
Expand All @@ -223,6 +225,7 @@ fn nested_text_style_uses_typography() {
"start": 10,
"end": 20,
"textStyleId": "S:body",
"lineHeight": {"unit": "AUTO"},
"fontName": {"family": "Pretendard", "style": "Regular"},
"fontSize": 16,
"fontWeight": 400,
Expand Down Expand Up @@ -269,7 +272,8 @@ fn nested_text_style_uses_typography() {
assert!(output.used_tokens.contains("text"));
assert!(output.used_tokens.contains("primaryLight"));
assert!(output.tsx.contains("{\" \"}왔어?<br />다음 줄"));
assert!(!output.tsx.contains("fontSize=\"16px\""));
assert!(output.tsx.contains("fontSize=\"16px\""));
assert!(output.tsx.contains("lineHeight=\"normal\""));
assert!(!output.tsx.contains("fontWeight=\"600\""));
}

Expand Down
Loading