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_boolean_logo_pagination.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"changes": {
"crates/devup-mcp-figma/Cargo.toml": "Patch",
"crates/devup-mcp-devup-ui/Cargo.toml": "Minor",
"crates/devup-mcp/Cargo.toml": "Patch"
},
"note": "Keep the vector operands of a Section screen that does not fit one page. A multi-root snapshot answers with a cursor when it has more nodes to give, and the collector read the first page and stopped, so on the two Loading screens of section 4279:7810 the twelve operands of two Boolean logos never arrived and the logos came out as grey boxes. The continuation is now followed to the end with the same Section and the same root set, and the cursor is checked rather than trusted: the offset has to be the one that was asked for, the next offset has to be the requested offset plus the nodes that came back, it may not pass the total, the complete flag has to agree with it, and a cursor that does not advance is refused. A continuation can no longer fall back to a legacy restart either, because the pages already accepted would be mixed with a second capture, and a first page is never cached as a finished design while operands remain unread. BOOLEAN_OPERATION is recognised as a vector asset, in asset discovery and in the single-colour test that decides between a masked Box and an Image, so the logo is exported as SVG and the generated TSX refers to those bytes. Fidelity gains uncoveredNodeIds: a declared child absent from both the snapshot and any asset projection is a hole nothing represents, so it counts against node coverage and as a lossy impact, where an operand deliberately flattened into an SVG does not. Verified against file 85CgSws3o5XsLv7aAwWJyS with cache bypassed - status partial to complete, 18 nodes to 30, 16 of 28 formats collected to 28 of 28, twelve missing formats to none, and both logos exported as SVG whose manifest, file hash, source map and TSX reference agree.",
"date": "2026-09-08T18:40:00+09:00"
}
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions crates/devup-mcp-devup-ui/src/codegen/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ fn asset_kind_nested(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Optio
return None;
}

if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") {
if matches!(
view.node_type(),
"VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION"
) {
return Some(svg_asset_kind(snapshot, node, nested));
}

Expand Down Expand Up @@ -323,7 +326,11 @@ fn same_color(
{
return SameColor::Null;
}
if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") {
// Boolean operands define geometry; the result's own paint colors it.
if matches!(
view.node_type(),
"VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION"
) {
return own();
}
if view.node_type() == "ELLIPSE"
Expand Down
27 changes: 26 additions & 1 deletion crates/devup-mcp-devup-ui/src/provenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ pub struct FidelityReport {
pub assets: FidelityCoverage,
pub layout: FidelityCoverage,
pub impacts: FidelityImpactCounts,
/// Declared children absent from both the snapshot and an asset projection.
/// Unlike an intentionally flattened SVG operand, these are visual losses.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub uncovered_node_ids: Vec<String>,
/// The `nodeId#property` layout pairs the generated TSX does not account
/// for, bounded by [`MAX_REPORTED_UNCOVERED`]. Reporting only a ratio left
/// a shortfall untriageable: nothing said whether the layout was wrong or
Expand Down Expand Up @@ -395,6 +399,18 @@ pub fn validate_fidelity(
.map(|node| node.id.clone())
.collect::<BTreeSet<_>>();
let parents = source_parents(snapshot);
// Coverage is otherwise measured only over nodes that arrived. Count
// declared holes too, unless an actual asset projection represents them.
// Snapshot completeness still reports every missing operand in either case.
let unrepresented_children = semantic_nodes
.iter()
.filter(|node_id| {
!asset_nodes.contains(**node_id) && !has_asset_ancestor(node_id, &parents, &asset_nodes)
})
.filter_map(|node_id| snapshot.nodes.get(*node_id))
.flat_map(|node| node.typed_view().child_ids().collect::<Vec<_>>())
.filter(|child_id| !snapshot.nodes.contains_key(*child_id))
.collect::<BTreeSet<_>>();
let layout = semantic_nodes
.iter()
.filter(|node_id| !has_asset_ancestor(node_id, &parents, &asset_nodes))
Expand Down Expand Up @@ -443,9 +459,18 @@ pub fn validate_fidelity(
FidelityImpact::Failed => impacts.failed += 1,
}
}
impacts.lossy += unrepresented_children.len();
Ok(FidelityReport {
syntax_valid: true,
nodes: FidelityCoverage::new(expected.len(), observed.len()),
nodes: FidelityCoverage::new(
expected.len() + unrepresented_children.len(),
observed.len(),
),
uncovered_node_ids: unrepresented_children
.into_iter()
.take(MAX_REPORTED_UNCOVERED)
.map(str::to_owned)
.collect(),
text: FidelityCoverage::new(text_segments.len(), covered_text),
variables: FidelityCoverage::new(variables.len(), covered_variables),
typography: FidelityCoverage::new(typography.len(), covered_typography),
Expand Down
67 changes: 67 additions & 0 deletions crates/devup-mcp-devup-ui/tests/boolean_logos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component};
use devup_mcp_figma::{Snapshot, discover_asset_manifest};

#[test]
fn loading_boolean_logos_reference_exportable_svg_without_hiding_missing_operands() {
// Captured 2026-09-08 from the two Loading screens in section 4279:7810.
// Keep only the logo wrappers and unions; their twelve operands were absent.
let snapshot: Snapshot =
serde_json::from_str(include_str!("fixtures/loading-boolean-logos.json")).unwrap();
assert_eq!(snapshot.audit().missing_children.len(), 12);
let manifest = discover_asset_manifest(&snapshot);
assert_eq!(manifest.assets.len(), 2, "both logos must be exportable");
for (root, expected_asset) in [
("3831:10710", "3831:10710:node"),
("3831:10725", "3831:10725:node"),
] {
let asset = manifest
.assets
.iter()
.find(|a| a.asset_id == expected_asset)
.unwrap();
assert_eq!(asset.source_kind, "vector-node");
assert_eq!(asset.field, "node");
let output = generate_component(&snapshot, root, &CodegenOptions::default()).unwrap();
assert!(output.tsx.contains(".svg"), "{}", output.tsx);
assert!(
output
.source_map
.entries
.iter()
.any(|e| e.asset_id.as_deref() == Some(expected_asset)
&& e.generated_range.is_some())
);
assert_eq!(output.fidelity_report.assets.total, 1);
assert_eq!(output.fidelity_report.assets.covered, 1);
}
assert_eq!(snapshot.audit().missing_children.len(), 12);
}

#[test]
fn an_unrepresented_missing_child_is_lossy_even_when_every_collected_node_has_tsx() {
let mut snapshot: Snapshot =
serde_json::from_str(include_str!("fixtures/loading-boolean-logos.json")).unwrap();
let union = snapshot.nodes.get_mut("3831:10711").unwrap();
union.node_type = "GROUP".into();
let output = generate_component(&snapshot, "3831:10710", &CodegenOptions::default()).unwrap();
assert!(output.fidelity_report.impacts.lossy > 0);
assert_eq!(output.fidelity_report.nodes.total, 8);
assert_eq!(output.fidelity_report.nodes.covered, 2);
assert!(!output.fidelity_report.strict_compatible());
}

#[test]
fn missing_descendants_of_a_hidden_logo_do_not_claim_visual_loss() {
let mut snapshot: Snapshot =
serde_json::from_str(include_str!("fixtures/loading-boolean-logos.json")).unwrap();
snapshot
.nodes
.get_mut("3831:10710")
.unwrap()
.fields
.insert("visible".into(), serde_json::json!(false));
snapshot.nodes.get_mut("3831:10711").unwrap().node_type = "GROUP".into();
let output = generate_component(&snapshot, "3831:10710", &CodegenOptions::default()).unwrap();
assert_eq!(output.fidelity_report.impacts.lossy, 0);
assert!(output.fidelity_report.nodes.complete());
}
121 changes: 121 additions & 0 deletions crates/devup-mcp-devup-ui/tests/fixtures/loading-boolean-logos.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
{
"fileKey": "85CgSws3o5XsLv7aAwWJyS",
"version": null,
"roots": [
"3831:10710",
"3831:10725"
],
"nodes": {
"3831:10710": {
"id": "3831:10710",
"type": "FRAME",
"fields": {
"childrenIds": [
"3831:10711"
],
"height": 34,
"isAsset": true,
"name": "BI - 아이콘",
"targetAspectRatio": {
"x": 59.07421875,
"y": 31.029436111450195
},
"visible": true,
"width": 64
},
"extra": {},
"fieldErrors": {}
},
"3831:10711": {
"id": "3831:10711",
"type": "BOOLEAN_OPERATION",
"fields": {
"parentId": "3831:10710",
"childrenIds": [
"3831:10712",
"3831:10713",
"3831:10714",
"3831:10715",
"3831:10716",
"3831:10717"
],
"fills": [
{
"blendMode": "NORMAL",
"boundVariables": {},
"color": {
"b": 0.17999267578125,
"g": 0.17999267578125,
"r": 0.4589996337890625
},
"opacity": 1,
"type": "SOLID",
"visible": true
}
],
"height": 33.99979019165039,
"name": "Union",
"visible": true,
"width": 64
},
"extra": {},
"fieldErrors": {}
},
"3831:10725": {
"id": "3831:10725",
"type": "FRAME",
"fields": {
"childrenIds": [
"3831:10726"
],
"height": 34,
"isAsset": true,
"name": "BI - 아이콘",
"targetAspectRatio": {
"x": 59.07421875,
"y": 31.029436111450195
},
"visible": true,
"width": 64
},
"extra": {},
"fieldErrors": {}
},
"3831:10726": {
"id": "3831:10726",
"type": "BOOLEAN_OPERATION",
"fields": {
"parentId": "3831:10725",
"childrenIds": [
"3831:10727",
"3831:10728",
"3831:10729",
"3831:10730",
"3831:10731",
"3831:10732"
],
"fills": [
{
"blendMode": "NORMAL",
"boundVariables": {},
"color": {
"b": 0.17999267578125,
"g": 0.17999267578125,
"r": 0.4589996337890625
},
"opacity": 1,
"type": "SOLID",
"visible": true
}
],
"height": 33.99979019165039,
"name": "Union",
"visible": true,
"width": 64
},
"extra": {},
"fieldErrors": {}
}
},
"diagnostics": []
}
7 changes: 6 additions & 1 deletion crates/devup-mcp-figma/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,12 @@ fn compute_asset_node(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Opti
return None;
}

if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") {
// A boolean's rendered shape is authoritative even when its operands
// were not included in the snapshot. Export the node, never its bounds.
if matches!(
view.node_type(),
"VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION"
) {
return Some(AssetNode::Svg);
}

Expand Down
Loading