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
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_Cplouv_3AT3N0Fj41CiIp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Preserve screen-plan frame inventories and bind navigation labels to confirmed routes",
"date": "2026-10-19T08:05:44.718031500Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_F-lJ1_TR61qiwFfH3-BxP.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Report missing public assets and omitted Figma sections and asset references during export and UI validation",
"date": "2026-10-19T08:09:42.879273700Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_PlugVer_0a1B2c3D4e5F6g7H.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Report the Devup Bridge plugin version in hello and show plugin and devup-mcp versions in the plugin window",
"date": "2026-10-19T09:00:00.000000000Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Make runtime audit image-loaded, native-appearance and stale-resources checks fail on unpainted images, native button chrome and differences from a fresh cache-free load",
"date": "2026-10-19T09:00:00.000000000Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Require complete URL-by-width runtime evidence, reading-order and responsive clipping checks before completion",
"date": "2026-10-19T08:11:39.299827700Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Show rendered completeness in per-width audit reports",
"date": "2026-10-19T08:14:14.391652Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_qCx9bQYoBJWWTta31W1XI.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Verify rendered completeness, effective text contrast, and loaded image resources in runtime audits",
"date": "2026-10-19T08:04:41.792494500Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_u8RE3FZrsXubFLyVO98qr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Reset generated button appearance and preserve original hero image fills without baking child text.",
"date": "2026-10-19T09:18:21.640928500Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Require real Chromium rendering and pinned Figma pixel comparisons before page completion; cover generated DevupUI pages, missing backgrounds, wrong colors and failed assets in the end-to-end harness.",
"date": "2026-10-19T14:22:49.794557600Z"
}
23 changes: 23 additions & 0 deletions crates/devup-mcp-devup-ui/examples/render_fixture.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//! Replay a snapshot through production codegen for the browser regression test.
use std::{error::Error, path::Path};

use devup_mcp_devup_ui::codegen::{
CodegenOptions, generate_component, node_ids::annotate_node_ids,
};
use devup_mcp_figma::Snapshot;

fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() != 2 {
return Err("usage: render_fixture <snapshot.json> <output.tsx>".into());
}
let snapshot: Snapshot = serde_json::from_slice(&std::fs::read(&args[0])?)?;
let options = CodegenOptions {
component_name: Some("RenderFixture".into()),
..Default::default()
};
let mut output = generate_component(&snapshot, &snapshot.roots[0], &options)?;
annotate_node_ids(&mut output, &snapshot.nodes.keys().cloned().collect());
std::fs::write(Path::new(&args[1]), output.tsx)?;
Ok(())
}
31 changes: 29 additions & 2 deletions crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,29 @@ pub(super) fn push_style_props(
tokens: length_tokens,
viewport: drawn_viewport(snapshot, node),
};
// Reset native faces even with transparent paint, including Figma button
// components that consumers render polymorphically.
let explicit_button = view.string("as") == Some("button")
|| view.node_type().eq_ignore_ascii_case("button");
let button_name = |name: &str| {
name.split(|c: char| !c.is_alphanumeric())
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("button"))
};
let named_button = component != "Text"
&& (view.name().is_some_and(button_name)
|| snapshot.nodes.values().any(|parent| {
let parent = parent.typed_view();
parent.node_type() == "COMPONENT_SET"
&& parent.name().is_some_and(button_name)
&& parent.child_ids().any(|id| id == node.id)
}));
if explicit_button || named_button || component.eq_ignore_ascii_case("button") {
if explicit_button {
string_prop(props, "as", "button");
}
string_prop(props, "appearance", "none");
}
if view.bool("visible") == Some(false) {
string_prop(props, "display", "none");
}
Expand Down Expand Up @@ -805,6 +828,11 @@ pub(super) fn push_style_props(
} else if let Some(background) = background_css(snapshot, node, used_tokens, style) {
string_prop(props, "bg", background);
}
if (explicit_button || named_button || component.eq_ignore_ascii_case("button"))
&& !props.iter().any(|(name, _)| name == "bg")
{
string_prop(props, "bg", "transparent");
}
if let Some(mode) = view
.value("fills")
.and_then(Value::as_array)
Expand All @@ -819,7 +847,6 @@ pub(super) fn push_style_props(
{
string_prop(props, "bgBlendMode", mode);
}

push_radius(&view, props);
if component != "Text" {
push_strokes(
Expand Down Expand Up @@ -969,4 +996,4 @@ fn background_css(
}
}
(!css.is_empty()).then(|| css.join(", "))
}
}
87 changes: 84 additions & 3 deletions crates/devup-mcp-devup-ui/src/ui_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ struct Collector<'s> {

impl<'a> Visit<'a> for Collector<'_> {
fn visit_jsx_element(&mut self, element: &JSXElement<'a>) {
let attrs = string_attributes(&element.opening_element);
let attrs = string_attributes(&element.opening_element, self.source);
let follows_viewport = match self.ancestors.last() {
None => fluid_width(&attrs),
Some(parent) => parent.follows_viewport && stretches_across(parent, &attrs),
Expand Down Expand Up @@ -133,7 +133,7 @@ impl<'a> Visit<'a> for Collector<'_> {
}
}

fn string_attributes(element: &JSXOpeningElement<'_>) -> BTreeMap<String, String> {
fn string_attributes(element: &JSXOpeningElement<'_>, source: &str) -> BTreeMap<String, String> {
element
.attributes
.iter()
Expand All @@ -150,7 +150,10 @@ fn string_attributes(element: &JSXOpeningElement<'_>) -> BTreeMap<String, String
match container.expression.as_expression()?.get_inner_expression() {
Expression::StringLiteral(text) => text.value.to_string(),
Expression::NumericLiteral(number) => number.value.to_string(),
_ => return None,
_ => {
let span = container.expression.span();
source[span.start as usize..span.end as usize].to_owned()
}
}
}
_ => return None,
Expand Down Expand Up @@ -221,6 +224,51 @@ fn center_offset(left: &str) -> Option<f64> {

impl Collector<'_> {
fn analyze(&mut self, parent: &Element, children: &[Element]) {
fn direction(element: &Element) -> Option<&String> {
element
.attrs
.get("flexDir")
.or_else(|| element.attrs.get("flexDirection"))
}
let column = |element: &Element| {
element.tag == "VStack"
|| direction(element).is_some_and(|value| value.contains("column"))
};
if children
.iter()
.filter(|child| column(child) && child.has_children)
.count()
>= 2
{
self.risks.push(json!({"rule":"column-reading-order", "confidence":"medium", "elementIndex":parent.ordinal,
"message":"Column wrappers can collapse cards in column order (01,03,02,04). Use one Grid or flex-wrap in design reading order, or explicit breakpoint order; run sequence-order at every width."}));
}
let row = parent.tag == "Flex"
&& direction(parent).is_none_or(|value| !value.contains("column"))
|| parent.tag == "Grid"
&& parent
.attrs
.get("gridTemplateColumns")
.is_some_and(|value| value.contains(" ") || value.contains("repeat"));
let wraps = parent
.attrs
.get("flexWrap")
.is_some_and(|value| value.contains("wrap"));
let fixed_children = children
.iter()
.filter(|child| pixels(child.attrs.get("w")).is_some())
.count();
let wide_only = std::iter::once(parent).chain(children).any(|element| {
element.attrs.values().any(|value| {
(value.trim_start().starts_with("[null")
|| value.trim_start().starts_with("[undefined"))
&& value.contains("px")
})
});
if row && !wraps && (fixed_children >= 2 || wide_only) {
self.risks.push(json!({"rule":"narrow-fixed-columns", "confidence":"medium", "elementIndex":parent.ordinal,
"message":"A multi-column row keeps fixed child widths without a narrow column or wrap rule. Supply narrow responsive layout props and verify section-layout, overflow and clipped text at 390/858px."}));
}
let width = pixels(parent.attrs.get("w"))
.or_else(|| pixels(parent.attrs.get("boxSize")))
.or(self.reference_width);
Expand Down Expand Up @@ -587,3 +635,36 @@ mod tests {
assert_eq!(report["groupsWithoutReferenceWidth"], 1);
}
}

#[cfg(test)]
mod responsive_failure_tests {
use super::*;
#[test]
fn columns_and_wide_only_fixed_rows_are_flagged() {
let columns = r#"const A = () => <Flex flexDir={["column", "row"]}><Flex flexDir="column"><Box/><Box/></Flex><Flex flexDir="column"><Box/><Box/></Flex></Flex>"#;
assert!(
layout_risks(columns, None)["risks"]
.as_array()
.unwrap()
.iter()
.any(|risk| risk["rule"] == "column-reading-order")
);
let fixed =
r#"const A = () => <Flex w={[null, "1200px"]}><Box w="400px"/><Box w="400px"/></Flex>"#;
assert!(
crate::ui_validate::validate_devup_ui_tsx(fixed, None, true)
.violations
.iter()
.any(|risk| risk.rule == "narrow-fixed-columns")
);
let wrapping =
r#"const A = () => <Flex flexWrap="wrap"><Box w="400px"/><Box w="400px"/></Flex>"#;
assert!(
!layout_risks(wrapping, None)["risks"]
.as_array()
.unwrap()
.iter()
.any(|risk| risk["rule"] == "narrow-fixed-columns")
);
}
}
12 changes: 12 additions & 0 deletions crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,18 @@ fn validate_source(
let checked_tokens = visitor.checked_tokens;
let attributes = crate::provenance::attributes::generated_attributes(tsx);
violations.extend(blur_layers::check(tsx, &attributes));
for risk in crate::ui_layout::layout_risks(tsx, None)["risks"].as_array().into_iter().flatten() {
let rule = match risk["rule"].as_str() {
Some("narrow-fixed-columns") => "narrow-fixed-columns",
Some("column-reading-order") => "column-reading-order",
_ => continue,
};
violations.push(Violation { rule, severity: Severity::Warning, byte_range: [0, 0],
context: risk.as_object().cloned().unwrap_or_default(),
message: risk["message"].as_str().unwrap_or_default().to_owned(),
suggestion: Some("Use reading-order DOM and explicit narrow layout props, then run the runtime audit at every required width.".into()) });
}

let elements = crate::provenance::attributes::opening_elements(tsx);
for violation in &mut violations {
let [start, end] = violation.byte_range;
Expand Down
70 changes: 70 additions & 0 deletions crates/devup-mcp-devup-ui/tests/button_appearance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component};
use devup_mcp_figma::Snapshot;
use serde_json::json;

#[test]
fn white_outline_button_without_fill_disables_the_native_face() {
for (name, tag) in [("Button / Outline", None), ("CTA", Some("button"))] {
for fill in [
json!([]),
json!([{"type":"SOLID","color":{"r":0,"g":0,"b":0}}]),
] {
let snapshot: Snapshot = serde_json::from_value(json!({
"fileKey":"button", "roots":["button"], "diagnostics":[], "nodes": {
"button":{"id":"button","type":"FRAME","fields":{
"name":name,"as":tag, "width":160,"height":48,"layoutMode":"HORIZONTAL",
"fills":fill,"strokeWeight":1,"strokeAlign":"INSIDE",
"strokes":[{"type":"SOLID","color":{"r":1,"g":1,"b":1}}],
"childrenIds":["label"]
}},
"label":{"id":"label","type":"TEXT","fields":{
"characters":"Get started","fontSize":16,
"fills":[{"type":"SOLID","color":{"r":1,"g":1,"b":1}}]
}}
}
}))
.unwrap();
let output =
generate_component(&snapshot, "button", &CodegenOptions::default()).unwrap();
assert!(output.tsx.contains("appearance=\"none\""), "{}", output.tsx);
assert!(output.tsx.contains("color=\"#FFF\""), "{}", output.tsx);
assert!(
output.tsx.contains("border=\"solid 1px #FFF\""),
"{}",
output.tsx
);
let expected = if fill.as_array().unwrap().is_empty() {
"transparent"
} else {
"#000"
};
assert!(
output.tsx.contains(&format!("bg=\"{expected}\"")),
"{}",
output.tsx
);
assert_eq!(output.tsx.matches("bg=").count(), 1, "{}", output.tsx);
if tag.is_some() {
assert!(output.tsx.contains("as=\"button\""), "{}", output.tsx);
}
}
}
}

#[test]
fn button_variant_inherits_native_appearance_safety_from_its_component_set() {
let snapshot: Snapshot = serde_json::from_value(json!({
"fileKey":"button","roots":["variant"],"diagnostics":[],"nodes":{
"set":{"id":"set","type":"COMPONENT_SET","fields":{
"name":"Button","childrenIds":["variant"]
}},
"variant":{"id":"variant","type":"COMPONENT","fields":{
"name":"Style=Outline","width":160,"height":48,"fills":[]
}}
}
}))
.unwrap();
let output = generate_component(&snapshot, "variant", &CodegenOptions::default()).unwrap();
assert!(output.tsx.contains("appearance=\"none\""), "{}", output.tsx);
assert!(output.tsx.contains("bg=\"transparent\""), "{}", output.tsx);
}
Loading
Loading