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
9 changes: 9 additions & 0 deletions .changepacks/changepack_log_bounded_layout_review.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"changes": {
"crates/devup-mcp/Cargo.toml": "Minor",
"crates/devup-mcp-figma/Cargo.toml": "Minor",
"crates/devup-mcp-devup-ui/Cargo.toml": "Patch"
},
"note": "Add bounded layout measurements and export selection, refresh Bridge selection evidence, and improve responsive, image-layer and runtime dimension review with explicit evidence limits.",
"date": "2026-10-17T00:00:00+09:00"
}
52 changes: 49 additions & 3 deletions crates/devup-mcp-devup-ui/src/codegen/responsive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,23 @@ pub fn is_breakpoint_name(name: &str) -> bool {
rank_of(name).is_some()
}

/// Explicit width suffixes can describe one named screen. No fuzzy matching.
pub fn family_name(name: &str) -> String {
let name = name.trim().to_ascii_lowercase();
if is_breakpoint_name(&name) {
return "@breakpoint-labels".into();
}
for suffix in ["desktop", "tablet", "mobile"] {
let trimmed = name.trim_end_matches(')');
if let Some(base) = trimmed.strip_suffix(suffix)
&& base.ends_with([' ', '-', '/', '('])
{
return base.trim_end_matches([' ', '-', '/', '(']).to_owned();
}
}
name
}

/// Whether roots with these names and widths could be a breakpoint family.
///
/// The question [`breakpoints`] answers, asked without a snapshot, so it can
Expand All @@ -319,6 +336,24 @@ pub fn is_breakpoint_name(name: &str) -> bool {
/// a change to either rule that forgets the other fails there rather than in
/// a 77-second export.
pub fn breakpoint_family_is_possible(frames: &[(&str, f64)]) -> bool {
if frames.len() < 2
|| frames
.iter()
.any(|(_, w)| !w.is_finite() || *w <= 0.0 || *w > f64::from(u32::MAX))
{
return false;
}
let mut slots = BTreeSet::new();
for (_, width) in frames {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "validated finite positive pixel width"
)]
if !slots.insert(slot_of_width(*width as u32)) {
return false;
}
}
let mut ranks: Vec<usize> = Vec::new();
for (name, _width) in frames {
let Some(rank) = rank_of(name) else {
Expand All @@ -329,7 +364,7 @@ pub fn breakpoint_family_is_possible(frames: &[(&str, f64)]) -> bool {
}
ranks.push(rank);
}
if ranks.len() >= 2 {
if ranks.len() == frames.len() {
return true;
}
width_family_is_possible(frames)
Expand All @@ -345,7 +380,7 @@ fn width_family_is_possible(frames: &[(&str, f64)]) -> bool {
let mut shared_name: Option<String> = None;
let mut slots: Vec<usize> = Vec::new();
for (name, width) in frames {
let name = name.trim().to_ascii_lowercase();
let name = family_name(name);
if *shared_name.get_or_insert_with(|| name.clone()) != name {
return false;
}
Expand Down Expand Up @@ -381,6 +416,17 @@ fn width_family_is_possible(frames: &[(&str, f64)]) -> bool {
/// Empty unless there are at least two: one width is a screen, not a screen
/// that changes, and there is nothing to line up.
pub fn breakpoints(snapshot: &Snapshot) -> Vec<Breakpoint> {
let frames = snapshot
.roots
.iter()
.filter_map(|id| {
let view = snapshot.nodes.get(id)?.typed_view();
Some((view.name()?, view.number("width")?))
})
.collect::<Vec<_>>();
if frames.len() != snapshot.roots.len() || !breakpoint_family_is_possible(&frames) {
return Vec::new();
}
let mut found: Vec<Breakpoint> = Vec::new();
for id in &snapshot.roots {
let Some(node) = snapshot.nodes.get(id) else {
Expand Down Expand Up @@ -438,7 +484,7 @@ fn breakpoints_by_width(snapshot: &Snapshot) -> Vec<Breakpoint> {
let (Some(name), Some(width)) = (view.name(), view.number("width")) else {
return Vec::new();
};
let name = name.trim().to_ascii_lowercase();
let name = family_name(name);
if *shared_name.get_or_insert_with(|| name.clone()) != name {
return Vec::new();
}
Expand Down
3 changes: 3 additions & 0 deletions crates/devup-mcp-devup-ui/src/ui_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
//! in `themeNotes`, avoiding repeated, impossible advice (D11).

use std::collections::BTreeSet;
#[path = "ui_validate/blur_layers.rs"]
mod blur_layers;

use oxc_allocator::Allocator;
use oxc_ast::ast::{
Expand Down Expand Up @@ -178,6 +180,7 @@ fn validate_source(
violations.extend(visitor.violations);
let checked_tokens = visitor.checked_tokens;
let attributes = crate::provenance::attributes::generated_attributes(tsx);
violations.extend(blur_layers::check(tsx, &attributes));
let elements = crate::provenance::attributes::opening_elements(tsx);
for violation in &mut violations {
let [start, end] = violation.byte_range;
Expand Down
256 changes: 256 additions & 0 deletions crates/devup-mcp-devup-ui/src/ui_validate/blur_layers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
use super::{Severity, Violation};
use crate::provenance::attributes::GeneratedAttribute;
use oxc_allocator::Allocator;
use oxc_ast::ast::JSXElement;
use oxc_ast_visit::{Visit, walk};
use oxc_parser::Parser;
use oxc_span::{GetSpan, SourceType};
use serde_json::json;
use std::collections::BTreeMap;

fn literal(source: &str) -> Option<&str> {
let (_, value) = source.split_once('=')?;
let value = value
.trim()
.strip_prefix('{')
.and_then(|v| v.strip_suffix('}'))
.unwrap_or(value.trim())
.trim();
if matches!(value, "true" | "false" | "0") {
return Some(value);
}
value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.filter(|s| !s.contains(['"', '\\']))
.or_else(|| {
value
.strip_prefix('\'')
.and_then(|v| v.strip_suffix('\''))
.filter(|s| !s.contains(['\'', '\\']))
})
}
fn positive_blur(value: &str) -> bool {
value
.trim()
.strip_prefix("blur(")
.and_then(|v| v.strip_suffix(')'))
.and_then(|v| v.trim().strip_suffix("px"))
.and_then(|v| v.parse::<f64>().ok())
.is_some_and(|n| n.is_finite() && n > 0.0)
}
#[derive(Clone, Copy, Default)]
struct Layer {
blurred: bool,
hidden: bool,
uncertain: bool,
}
fn layers(source: &str, attrs: &[GeneratedAttribute]) -> Vec<Layer> {
struct Layers<'s> {
source: &'s str,
attrs: BTreeMap<usize, Vec<&'s GeneratedAttribute>>,
stack: Vec<Layer>,
found: Vec<Layer>,
}
impl<'a> Visit<'a> for Layers<'_> {
fn visit_jsx_element(&mut self, element: &JSXElement<'a>) {
let ordinal = self.found.len();
let local = self.attrs.get(&ordinal).map(Vec::as_slice).unwrap_or(&[]);
let value = |key| {
local
.iter()
.find(|a| a.name == key)
.and_then(|a| literal(&a.source))
};
let parent = self.stack.last().copied().unwrap_or_default();
let span = element.opening_element.name.span();
let tag = &self.source[span.start as usize..span.end as usize];
let primitive = matches!(
tag,
"Box" | "Flex" | "VStack" | "Center" | "Grid" | "Image" | "Text"
);
let known = primitive
|| (tag.bytes().next().is_some_and(|b| b.is_ascii_lowercase())
&& !tag.contains(['.', '-']));
let uncertain = parent.uncertain
|| !known
|| local.iter().any(|a| {
matches!(a.name.as_str(), "..." | "style" | "className")
|| (matches!(
a.name.as_str(),
"filter" | "display" | "visibility" | "opacity"
) && literal(&a.source).is_none())
});
let layer = Layer {
blurred: parent.blurred
|| (primitive && value("filter").is_some_and(positive_blur)),
hidden: parent.hidden
|| (primitive
&& (value("display") == Some("none")
|| value("visibility") == Some("hidden")
|| value("opacity") == Some("0"))),
uncertain,
};
self.found.push(layer);
self.stack.push(layer);
walk::walk_jsx_element(self, element);
self.stack.pop();
}
}
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse();
let mut visitor = Layers {
source,
attrs: attrs.iter().fold(
BTreeMap::<usize, Vec<&GeneratedAttribute>>::new(),
|mut map, a| {
map.entry(a.element).or_default().push(a);
map
},
),
stack: vec![],
found: vec![],
};
visitor.visit_program(&parsed.program);
visitor.found
}
pub(super) fn check(source_text: &str, attrs: &[GeneratedAttribute]) -> Vec<Violation> {
let layers = layers(source_text, attrs);
let mut uses = BTreeMap::<&str, Vec<(&GeneratedAttribute, Option<bool>)>>::new();
for source in attrs
.iter()
.filter(|a| a.name == "src" && matches!(a.tag.as_str(), "img" | "Image"))
{
let Some(path) = literal(&source.source).filter(|s| !s.is_empty()) else {
continue;
};
let on_element = attrs
.iter()
.filter(|a| a.element == source.element)
.collect::<Vec<_>>();
let value = |key| {
on_element
.iter()
.find(|a| a.name == key)
.and_then(|a| literal(&a.source))
};
if value("alt") == Some("")
|| value("aria-hidden") == Some("true")
|| value("role") == Some("presentation")
{
continue;
}
let layer = layers.get(source.element).copied().unwrap_or(Layer {
uncertain: true,
..Default::default()
});
if layer.hidden {
continue;
}
// Spread, class/style and expression-valued filters may supply or
// override the layer. Their absence is never treated as sharp proof.
let state = if layer.uncertain {
None
} else {
Some(layer.blurred)
};
uses.entry(path).or_default().push((source, state));
}
uses.into_iter().filter(|(_,uses)|!uses.is_empty()&&uses.iter().all(|(_,state)|*state==Some(true))).map(|(path,uses)| {
let first=uses[0].0;
Violation {rule:"blur-only-image",severity:Severity::Warning,byte_range:[first.start,first.end],
context:json!({"assetPath":path,"elementIndices":uses.iter().map(|(a,_)|a.element).collect::<Vec<_>>(),
"state":"review-required","evidenceScope":"literal src and statically resolved known-wrapper or same-element CSS filters",
"evidenceLimit":"Static review cannot establish intended decoration, conditional visibility, external CSS, dynamic/custom ancestor effects or browser pixels; this warning does not prove a missing layer."}).as_object().unwrap().clone(),
message:"All statically visible literal uses of this image apply positive CSS blur; review whether an intended sharp copy is missing.".into(),
suggestion:Some("If the design includes a sharp image, render that copy alongside the blur layer; keep deliberate decoration explicitly marked.".into())}
}).collect()
}

#[cfg(test)]
mod tests {
use super::*;
fn count(tsx: &str) -> usize {
check(
tsx,
&crate::provenance::attributes::generated_attributes(tsx),
)
.len()
}
#[test]
fn conservative_blur_layer_review() {
assert_eq!(
count(r#"<Image src="/photo.webp" filter="blur(8px)" />"#),
1
);
assert_eq!(
count(
r#"<><Image src="/photo.webp" filter="blur(8px)"/><Image src="/photo.webp" /></>"#
),
0
);
assert_eq!(
count(r#"<Image src="/photo.webp" filter="blur(0px)" />"#),
0
);
assert_eq!(count(r#"<Image src="/photo.webp" filter={effect} />"#), 0);
assert_eq!(
count(r#"<Image src="/photo.webp" filter="blur(8px)" className="photo"/>"#),
0
);
assert_eq!(
count(r#"<Image src="/photo.webp" filter="blur(8px)" alt=""/>"#),
0
);
assert_eq!(
count(r#"<Image src="/photo.webp" filter="blur(8px)" display="none"/>"#),
0
);
assert_eq!(
count(r#"<>{/* <Image src="/photo.webp" filter="blur(8px)"/> */}</>"#),
0
);
}
#[test]
fn static_wrapper_blur_requires_a_sharp_copy_outside_the_blurred_subtree() {
assert_eq!(
count(r#"<div filter="blur(6px)"><img src="/art.webp" /></div>"#),
0
);
assert_eq!(
count(r#"<Box filter="blur(6px)"><div><img src="/art.webp" /></div></Box>"#),
1
);
assert_eq!(
count(
r#"<Box filter="blur(6px)"><Box overflow="hidden"><Image src="/art.webp" /></Box></Box>"#
),
1
);
assert_eq!(
count(
r#"<Box filter="blur(6px)"><Image src="/art.webp" /><Image src="/art.webp" /></Box>"#
),
1
);
assert_eq!(
count(
r#"<><Box filter="blur(6px)"><Image src="/art.webp" /></Box><Image src="/art.webp" /></>"#
),
0
);
assert_eq!(
count(r#"<Box filter={effect}><Image src="/art.webp" /></Box>"#),
0
);
assert_eq!(
count(r#"<Box className="effect" filter="blur(6px)"><Image src="/art.webp" /></Box>"#),
0
);
assert_eq!(count(r#"<Box><Image src="/art.webp" /></Box>"#), 0);
assert_eq!(
count(r#"<CustomWrapper filter="blur(6px)"><Image src="/art.webp" /></CustomWrapper>"#),
0
);
}
}
Loading
Loading