diff --git a/.changepacks/changepack_log_bounded_layout_review.json b/.changepacks/changepack_log_bounded_layout_review.json new file mode 100644 index 00000000..5922fed9 --- /dev/null +++ b/.changepacks/changepack_log_bounded_layout_review.json @@ -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" +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs index bc03e219..3a9f34e2 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs @@ -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 @@ -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 = Vec::new(); for (name, _width) in frames { let Some(rank) = rank_of(name) else { @@ -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) @@ -345,7 +380,7 @@ fn width_family_is_possible(frames: &[(&str, f64)]) -> bool { let mut shared_name: Option = None; let mut slots: Vec = 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; } @@ -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 { + let frames = snapshot + .roots + .iter() + .filter_map(|id| { + let view = snapshot.nodes.get(id)?.typed_view(); + Some((view.name()?, view.number("width")?)) + }) + .collect::>(); + if frames.len() != snapshot.roots.len() || !breakpoint_family_is_possible(&frames) { + return Vec::new(); + } let mut found: Vec = Vec::new(); for id in &snapshot.roots { let Some(node) = snapshot.nodes.get(id) else { @@ -438,7 +484,7 @@ fn breakpoints_by_width(snapshot: &Snapshot) -> Vec { 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(); } diff --git a/crates/devup-mcp-devup-ui/src/ui_validate.rs b/crates/devup-mcp-devup-ui/src/ui_validate.rs index da202410..e07f764a 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -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::{ @@ -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; diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/blur_layers.rs b/crates/devup-mcp-devup-ui/src/ui_validate/blur_layers.rs new file mode 100644 index 00000000..41871f9d --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/blur_layers.rs @@ -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::().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 { + struct Layers<'s> { + source: &'s str, + attrs: BTreeMap>, + stack: Vec, + found: Vec, + } + 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::>::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 { + let layers = layers(source_text, attrs); + let mut uses = BTreeMap::<&str, Vec<(&GeneratedAttribute, Option)>>::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::>(); + 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::>(), + "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#""#), + 1 + ); + assert_eq!( + count( + r#"<>"# + ), + 0 + ); + assert_eq!( + count(r#""#), + 0 + ); + assert_eq!(count(r#""#), 0); + assert_eq!( + count(r#""#), + 0 + ); + assert_eq!( + count(r#""#), + 0 + ); + assert_eq!( + count(r#""#), + 0 + ); + assert_eq!( + count(r#"<>{/* */}"#), + 0 + ); + } + #[test] + fn static_wrapper_blur_requires_a_sharp_copy_outside_the_blurred_subtree() { + assert_eq!( + count(r#"
"#), + 0 + ); + assert_eq!( + count(r#"
"#), + 1 + ); + assert_eq!( + count( + r#""# + ), + 1 + ); + assert_eq!( + count( + r#""# + ), + 1 + ); + assert_eq!( + count( + r#"<>"# + ), + 0 + ); + assert_eq!( + count(r#""#), + 0 + ); + assert_eq!( + count(r#""#), + 0 + ); + assert_eq!(count(r#""#), 0); + assert_eq!( + count(r#""#), + 0 + ); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_prevalidate.rs b/crates/devup-mcp-devup-ui/tests/responsive_prevalidate.rs index de55f866..a2f10cb8 100644 --- a/crates/devup-mcp-devup-ui/tests/responsive_prevalidate.rs +++ b/crates/devup-mcp-devup-ui/tests/responsive_prevalidate.rs @@ -85,3 +85,23 @@ fn the_plugin_family_is_recognized_from_names_and_widths_alone() { ])); assert!(!breakpoint_family_is_possible(&[("desktop", 1920.0)])); } + +#[test] +fn explicit_screen_suffixes_pair_and_ambiguous_widths_or_unrelated_names_refuse() { + for names in [ + vec![("Catalog / Mobile", 390.0), ("Catalog / Desktop", 1440.0)], + vec![("Catalog (mobile)", 390.0), ("Catalog (desktop)", 1440.0)], + ] { + assert!(breakpoint_family_is_possible(&names)); + assert_eq!(breakpoints(&snapshot(&names)).len(), 2); + } + for names in [ + vec![("Catalog / Mobile", 390.0), ("Profile / Desktop", 1440.0)], + vec![("Catalog / Mobile", 390.0), ("Catalog / Desktop", 400.0)], + vec![("mobile", 390.0), ("desktop", f64::NAN)], + vec![("mobile", 390.0), ("mobile", 400.0), ("desktop", 1440.0)], + ] { + assert!(!breakpoint_family_is_possible(&names)); + assert!(breakpoints(&snapshot(&names)).is_empty()); + } +} diff --git a/crates/devup-mcp-figma/src/bridge.rs b/crates/devup-mcp-figma/src/bridge.rs index 87fb8f78..d6914c53 100644 --- a/crates/devup-mcp-figma/src/bridge.rs +++ b/crates/devup-mcp-figma/src/bridge.rs @@ -105,6 +105,17 @@ pub struct NodeRef { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zoom: Option, + /// Pixel ratio of the plugin UI iframe, not a verified canvas capture scale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_device_pixel_ratio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_known_selection: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_known_page: Option, #[serde(default)] pub current_page: Option, /// 선택한 노드의 앞부분. 플러그인이 보내는 만큼만 있고, 전체 수는 @@ -162,7 +173,7 @@ pub struct BridgeServed { impl BridgeServed { /// 브리지가 답한 결과면 그 출처, 아니면 `None`. - pub(crate) fn from_result(raw: &Value) -> Option { + pub fn from_result(raw: &Value) -> Option { serde_json::from_value(raw.get("_meta")?.get(SERVED_META_KEY)?.clone()).ok() } } @@ -1638,6 +1649,31 @@ impl FigmaUpstream for BridgeFigmaClient { Some(self.state.path_snapshot(self.port).await) } + async fn refresh_bridge_context(&self, file_key: &str) -> Result { + let (data, _) = self + .state + .dispatch(file_key, "bridgeContext", json!({})) + .await?; + if data.to_string().len() > 16_384 + || data["kind"] != "devup-bridge-context" + || !data["selection"].is_array() + || !data["selectionCount"].is_u64() + || !data["currentPage"]["id"].is_string() + { + return Err(unavailable( + "The plugin did not return a current selection context.", + )); + } + let context: PluginContext = serde_json::from_value(data) + .map_err(|_| unavailable("Invalid plugin selection context."))?; + if context.selection.as_ref().is_some_and(|s| { + s.len() > 20 || context.selection_count.is_some_and(|count| count < s.len()) + }) { + return Err(unavailable("Invalid plugin selection count.")); + } + Ok(context) + } + async fn is_metered(&self, _call: &ReadToolCall) -> bool { false } @@ -1841,6 +1877,10 @@ where self.preferred.bridge_path_snapshot().await } + async fn refresh_bridge_context(&self, file_key: &str) -> Result { + self.preferred.refresh_bridge_context(file_key).await + } + /// 브리지가 맡는 읽기는 한도를 쓰지 않는다. 브리지만 읽을 수 있는 키는 원격으로 /// 가지 않으므로(거절된다) 역시 세지 않는다. async fn is_metered(&self, call: &ReadToolCall) -> bool { diff --git a/crates/devup-mcp-figma/src/explore.rs b/crates/devup-mcp-figma/src/explore.rs index 95ad1090..dfa920b0 100644 --- a/crates/devup-mcp-figma/src/explore.rs +++ b/crates/devup-mcp-figma/src/explore.rs @@ -459,6 +459,9 @@ pub fn explore_snapshot( if node.kind != ExploreKind::Screen { return false; } + if target_kind == TargetKind::Page { + return is_descendant_of(snapshot, &node.node_id, &anchor.node_id); + } if anchor.kind == ExploreKind::Container { return node.parent_id.as_deref() == Some(anchor.node_id.as_str()) || contains(anchor.bounds, node.bounds); @@ -476,7 +479,9 @@ pub fn explore_snapshot( let overlap = horizontal_overlap(node.bounds, anchor.bounds); let overlap_ratio = overlap / node.bounds.width.max(1.0); let mut reasons = vec!["screen-like".to_owned()]; - if anchor.kind == ExploreKind::Container { + if target_kind == TargetKind::Page { + reasons.push("inside-page".to_owned()); + } else if anchor.kind == ExploreKind::Container { reasons.push("inside-container".to_owned()); } else { reasons.push("below-anchor".to_owned()); diff --git a/crates/devup-mcp-figma/src/scripts/bridge_context.js b/crates/devup-mcp-figma/src/scripts/bridge_context.js new file mode 100644 index 00000000..d7083854 --- /dev/null +++ b/crates/devup-mcp-figma/src/scripts/bridge_context.js @@ -0,0 +1,9 @@ +// Operation-time read: independent of asynchronous selection event delivery. +const page = figma.currentPage; +const selected = page.selection; +const ref = node => ({id: node.id, name: String(node.name).slice(0, 256), type: node.type}); +return {kind: 'devup-bridge-context', currentPage: ref(page), + selection: selected.slice(0, 20).map(ref), selectionCount: selected.length, + zoom: Number.isFinite(figma.viewport?.zoom) ? figma.viewport.zoom : null, + observedAt: Date.now(), lastKnownSelection: globalThis.__devupLastSelection ?? null, + lastKnownPage: globalThis.__devupLastSelectionPage ?? null}; diff --git a/crates/devup-mcp-figma/src/scripts/explore.js b/crates/devup-mcp-figma/src/scripts/explore.js index b89a2506..f3be9fa7 100644 --- a/crates/devup-mcp-figma/src/scripts/explore.js +++ b/crates/devup-mcp-figma/src/scripts/explore.js @@ -23,9 +23,10 @@ function compactName(value) { } const requestedAnchor = anchor; +const requestedPage = requestedAnchor.type === "PAGE"; let pagePeer = requestedAnchor; let nearestSection = requestedAnchor.type === "SECTION" ? requestedAnchor : null; -while (pagePeer.parent && pagePeer.parent.type !== "PAGE") { +while (!requestedPage && pagePeer.parent && pagePeer.parent.type !== "PAGE") { pagePeer = pagePeer.parent; if (!nearestSection && pagePeer.type === "SECTION") nearestSection = pagePeer; } @@ -88,7 +89,7 @@ function breadcrumb(node) { return names.reverse(); } -const scopeAnchor = !isScreenLike(requestedAnchor) && nearestSection +const scopeAnchor = requestedPage ? page : !isScreenLike(requestedAnchor) && nearestSection ? nearestSection : pagePeer; const anchorBounds = bounds(scopeAnchor) || bounds(requestedAnchor); @@ -98,6 +99,7 @@ const eligible = pageChildren .map((node, pageChildIndex) => ({ node, pageChildIndex, bounds: bounds(node) })) .filter((entry) => entry.bounds) .filter((entry) => { + if (requestedPage) return true; if (entry.node.id === scopeAnchor.id) return true; const verticallyNear = entry.bounds.y >= anchorBounds.y - 240 && entry.bounds.y <= anchorBounds.y + 12000; @@ -125,7 +127,7 @@ if (!included.has(requestedAnchor.id)) { bounds: bounds(requestedAnchor), }); } -let requiredAncestor = requestedAnchor.parent; +let requiredAncestor = requestedPage ? null : requestedAnchor.parent; while (requiredAncestor && requiredAncestor.type !== "PAGE") { if (!included.has(requiredAncestor.id)) { included.set(requiredAncestor.id, { @@ -144,10 +146,11 @@ if ("children" in scopeAnchor) { } } } -let sectionTraversalTruncated = scopeAnchor.type === "SECTION" +const descendantScope = scopeAnchor.type === "SECTION" || requestedPage; +let sectionTraversalTruncated = descendantScope && "children" in scopeAnchor && scopeAnchor.children.length > projectionLimit; -if (scopeAnchor.type === "SECTION" && "children" in scopeAnchor) { +if (descendantScope && "children" in scopeAnchor) { const traversalLimit = projectionLimit * 8; const nestedQueue = scopeAnchor.children .filter((node) => !isScreenLike(node)) @@ -286,9 +289,28 @@ if (JSON.stringify(output).length > MAX_PROJECTION_JSON_CHARS) { } // Optional previews may only consume space left by the structural projection. +// Page fallback can collect names across the page; bound UTF-8 bytes as well +// as the existing character budget on the metered text transport. +function utf8Bytes(value) { + let count = 0; + for (const character of value) { + const point = character.codePointAt(0); + count += point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4; + } + return count; +} +const PAGE_TEXT_BYTES = 19 * 1024; +if (requestedPage) { + while (utf8Bytes(JSON.stringify(output)) > PAGE_TEXT_BYTES && output.nodes.length > 1) { + output.nodes.pop(); + pageNode.fields.projectionTruncated = true; + } + if (utf8Bytes(JSON.stringify(output)) > PAGE_TEXT_BYTES) throw new Error("DEVUP_EXPLORE_PROJECTION_TOO_LARGE"); +} // Count JSON escaping as well, so quotes/control characters cannot push a // preview over the envelope cap or evict a candidate. let previewBudget = MAX_PROJECTION_JSON_CHARS - JSON.stringify(output).length; +if (requestedPage) previewBudget = Math.min(previewBudget, PAGE_TEXT_BYTES - utf8Bytes(JSON.stringify(output))); if (textPreviewLimit > 0 && previewBudget > 0) { // Each candidate takes an equal share of what is left rather than as much // as it wants, so the last one is funded before the first is indulged. @@ -307,7 +329,7 @@ if (textPreviewLimit > 0 && previewBudget > 0) { let preview = ""; let spent = 0; for (const character of textPreview(included.get(projected.id).node)) { - const cost = JSON.stringify(character).length - 2; + const cost = (requestedPage ? utf8Bytes(JSON.stringify(character)) : JSON.stringify(character).length) - 2; if (spent + cost > share) break; preview += character; spent += cost; diff --git a/crates/devup-mcp-figma/src/scripts/measure_nodes.js b/crates/devup-mcp-figma/src/scripts/measure_nodes.js new file mode 100644 index 00000000..1aa2753b --- /dev/null +++ b/crates/devup-mcp-figma/src/scripts/measure_nodes.js @@ -0,0 +1,36 @@ +// A fixed read of explicit IDs. Never walk children or acquire resources. +const ids = "__DEVUP_ROOT_IDS__"; +if (!Array.isArray(ids) || ids.length < 1 || ids.length > 10 || + ids.some(id => typeof id !== 'string' || id.length > 128 || !/^(?:[0-9]+:[0-9]+|I[0-9]+:[0-9]+(?:;[0-9]+:[0-9]+)+)$/.test(id)) || + new Set(ids).size !== ids.length) throw new Error('DEVUP_INVALID_INPUT: nodeIds must be 1..10 distinct Figma IDs'); +const number = (node, key) => { + try { const value = node[key]; return typeof value === 'number' && Number.isFinite(value) ? value : null; } + catch { return null; } +}; +const label = (node, key) => { + try { const value = node[key]; return typeof value === 'string' && value.length <= 32 && /^[A-Z_]+$/.test(value) ? value : null; } + catch { return null; } +}; +const nodes = []; +for (const nodeId of ids) { + try { + const node = await figma.getNodeByIdAsync(nodeId); + if (!node || node.type === 'DOCUMENT' || node.type === 'PAGE') { + nodes.push({nodeId, status: 'unavailable', reason: node ? 'not-scene-node' : 'not-found'}); + continue; + } + const layout = { + width: number(node, 'width'), height: number(node, 'height'), + padding: {top: number(node, 'paddingTop'), right: number(node, 'paddingRight'), bottom: number(node, 'paddingBottom'), left: number(node, 'paddingLeft')}, + gap: number(node, 'itemSpacing'), layoutMode: label(node, 'layoutMode'), + align: {primary: label(node, 'primaryAxisAlignItems'), counter: label(node, 'counterAxisAlignItems')} + }; + const known = layout.width !== null && layout.height !== null; + nodes.push({nodeId, status: known ? 'measured' : 'partial', ...layout}); + } catch { + nodes.push({nodeId, status: 'unavailable', reason: 'read-failed'}); + } +} +return {kind: 'devup-measure', schemaVersion: 1, coordinateSpace: 'figma-logical', nodes, + partial: nodes.some(node => node.status !== 'measured'), + evidenceLimit: 'Null means unavailable or not applicable; no screenshot dimensions, descendants, theme, assets or code were read.'}; diff --git a/crates/devup-mcp-figma/src/upstream.rs b/crates/devup-mcp-figma/src/upstream.rs index 563da7ab..a093e2b2 100644 --- a/crates/devup-mcp-figma/src/upstream.rs +++ b/crates/devup-mcp-figma/src/upstream.rs @@ -161,6 +161,8 @@ fn is_read_only_tool_name(name: &str) -> bool { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BuiltinScript { + MeasureNodes, + BridgeContext, NodeSnapshot, FastSnapshotEnvelope, FastThemeEnvelope, @@ -181,6 +183,8 @@ impl BuiltinScript { let default_root_ids = [node_id.to_owned()]; let node_id = serde_json::to_string(node_id).expect("node id serializes"); let source = match self { + Self::MeasureNodes => include_str!("scripts/measure_nodes.js"), + Self::BridgeContext => include_str!("scripts/bridge_context.js"), Self::NodeSnapshot => include_str!("scripts/snapshot.js"), Self::FastSnapshotEnvelope => include_str!("scripts/fast_snapshot.js"), Self::FastThemeEnvelope => include_str!("scripts/fast_theme.js"), @@ -414,6 +418,18 @@ pub enum ReadToolCall { } impl ReadToolCall { + /// Fixed, bounded node-list read. No descendants, resources or image bytes. + pub fn measure_nodes(file_key: impl Into, node_ids: Vec) -> Self { + Self::Snapshot { + file_key: file_key.into(), + node_id: String::new(), + script: BuiltinScript::MeasureNodes, + resources: None, + snapshot: None, + root_ids: Some(node_ids), + } + } + pub fn metadata(file_key: impl Into, node_id: Option<&str>) -> Self { Self::Metadata { file_key: file_key.into(), @@ -868,6 +884,19 @@ pub trait FigmaUpstream: Send + Sync { None } + /// Refresh only through the attached plugin. Never use remote selection or + /// silently promote the last delivered event to current evidence. + async fn refresh_bridge_context( + &self, + _file_key: &str, + ) -> Result { + Err(DevupError::new( + crate::ErrorCode::DevupFigmaDirectUnavailable, + "The attached plugin cannot refresh its current selection.", + false, + )) + } + /// 이 읽기가 Figma 가 세는 경로로 가는지 — 분당 한도에 맞춰 늦춰야 하는지. /// /// 플러그인이 답하는 읽기는 한도를 쓰지 않는다. 그런데도 모든 읽기를 한도의 @@ -999,6 +1028,8 @@ impl BuiltinScript { /// 돌려주며 아는 이름을 함께 알려 준다. pub fn plugin_name(self) -> &'static str { match self { + Self::MeasureNodes => "measureNodes", + Self::BridgeContext => "bridgeContext", Self::NodeSnapshot => "snapshot", // 두 변형이 같은 원본을 쓴다. 나뉘는 곳은 넘기는 rootIds 뿐이다. Self::FastSnapshotEnvelope | Self::MultiRootSnapshotEnvelope => "fastSnapshot", diff --git a/crates/devup-mcp-figma/tests/explore.rs b/crates/devup-mcp-figma/tests/explore.rs index ba5e30ea..95b6249c 100644 --- a/crates/devup-mcp-figma/tests/explore.rs +++ b/crates/devup-mcp-figma/tests/explore.rs @@ -625,3 +625,43 @@ fn section_notes_combine_direct_text_and_descendant_annotations() { .expect("Section exploration"); assert_eq!(explored.group.expect("Section group").notes, expected); } + +#[test] +fn page_anchors_offer_only_actual_page_descendants_without_below_page_filtering() { + let mut snapshot = projection(false, false); + let mut page = raw_node( + "0:1", + "PAGE", + "Current page", + [0.0, 0.0, 2000.0, 3000.0], + 0, + "", + ); + page.fields + .insert("parentId".into(), serde_json::Value::Null); + snapshot.nodes.insert("0:1".into(), page); + let mut other = raw_node( + "9:9", + "FRAME", + "Other page screen", + [0.0, 0.0, 390.0, 844.0], + 0, + "", + ); + other.fields.insert("parentId".into(), json!("0:2")); + snapshot.nodes.insert("9:9".into(), other); + let target = FigmaTarget { + file_key: snapshot.file_key.clone(), + node_id: Some("0:1".into()), + branch_key: None, + }; + let result = explore_snapshot(&snapshot, &target, &ExploreOptions { limit: 100 }).unwrap(); + assert!(!result.candidates.is_empty()); + assert!(result.candidates.iter().all(|c| c.node.node_id != "9:9")); + assert!( + result + .candidates + .iter() + .all(|c| c.selection_reasons.iter().any(|r| r == "inside-page")) + ); +} diff --git a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs index a29249bc..073dee8a 100644 --- a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs +++ b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs @@ -244,6 +244,37 @@ function containsCompleteParentChain(result, nodeId, ancestorId) { return false; } +test("a PAGE anchor captures nested screen candidates without traversing DOCUMENT or applying heading geometry", async () => { + const screen=sceneNode({id:"nested-screen",type:"FRAME",width:390,height:844,children:[Object.assign(sceneNode({id:"copy",type:"TEXT"}),{characters:"Featured catalog"})]}); + screen.x=5000;screen.absoluteBoundingBox.x=5000; + const wrapper=sceneNode({id:"nested-wrapper",children:[screen]}); + const section=sceneNode({id:"page-section",type:"SECTION",children:[wrapper]}); + const page=pageWith(section); + const result=await executeExplore(page,page); + assert.equal(containsCompleteParentChain(result,"nested-screen",page.id),true); + assert.equal(result.nodes.some(node=>node.type==="DOCUMENT"),false); + assert.equal(result.nodes.find(node=>node.id==="nested-screen").fields.textPreview,"Featured catalog"); + assert.equal(result.nodes[0].fields.projectionTruncated,false); + assert.ok(JSON.stringify(result).length<=14000); +}); + +test("a PAGE anchor retains the bounded descendant visit and truncation contracts", async () => { + const visited=new Set(); + const groups=Array.from({length:1000},(_,i)=>sceneNode({id:`page-group-${i}`,onChildrenRead:id=>visited.add(id)})); + const page=pageWith(sceneNode({id:"page-section",type:"SECTION",children:groups})); + const result=await executeExplore(page,page); + assert.ok(visited.size<=160); + assert.equal(result.nodes[0].fields.projectionTruncated,true); +}); + +test("a PAGE projection with multibyte names fits the direct UTF-8 text budget", async () => { + const frames=Array.from({length:60},(_,i)=>sceneNode({id:`unicode-frame-${i}`,type:"FRAME",name:"화면".repeat(120),width:390,height:844})); + const page=pageWith(sceneNode({id:"unicode-section",type:"SECTION",children:frames})); + const result=await executeExplore(page,page); + assert.ok(Buffer.byteLength(JSON.stringify(result),'utf8')<=19*1024); + assert.equal(result.nodes[0].fields.projectionTruncated,true); +}); + test("a screen below two wrappers retains its complete SECTION parent chain", async () => { const screen = sceneNode({ id: "screen", type: "FRAME", width: 360, height: 740 }); const wrapper2 = sceneNode({ id: "wrapper-2", children: [screen] }); diff --git a/crates/devup-mcp/src/server/artifacts.rs b/crates/devup-mcp/src/server/artifacts.rs index ff95e4e3..2c0909e8 100644 --- a/crates/devup-mcp/src/server/artifacts.rs +++ b/crates/devup-mcp/src/server/artifacts.rs @@ -22,6 +22,8 @@ use super::delivery::{ProjectedOutput, RESOURCE_CHUNK_BYTES}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ArtifactRequestKey { + #[serde(default, skip_serializing_if = "Option::is_none")] + measurement_node_ids: Option>, file_key: String, node_id: Option, branch_key: Option, @@ -50,6 +52,7 @@ impl ArtifactRequestKey { .sort_by_cached_key(|selection| serde_json::to_string(selection).unwrap_or_default()); asset_selections.dedup(); Self { + measurement_node_ids: None, file_key: request.target.file_key.clone(), node_id: request.target.node_id.clone(), branch_key: request.target.branch_key.clone(), @@ -72,7 +75,9 @@ impl ArtifactRequestKey { } fn capabilities(&self) -> ArtifactCapabilities { - let kind = if self.search.is_some() { + let kind = if self.measurement_node_ids.is_some() { + ArtifactKind::Measurement + } else if self.search.is_some() { ArtifactKind::Search } else if self.explore.is_some() { ArtifactKind::Explore @@ -99,6 +104,14 @@ impl ArtifactRequestKey { } } + pub(super) fn measurement(target: devup_mcp_figma::FigmaTarget, ids: Vec) -> Self { + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.resource_scope = ResourceScope::None; + let mut key = Self::from_collection(&request); + key.measurement_node_ids = Some(ids); + key + } + fn can_serve_explore(&self, requested: &Self) -> bool { let (Some(cached_options), Some(requested_options)) = (self.explore.as_ref(), requested.explore.as_ref()) @@ -143,6 +156,7 @@ impl ArtifactRequestKey { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ArtifactKind { + Measurement, Design, ThemeOnly, Search, @@ -647,6 +661,10 @@ impl ArtifactStore { true, missing_artifact_details(&state, artifact_id), ) + }).and_then(|artifact| { + if artifact.capabilities.kind == ArtifactKind::Measurement { + Err(DevupError::with_details(ErrorCode::DevupFigmaHandoffInvalid,"A measurement-only artifact cannot supply export outputs; acquire a design export by explicit URL.",false,json!({"capabilities":artifact.capabilities,"artifactId":artifact.artifact_id}))) + } else {Ok(artifact)} }) } @@ -1198,6 +1216,9 @@ fn recovery_arguments(entry: &Entry) -> serde_json::Value { branch_key: key.branch_key.clone(), } .link(key.node_id.as_deref()); + if let Some(ids) = &key.measurement_node_ids { + return json!({"url":url,"nodeIds":ids}); + } let mut args = json!({"url":url,"refresh":true}); if let Some(selection) = &entry.capabilities.section_selection { args["frameIds"] = json!(selection.frame_ids); diff --git a/crates/devup-mcp/src/server/asset_jobs.rs b/crates/devup-mcp/src/server/asset_jobs.rs index d3fa8d20..4b7db4dd 100644 --- a/crates/devup-mcp/src/server/asset_jobs.rs +++ b/crates/devup-mcp/src/server/asset_jobs.rs @@ -583,6 +583,7 @@ mod tests { asset_output_paths: Default::default(), asset_public_root: None, fields: vec![], + response_selection: Default::default(), delivery: super::super::delivery::DeliveryMode::Auto, width_policy: None, }; diff --git a/crates/devup-mcp/src/server/compact.rs b/crates/devup-mcp/src/server/compact.rs new file mode 100644 index 00000000..c69a45ad --- /dev/null +++ b/crates/devup-mcp/src/server/compact.rs @@ -0,0 +1,500 @@ +//! Bounded data selection, never an executable query language. +use devup_mcp_figma::{DevupError, ErrorCode, Snapshot}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeSet, VecDeque}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ResponseSelection { + /// At most 16 paths: dot fields, [index], [name="exact name"] or [id="exact id"]. + /// nodes selects captured node summaries, e.g. nodes[name="Cards"].layout. + #[serde(default)] + #[schemars(extend("maxItems" = 16))] + pub response_fields: Vec, + #[serde(default)] + pub path: Option, + /// Stable captured-order offset for outline and each selected path. + #[serde(default)] + pub response_offset: usize, + #[serde(default = "default_limit")] + #[schemars(range(min = 1, max = 200))] + pub response_limit: usize, +} +fn default_limit() -> usize { + 100 +} +impl Default for ResponseSelection { + fn default() -> Self { + Self { + response_fields: vec![], + path: None, + response_offset: 0, + response_limit: default_limit(), + } + } +} + +#[derive(Debug, PartialEq)] +enum Step { + Field(String), + Index(usize), + Exact(String, String), +} +fn invalid() -> DevupError { + DevupError::new( + ErrorCode::DevupInvalidInput, + "Selection accepts at most 16 paths of 256 bytes and 8 steps: dot fields, [index], [name=\"exact\"] or [id=\"exact\"]. No wildcards, code, recursion or expressions. responseLimit is 1..200; responseOffset is at most 100000.", + false, + ) +} +fn parse(path: &str) -> Result, DevupError> { + if path.is_empty() || path.len() > 256 { + return Err(invalid()); + } + let mut steps = Vec::new(); + let mut rest = path; + loop { + let end = rest.find(['.', '[']).unwrap_or(rest.len()); + let key = &rest[..end]; + if key.is_empty() + || !key + .bytes() + .enumerate() + .all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && b.is_ascii_digit())) + || matches!(key, "__proto__" | "constructor" | "prototype") + { + return Err(invalid()); + } + steps.push(Step::Field(key.into())); + rest = &rest[end..]; + while let Some(after) = rest.strip_prefix('[') { + if after.starts_with("name=") || after.starts_with("id=") { + let (field, value) = after.split_once('=').ok_or_else(invalid)?; + let mut decoded = serde_json::Deserializer::from_str(value).into_iter::(); + let value_decoded = decoded.next().ok_or_else(invalid)?.map_err(|_| invalid())?; + let tail = &value[decoded.byte_offset()..]; + rest = tail.strip_prefix(']').ok_or_else(invalid)?; + steps.push(Step::Exact(field.into(), value_decoded)); + } else { + let (index, tail) = after.split_once(']').ok_or_else(invalid)?; + if index.is_empty() || !index.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid()); + } + let index = index.parse::().map_err(|_| invalid())?; + if index > 100_000 { + return Err(invalid()); + } + steps.push(Step::Index(index)); + rest = tail; + } + } + if steps.len() > 8 { + return Err(invalid()); + } + if rest.is_empty() { + break; + } + rest = rest.strip_prefix('.').ok_or_else(invalid)?; + } + Ok(steps) +} +impl ResponseSelection { + pub fn validate(&self) -> Result<(), DevupError> { + if self.response_fields.len() + usize::from(self.path.is_some()) > 16 + || self.response_limit == 0 + || self.response_limit > 200 + || self.response_offset > 100_000 + { + return Err(invalid()); + } + for path in self.response_fields.iter().chain(self.path.iter()) { + parse(path)?; + } + Ok(()) + } + pub fn active(&self) -> bool { + !self.response_fields.is_empty() || self.path.is_some() + } +} + +pub fn layout(node: &devup_mcp_figma::RawNode) -> Value { + let number = |key| { + node.fields + .get(key) + .and_then(Value::as_f64) + .filter(|n| n.is_finite()) + .map_or(Value::Null, |n| json!(n)) + }; + let label = |key| { + node.fields + .get(key) + .and_then(Value::as_str) + .filter(|s| s.len() <= 32) + }; + json!({"width":number("width"),"height":number("height"), + "padding":{"top":number("paddingTop"),"right":number("paddingRight"),"bottom":number("paddingBottom"),"left":number("paddingLeft")}, + "gap":number("itemSpacing"),"layoutMode":label("layoutMode"), + "align":{"primary":label("primaryAxisAlignItems"),"counter":label("counterAxisAlignItems")}}) +} + +pub fn node_summaries(snapshot: &Snapshot, roots: &[String]) -> Value { + node_summaries_bounded(snapshot, roots, 100_000) +} + +fn node_summaries_bounded(snapshot: &Snapshot, roots: &[String], scan_limit: usize) -> Value { + let mut queue = roots + .iter() + .map(|id| (id.as_str(), 0)) + .collect::>(); + let mut seen = BTreeSet::new(); + let mut nodes = Vec::new(); + let mut missing = Vec::new(); + while let Some((id, depth)) = queue.pop_front() { + if !seen.insert(id) { + continue; + } + if seen.len() > scan_limit { + break; + } + let Some(node) = snapshot.nodes.get(id) else { + missing.push(id); + continue; + }; + let view = node.typed_view(); + let children = view.child_ids().collect::>(); + queue.extend(children.iter().map(|id| (*id, depth + 1))); + nodes.push(json!({"id":id,"nodeId":id,"name":view.name().map(|s| s.chars().take(256).collect::()), + "type":node.node_type,"parentId":view.string("parentId"),"depth":depth,"childCount":children.len(),"layout":layout(node)})); + } + json!({"nodes":nodes,"scope":"captured selected roots and descendants","roots":roots, + "scanTruncated":seen.len()>scan_limit || !queue.is_empty(),"missingCapturedNodes":missing.len(), + "evidenceLimit":"Only captured fields are summarized; null is unavailable, never zero. No live selection or rendered implementation is verified."}) +} + +pub fn outline(summary: &Value, options: &ResponseSelection) -> Value { + let nodes = summary["nodes"].as_array().expect("node summaries"); + let end = nodes.len().min( + options + .response_offset + .saturating_add(options.response_limit), + ); + let page = nodes + .iter() + .skip(options.response_offset) + .take(options.response_limit) + .cloned() + .collect::>(); + json!({"nodes":page,"coordinateSpace":"figma-logical","scope":summary["scope"],"roots":summary["roots"], + "pagination":{"offset":options.response_offset,"returned":page.len(),"totalCaptured":nodes.len(), + "nextOffset":(end < nodes.len()).then_some(end),"complete":end >= nodes.len() && summary["scanTruncated"] == false && summary["missingCapturedNodes"] == 0}, + "scanTruncated":summary["scanTruncated"],"missingCapturedNodes":summary["missingCapturedNodes"],"evidenceLimit":summary["evidenceLimit"]}) +} + +pub fn select( + result: &mut serde_json::Map, + summary: &Value, + options: &ResponseSelection, +) -> Result, DevupError> { + if !options.active() { + return Ok(None); + } + options.validate()?; + let mut root = Value::Object(result.clone()); + root["nodes"] = summary["nodes"].clone(); + let mut selections = Vec::new(); + for path in options.response_fields.iter().chain(options.path.iter()) { + let mut values = vec![(&root, None::)]; + for step in parse(path)? { + values = match step { + Step::Field(key) => values + .into_iter() + .flat_map(|(v, id)| { + if let Some(items) = v.as_array() { + items + .iter() + .filter_map(|v| { + v.get(&key).map(|field| { + ( + field, + v.get("nodeId") + .or_else(|| v.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| id.clone()), + ) + }) + }) + .collect::>() + } else { + v.get(&key).map(|field| (field, id)).into_iter().collect() + } + }) + .collect(), + Step::Index(index) => values + .into_iter() + .filter_map(|(v, id)| { + v.get(index).map(|item| { + ( + item, + item.get("nodeId") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .or(id), + ) + }) + }) + .collect(), + Step::Exact(key, value) => values + .into_iter() + .flat_map(|(v, _)| v.as_array().into_iter().flatten()) + .filter(|v| v.get(&key).and_then(Value::as_str) == Some(value.as_str())) + .map(|v| { + ( + v, + v.get("nodeId") + .or_else(|| v.get("id")) + .and_then(Value::as_str) + .map(str::to_owned), + ) + }) + .collect(), + }; + } + let count = values.len(); + let end = count.min( + options + .response_offset + .saturating_add(options.response_limit), + ); + let identities = values + .iter() + .skip(options.response_offset) + .take(options.response_limit) + .map(|(_, id)| json!(id)) + .collect::>(); + let values = values + .into_iter() + .skip(options.response_offset) + .take(options.response_limit) + .map(|(v, _)| v.clone()) + .collect::>(); + selections.push(json!({"path":path,"values":values,"matchedCaptured":count, + "nodeIds":identities, + "offset":options.response_offset,"nextOffset":(end < count).then_some(end), + "state":if count == 0 {"not-found-in-capture"} else {"selected"},"truncated":end < count})); + } + let selected = json!({"selections":selections,"coordinateSpace":"figma-logical", + "scanTruncated":summary["scanTruncated"],"missingCapturedNodes":summary["missingCapturedNodes"], + "evidenceLimit":summary["evidenceLimit"]}); + if serde_json::to_vec(&selected).unwrap_or_default().len() > 65_536 { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "Selected data exceeds 65536 bytes. Select a narrower field or lower responseLimit; a scalar is never silently truncated.", + false, + )); + } + let evidence_fields = [ + "projectionIssues", + "projectionEvidence", + "completenessReport", + "fidelity", + "verdictScope", + "outputResults", + "failures", + "warnings", + "responsiveReviewChecklist", + "sectionChecklist", + "projectionReview", + "placementContracts", + "widthBehavior", + "imageFillAudit", + "imageAudit", + "assetCssMap", + "assetSummary", + "widgetCandidates", + "sharedComponentCandidates", + "breakpointCompletion", + ]; + let mut details = serde_json::Map::new(); + for field in evidence_fields { + if let Some(value) = result.get(field) { + details.insert(field.into(), value.clone()); + } + } + if let Some(frames) = result.get("frames").and_then(Value::as_array) { + details.insert("frames".into(),json!(frames.iter().map(|f|json!({"nodeId":f["nodeId"],"failures":f["failures"],"outputResults":f["outputResults"],"projectionIssues":f["projectionIssues"],"fidelity":f["fidelity"]})).collect::>())); + } + let issue_count = result + .get("projectionIssues") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let issues=result.get("projectionIssues").and_then(Value::as_array).map(|items|items.iter().take(10).map(|v|json!({"code":v["code"],"nodeId":v["nodeId"],"property":v["property"],"fidelityImpact":v["fidelityImpact"],"message":v["message"].as_str().map(|s|s.chars().take(240).collect::())})).collect::>()).unwrap_or_default(); + let output_results = result.get("outputResults").and_then(Value::as_object).map(|items| items.iter().map(|(name,v)|(name.clone(),json!({"state":v["state"],"projection":v["projection"],"bodyDelivery":"omitted-by-selection"}))).collect::>()); + result.retain(|key, _| { + matches!( + key.as_str(), + "status" + | "source" + | "cache" + | "quality" + | "completeness" + | "collection" + | "verificationScope" + | "outputPaths" + | "outputPathResults" + | "resume" + | "responsiveEvidence" + | "mappingComplete" + ) + }); + result.insert("projectionIssues".into(), json!(issues)); + result.insert("selectionEvidence".into(),json!({"name":"compact-details.json","fields":evidence_fields, + "projectionIssueCount":issue_count,"returnedProjectionIssues":issues.len(),"issuesTruncated":issue_count>issues.len(), + "requiresFullDetailRead":true,"scope":"Complete omitted evidence for the original requested projection; selected-data delivery does not establish code completeness."})); + result.insert("deliverable".into(),json!({"kind":"selected-data","state":"data-only","codeDelivered":false, + "evidenceLimit":"Quality describes source acquisition and requested projections; selected fields do not deliver or verify a complete application."})); + if let Some(outputs) = output_results { + result.insert("outputResults".into(), json!(outputs)); + } + result.insert("selected".into(), selected); + Ok(Some(super::delivery::ProjectedOutput::text( + "compact-details.json", + "application/json", + serde_json::to_vec(&details).map_err(|_| invalid())?, + ))) +} + +/// Standalone outlines keep the small data body inline and move unrelated +/// full acquisition/projection evidence into the same bounded resource path. +pub fn outline_response( + result: &mut serde_json::Map, + summary: &Value, +) -> Result, DevupError> { + let body = result.get("outline").cloned().unwrap_or(Value::Null); + let details = select( + result, + summary, + &ResponseSelection { + path: Some("outline".into()), + response_limit: 1, + ..Default::default() + }, + )?; + result.remove("selected"); + result.insert("outline".into(), body); + result.get_mut("deliverable").unwrap()["kind"] = json!("outline-data"); + Ok(details) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn the_last_node_past_the_scan_cap_still_marks_partial_outline_data() { + let snapshot: Snapshot = + serde_json::from_value(json!({"fileKey":"Synthetic","roots":["1:1"],"nodes":{ + "1:1":{"id":"1:1","type":"FRAME","fields":{"childrenIds":["1:2"]}}, + "1:2":{"id":"1:2","type":"FRAME","fields":{"childrenIds":["1:3"]}}, + "1:3":{"id":"1:3","type":"TEXT","fields":{}} + },"diagnostics":[]})) + .unwrap(); + let summary = node_summaries_bounded(&snapshot, &snapshot.roots, 2); + assert_eq!(summary["nodes"].as_array().unwrap().len(), 2); + assert_eq!(summary["scanTruncated"], true); + assert_eq!( + outline(&summary, &ResponseSelection::default())["pagination"]["complete"], + false + ); + let complete = node_summaries_bounded(&snapshot, &snapshot.roots, 3); + assert_eq!(complete["scanTruncated"], false); + assert_eq!( + outline(&complete, &ResponseSelection::default())["pagination"]["complete"], + true + ); + } + #[test] + fn grammar_refuses_execution_recursion_and_overruns() { + for path in [ + "nodes[*]", + "nodes.filter(x)", + "nodes..layout", + "nodes[0];alert(1)", + "__proto__.x", + "nodes[name='x']", + "nodes[100001]", + "nodes[name=\"x\"]tail", + ] { + assert!(parse(path).is_err(), "{path}"); + } + assert!(parse("nodes[name=\"Cards\"].layout.padding").is_ok()); + assert!(parse("frames[0].tsx").is_ok()); + } + #[test] + fn exact_unicode_selection_and_pagination_keep_identity() { + let summary = json!({"nodes":[{"id":"1:1","name":"Cards","layout":{"width":167}},{"id":"1:2","name":"Cards","layout":{"width":195}}],"scanTruncated":false,"missingCapturedNodes":0}); + let mut result = + json!({"tsx":"huge code","cache":{"artifactId":"stable"},"failures":[{"code":"gap"}]}) + .as_object() + .unwrap() + .clone(); + let options = ResponseSelection { + path: Some("nodes[name=\"Cards\"].layout".into()), + response_limit: 1, + ..Default::default() + }; + select(&mut result, &summary, &options).unwrap(); + assert_eq!( + result["selected"]["selections"][0]["values"][0]["width"], + 167 + ); + assert_eq!(result["selected"]["selections"][0]["nextOffset"], 1); + assert_eq!( + result["selected"]["selections"][0]["nodeIds"], + json!(["1:1"]) + ); + assert_eq!(result["cache"]["artifactId"], "stable"); + assert!(result.get("tsx").is_none()); + assert_eq!(outline(&summary, &options)["pagination"]["complete"], false); + } + #[test] + fn bulky_evidence_has_a_full_resource_and_bounded_inline_preview() { + let mut result=json!({"status":"partial","quality":{"projection":"lossy"},"projectionIssues":(0..500).map(|i|json!({"code":"SYNTHETIC_GAP","nodeId":format!("1:{i}"),"message":"x".repeat(1000),"details":{"value":"y".repeat(1000)}})).collect::>(),"tsx":"large code","outputResults":{"tsx":{"state":"produced","projection":"lossy"}}}).as_object().unwrap().clone(); + let summary = json!({"nodes":[{"id":"1:1","name":"Cards","layout":{"width":167}}],"scanTruncated":false,"missingCapturedNodes":0}); + let options = ResponseSelection { + path: Some("nodes[id=\"1:1\"].layout".into()), + ..Default::default() + }; + let resource = select(&mut result, &summary, &options).unwrap().unwrap(); + assert!(resource.bytes.len() > 1_000_000); + assert!(serde_json::to_vec(&result).unwrap().len() < 10_000); + assert_eq!(result["status"], "partial"); + assert_eq!(result["selectionEvidence"]["projectionIssueCount"], 500); + assert_eq!(result["projectionIssues"].as_array().unwrap().len(), 10); + assert_eq!(result["deliverable"]["codeDelivered"], false); + assert_eq!( + result["outputResults"]["tsx"]["bodyDelivery"], + "omitted-by-selection" + ); + } + #[test] + fn standalone_outline_keeps_only_bounded_data_and_links_verbose_evidence() { + let body = json!({"nodes":[{"id":"1:1","layout":{"width":167}}],"pagination":{"offset":2,"nextOffset":3}}); + let mut result=json!({"status":"partial","outline":body,"projectionIssues":(0..500).map(|_|json!({"code":"SYNTHETIC_GAP","message":"x".repeat(1000),"details":{"raw":"y".repeat(1000)}})).collect::>(),"imageFillAudit":{"unrelated":"z".repeat(50000)}}).as_object().unwrap().clone(); + let details = outline_response( + &mut result, + &json!({"nodes":[],"scanTruncated":false,"missingCapturedNodes":0}), + ) + .unwrap() + .unwrap(); + assert_eq!(result["outline"], body); + assert_eq!(result["status"], "partial"); + assert_eq!(result["deliverable"]["kind"], "outline-data"); + assert!(result.get("selected").is_none()); + assert!(result.get("imageFillAudit").is_none()); + assert!(serde_json::to_vec(&result).unwrap().len() < 10000); + assert!(details.bytes.len() > 1_000_000); + } +} diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index a136f7f2..f87d6d28 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -597,6 +597,10 @@ fn attached_file(file: &AttachedFile) -> Value { "currentPage": file.context.current_page, "selection": file.context.selection, "selectionCount": file.context.selection_count, + "zoom":file.context.zoom,"uiDevicePixelRatio":file.context.ui_device_pixel_ratio, + "contextFreshness":"last-delivered-event","observedAt":file.context.observed_at, + "lastKnownSelection":file.context.last_known_selection,"lastKnownPage":file.context.last_known_page, + "displayContextEvidenceLimit":"Zoom is Figma viewport zoom; DPR belongs to the plugin UI iframe and does not verify screenshot capture scale.", }); if let Some(in_view) = file.context.in_view { entry["inView"] = json!(in_view); @@ -823,6 +827,7 @@ mod tests { selection: selected.map(|count| (1..=count).map(node).collect()), selection_count: selected, in_view: Some(false), + ..Default::default() }, } } diff --git a/crates/devup-mcp/src/server/feature_trace.rs b/crates/devup-mcp/src/server/feature_trace.rs index df441730..a9631463 100644 --- a/crates/devup-mcp/src/server/feature_trace.rs +++ b/crates/devup-mcp/src/server/feature_trace.rs @@ -324,6 +324,15 @@ fn design_source(source: &str) -> Design { async fn design(input: &FeatureTraceInput, artifacts: &ArtifactStore) -> Design { if supplied(&input.artifact_id) { if let Some(artifact) = artifacts.get(input.artifact_id.as_deref().unwrap()).await { + if artifact.capabilities.kind == super::artifacts::ArtifactKind::Measurement { + return Design { + evidence: json!({"source":"measurement-only-artifact","artifactId":artifact.artifact_id,"state":"unverified","capabilities":artifact.capabilities}), + diagnostics: vec![ + json!({"reason":"Measurement receipts contain logical dimensions only; design structure, component bindings and code were not acquired. Export a design snapshot for feature tracing."}), + ], + ..Design::default() + }; + } let node = input.figma_node_id.as_deref().or(artifact .payload .target diff --git a/crates/devup-mcp/src/server/measurement.rs b/crates/devup-mcp/src/server/measurement.rs new file mode 100644 index 00000000..654110e4 --- /dev/null +++ b/crates/devup-mcp/src/server/measurement.rs @@ -0,0 +1,321 @@ +use super::{DevupServer, Unnamed, parse_link, tools::FigmaMeasureInput}; +use devup_mcp_figma::{DevupError, ErrorCode, FigmaTarget, ReadToolCall}; +use serde_json::{Value, json}; + +fn invalid(message: &str) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} +pub(super) fn decode(raw: &Value) -> Result { + if raw["kind"] == "devup-measure" { + return Ok(raw.clone()); + } + if raw["structuredContent"]["kind"] == "devup-measure" { + return Ok(raw["structuredContent"].clone()); + } + raw["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|c| c["text"].as_str()) + .filter_map(|text| serde_json::from_str::(text).ok()) + .find(|v| v["kind"] == "devup-measure") + .ok_or_else(|| invalid("The measurement read returned no measurement envelope.")) +} + +fn validated_node(node: &Value, id: &str) -> Result { + if node["nodeId"] != id + || !matches!( + node["status"].as_str(), + Some("measured" | "partial" | "unavailable") + ) + { + return Err(invalid( + "Measurement node identity or state does not match the request.", + )); + } + if node["status"] == "unavailable" { + return Ok(json!({"nodeId":id,"status":"unavailable","reason":"node-read-unavailable"})); + } + let numeric = |key: &str, v: &Value| -> Result { + if v.is_null() { + return Ok(Value::Null); + } + let n = v + .as_f64() + .filter(|n| n.is_finite() && n.abs() <= 1e12) + .ok_or_else(|| invalid("Measurement contains an invalid numeric layout value."))?; + if matches!(key, "width" | "height") && n < 0.0 { + return Err(invalid("Logical dimensions cannot be negative.")); + } + Ok(v.clone()) + }; + let label = |v: &Value| -> Result { + if v.is_null() + || v.as_str().is_some_and(|s| { + s.len() <= 32 && s.bytes().all(|b| b.is_ascii_uppercase() || b == b'_') + }) + { + Ok(v.clone()) + } else { + Err(invalid( + "Measurement layout labels must be null or bounded strings.", + )) + } + }; + let width = numeric("width", &node["width"])?; + let height = numeric("height", &node["height"])?; + if node["status"] == "measured" && (width.is_null() || height.is_null()) { + return Err(invalid( + "A measured node requires finite logical width and height.", + )); + } + Ok( + json!({"nodeId":id,"status":node["status"],"width":width,"height":height, + "padding":{"top":numeric("padding",&node["padding"]["top"] )?,"right":numeric("padding",&node["padding"]["right"] )?,"bottom":numeric("padding",&node["padding"]["bottom"] )?,"left":numeric("padding",&node["padding"]["left"] )?}, + "gap":numeric("gap",&node["gap"] )?,"layoutMode":label(&node["layoutMode"] )?, + "align":{"primary":label(&node["align"]["primary"] )?,"counter":label(&node["align"]["counter"] )?}}), + ) +} +pub(super) fn calibrate(result: &Value, input: &super::tools::MeasureCalibration) -> Value { + let Some(node) = result["nodes"] + .as_array() + .into_iter() + .flatten() + .find(|n| n["nodeId"] == input.node_id) + else { + return json!({"state":"unverified","reason":"calibration-node-unavailable"}); + }; + let (Some(width), Some(height)) = (node["width"].as_f64(), node["height"].as_f64()) else { + return json!({"state":"unverified","reason":"logical-dimensions-unavailable"}); + }; + if width <= 0.0 || height <= 0.0 { + return json!({"state":"unverified","reason":"zero-logical-dimension"}); + } + let x = input.screen_width_px / width; + let y = input.screen_height_px / height; + let consistent = x.is_finite() && y.is_finite() && (x - y).abs() / x.max(y) <= 0.02; + json!({"state":if consistent {"calibrated-from-caller-pixels"} else {"unverified"}, + "nodeId":input.node_id,"logicalWidth":width,"logicalHeight":height, + "scaleX":x.is_finite().then_some(x),"scaleY":y.is_finite().then_some(y), + "uniformScale":consistent.then_some((x+y)/2.0),"aspectConsistent":consistent, + "evidenceLimit":"Screen pixels are caller-declared. No screenshot, canvas mapping, crop or browser viewport was inspected; UI DPR and zoom do not prove capture scale. Compare DOM CSS logical dimensions to these Figma logical dimensions."}) +} + +pub(super) async fn run( + server: &DevupServer, + mut input: FigmaMeasureInput, +) -> Result { + if input.node_ids.is_empty() || input.node_ids.len() > 100 { + return Err(invalid("nodeIds requires 1..100 distinct IDs.")); + } + // Reuse the URL parser's canonical instance-descendant grammar. + for id in &mut input.node_ids { + if id.len() > 128 { + return Err(invalid("A node ID exceeds 128 bytes.")); + } + let mut url = url::Url::parse("figma-bridge://current").expect("constant URL"); + url.query_pairs_mut().append_pair("node-id", id); + *id = FigmaTarget::parse(url.as_str())? + .node_id + .expect("explicit ID"); + } + if input + .node_ids + .iter() + .collect::>() + .len() + != input.node_ids.len() + { + return Err(invalid("nodeIds must be distinct after normalization.")); + } + if let Some(calibration) = &input.calibration + && (!input.node_ids.contains(&calibration.node_id) + || !calibration.screen_width_px.is_finite() + || !calibration.screen_height_px.is_finite() + || calibration.screen_width_px <= 0.0 + || calibration.screen_height_px <= 0.0 + || calibration.screen_width_px > 1_000_000.0 + || calibration.screen_height_px > 1_000_000.0) + { + return Err(invalid( + "calibration needs a requested canonical nodeId and finite positive screen dimensions at most 1000000 pixels.", + )); + } + let target = server + .bind_to_bridge( + parse_link(input.url.as_deref())?, + Unnamed::File, + &json!({"tool":"devup_figma_measure","arguments":{"nodeIds":input.node_ids}}), + ) + .await?; + let mut measured = Vec::new(); + let mut stats = devup_mcp_figma::CollectionStats { + transport: "explicit-node-list".into(), + ..Default::default() + }; + // At most 10 nodes per direct text response; even maximal ID/float/label + // lengths fit below the upstream 20 KiB ceiling. Bridge uses the same bound. + for ids in input.node_ids.chunks(10) { + let call = ReadToolCall::measure_nodes(&target.file_key, ids.to_vec()); + if server.services.upstream.is_metered(&call).await { + server.services.pacer.acquire().await; + } + // Live measurement deliberately bypasses the acquisition call bank. + let read = tokio::time::timeout( + std::time::Duration::from_secs(15), + server.services.upstream.call_read_tool(call), + ) + .await + .map_err(|_| { + DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "Measurement timed out; no cached dimensions were substituted.", + true, + ) + })??; + if let Some(error) = super::operation::upstream_error(&read.raw) { + return Err(error); + } + stats.figma_tool_calls += 1; + stats.raw_bytes += read.raw.to_string().len(); + stats.wire_bytes += read.raw.to_string().len(); + if let Some(mut served) = devup_mcp_figma::BridgeServed::from_result(&read.raw) { + served.reads += stats.bridge.as_ref().map_or(0, |b| b.reads); + stats.bridge = Some(served); + } + let result = decode(&read.raw)?; + let nodes = result["nodes"] + .as_array() + .ok_or_else(|| invalid("Measurement nodes are missing."))?; + if result["schemaVersion"] != 1 + || result["coordinateSpace"] != "figma-logical" + || nodes.len() != ids.len() + || result.to_string().len() > 20_000 + { + return Err(invalid( + "Measurement envelope size, count or coordinate space is invalid.", + )); + } + for (node, id) in nodes.iter().zip(ids) { + measured.push(validated_node(node, id)?); + } + } + let mut result = json!({"kind":"devup-measure","schemaVersion":1,"coordinateSpace":"figma-logical","nodes":measured, + "evidenceLimit":"Only explicit node layout fields were read; null is unavailable or not applicable. No descendants, resources, image bytes or generated code were acquired."}); + let nodes = result["nodes"] + .as_array() + .ok_or_else(|| invalid("Measurement nodes are missing."))?; + if result["schemaVersion"] != 1 + || result["coordinateSpace"] != "figma-logical" + || nodes.len() != input.node_ids.len() + || nodes.iter().zip(&input.node_ids).any(|(n, id)| { + n["nodeId"].as_str() != Some(id.as_str()) + || !matches!( + n["status"].as_str(), + Some("measured" | "partial" | "unavailable") + ) + }) + { + return Err(invalid( + "Measurement response identity, count or coordinate space does not match this request.", + )); + } + let partial = nodes.iter().any(|n| n["status"] != "measured"); + let retained_nodes = nodes + .iter() + .filter(|n| n["status"] != "unavailable") + .map(|n| { + ( + n["nodeId"].as_str().unwrap().to_owned(), + json!({"id":n["nodeId"],"type":"MEASUREMENT", + "fields":{"width":n["width"],"height":n["height"]}}), + ) + }) + .collect::>(); + stats.node_count = retained_nodes.len(); + let file_key = stats.bridge.as_ref().map_or_else( + || (!target.is_bridge_only()).then_some(target.file_key.clone()), + |b| b.file_key.clone(), + ); + let source_kind = if stats.bridge.is_some() { + "bridge" + } else { + "direct" + }; + let payload: devup_mcp_figma::CollectedPayload = serde_json::from_value(json!({ + "target":target,"scope":"node","metadata":{"kind":"explicit-node-measurement","partial":partial}, + "snapshot":{"fileKey":target.file_key,"version":null,"roots":retained_nodes.keys().collect::>(),"nodes":retained_nodes,"diagnostics":[]}, + "variables":null,"styles":null,"completeness":"resolved-values-only","sourceVersion":null,"stats":stats + })).map_err(|_| invalid("Measurement receipt could not be retained."))?; + let artifact = server + .artifacts + .get_or_acquire( + super::artifacts::ArtifactRequestKey::measurement( + target.clone(), + input.node_ids.clone(), + ), + true, + || async { Ok(payload) }, + ) + .await?; + result["cache"] = super::projection::artifact_metadata(&artifact); + result["source"] = json!({"kind":source_kind,"fileKey":file_key,"requestedFileKey":(!target.is_bridge_only()).then_some(&target.file_key),"canonicalUrl":target.link(None),"nodeIds":input.node_ids,"freshness":"operation-time-read","collection":"explicit-node-list-only"}); + result["partial"] = json!(partial); + result["status"] = json!(if partial { "partial" } else { "complete" }); + if let Some(calibration) = input.calibration { + result["calibration"] = calibrate(&result, &calibration); + } + result["displayContext"] = match tokio::time::timeout( + std::time::Duration::from_secs(3), + server + .services + .upstream + .refresh_bridge_context(&target.file_key), + ) + .await + { + Ok(Ok(context)) => { + json!({"state":"current","zoom":context.zoom,"uiDevicePixelRatio":context.ui_device_pixel_ratio,"observedAt":context.observed_at, + "evidenceLimit":"DPR belongs to the plugin UI iframe; neither it nor zoom verifies screenshot-to-canvas scale."}) + } + _ => { + json!({"state":"unavailable","zoom":null,"uiDevicePixelRatio":null,"evidenceLimit":"The attached Bridge could not refresh display context. Logical node dimensions do not depend on zoom or DPR."}) + } + }; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn calibration_requires_consistent_known_dimensions_and_discloses_evidence() { + let result = json!({"nodes":[{"nodeId":"1:1","width":100,"height":50}]}); + let mut input = super::super::tools::MeasureCalibration { + node_id: "1:1".into(), + screen_width_px: 200.0, + screen_height_px: 100.0, + }; + assert_eq!(calibrate(&result, &input)["uniformScale"], 2.0); + input.screen_height_px = 200.0; + assert_eq!(calibrate(&result, &input)["uniformScale"], Value::Null); + input.node_id = "2:2".into(); + assert_eq!(calibrate(&result, &input)["state"], "unverified"); + } + #[test] + fn malformed_measured_nodes_are_refused_and_untrusted_extras_are_removed() { + for width in [Value::Null, json!("167"), json!(-1)] { + assert!( + validated_node( + &json!({"nodeId":"1:1","status":"measured","width":width,"height":94}), + "1:1" + ) + .is_err() + ); + } + let valid=validated_node(&json!({"nodeId":"1:1","status":"measured","width":167,"height":94,"code":"untrusted","image":"untrusted"}),"1:1").unwrap(); + assert!(valid.get("code").is_none()); + assert!(valid.get("image").is_none()); + assert!(validated_node(&json!({"nodeId":"2:2","status":"unavailable"}), "1:1").is_err()); + } +} diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 4b73d7b7..8ae4b95e 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -1,6 +1,7 @@ pub mod artifacts; mod asset_jobs; mod call_cache; +mod compact; pub mod delivery; mod design_drift; mod diagnostics; @@ -10,6 +11,7 @@ mod feedback_interactions_tests; mod field_select; mod guide; mod interactions; +mod measurement; mod navigation; pub mod operation; pub mod output; @@ -23,6 +25,8 @@ pub mod resources; mod result_contract; mod review; mod runtime_audit; +mod screenshot_observations; +mod section_checklist; pub mod self_update; mod skills; mod stack_diff; @@ -861,8 +865,8 @@ impl DevupServer { let attached = bridge .as_ref() .map_or(&[][..], |bridge| bridge.attached_files.as_slice()); - let file = match attached { - [file] => file, + let mut file = match attached { + [file] => file.clone(), [] => { let direct_available = self.services.auth.status().await? == AuthStatus::Connected; return Err(DevupError::with_details( @@ -916,6 +920,26 @@ impl DevupServer { if target.node_id.is_some() || unnamed == Unnamed::File { return Ok(target); } + let last_delivered = file.context.clone(); + let refreshed = tokio::time::timeout( + std::time::Duration::from_secs(3), + self.services + .upstream + .refresh_bridge_context(&file.target_key), + ) + .await; + match refreshed { + Ok(Ok(context)) => file.context = context, + _ => { + let mut unnamed_file = file.clone(); + unnamed_file.context.selection = None; + let mut error = selection_refusal(&file, + "Current Bridge selection could not be refreshed. Last-known context is diagnostic only; no node was exported.".into(),name_the_node(&unnamed_file,retry)); + error.details["selectionFreshness"] = json!({"state":"refresh-failed","current":null,"lastKnown":last_delivered, + "nextStep":"Reload an up-to-date Bridge plugin or pass an explicit node link; the last-known node is never selected automatically."}); + return Err(error); + } + } let context = &file.context; match (context.single_selection(), unnamed) { (Some(node), Unnamed::SelectedSection) @@ -932,7 +956,7 @@ impl DevupServer { "Select the Section in Figma and repeat the call, or export one frame per call: a single frameId and no url, once for each frame." ); Err(selection_refusal( - file, + &file, format!( "frameIds and allScreens without url choose screens inside the Section \ selected in Figma, and the selection is a {}.", @@ -950,27 +974,102 @@ impl DevupServer { { Ok(target) } - (None, _) => Err(selection_refusal( - file, - match &context.selection { - None => "No url or node was given, and the attached Devup Bridge plugin does \ + (None, _) => { + let mut error = selection_refusal( + &file, + match &context.selection { + None => { + "No url or node was given, and the attached Devup Bridge plugin does \ not report the Figma selection - it predates this devup-mcp. Re-run \ Devup Bridge from this devup-mcp's plugin/manifest.json, or name the \ node." - .to_owned(), - Some(selection) if selection.is_empty() => { - "No url or node was given and nothing is selected in Figma. Select the \ + .to_owned() + } + Some(selection) if selection.is_empty() => { + "No url or node was given and nothing is selected in Figma. Select the \ node in Figma, or name it." - .to_owned() - } - Some(selection) => format!( - "No url or node was given and {} nodes are selected in Figma. Select \ + .to_owned() + } + Some(selection) => format!( + "No url or node was given and {} nodes are selected in Figma. Select \ exactly one, or name the one you mean.", - context.selection_count.unwrap_or(selection.len()) - ), - }, - name_the_node(file, retry), - )), + context.selection_count.unwrap_or(selection.len()) + ), + }, + name_the_node(&file, retry), + ); + error.details["selectionFreshness"] = + json!({"state":"current","current":file.context,"lastKnown":last_delivered}); + if context.selection.as_ref().is_some_and(Vec::is_empty) + && let Some(page) = &context.current_page + { + let observations = retry["arguments"] + .get("screenshotObservations") + .filter(|v| !v.is_null()) + .and_then(|v| { + serde_json::from_value::< + screenshot_observations::ScreenshotObservations, + >(v.clone()) + .ok() + }); + let anchor = FigmaTarget { + node_id: Some(page.id.clone()), + ..target.clone() + }; + error.details["exploreSuggestion"] = json!({"tool":"devup_figma_explore","arguments":{"url":anchor.link(Some(&page.id)),"limit":10}, + "basis":"current-page-context","screenshotMatch":"unverified","evidenceLimit":"No screenshot was inspected. Candidates use actual Figma page structure and geometry only."}); + if let Some(hints) = &observations { + error.details["exploreSuggestion"]["arguments"]["screenshotObservations"] = + json!(hints); + error.details["exploreSuggestion"]["basis"] = + json!("caller-observations-and-current-page-evidence"); + } + let call = ReadToolCall::explore_snapshot( + &target.file_key, + &page.id, + ExploreReadOptions { + projection_limit: EXPLORE_PROJECTION_LIMIT, + text_preview_limit: if observations.is_some() { 160 } else { 0 }, + }, + ); + if let Ok(Ok(read)) = tokio::time::timeout( + std::time::Duration::from_secs(3), + self.services.upstream.call_read_tool(call), + ) + .await + && let Ok(chunk) = devup_mcp_figma::snapshot_chunk_from_result(&read) + && let Ok(snapshot) = devup_mcp_figma::merge_chunks(vec![chunk]) + && let Ok(candidates) = devup_mcp_figma::explore_snapshot( + &snapshot, + &anchor, + &devup_mcp_figma::ExploreOptions { + limit: MATCH_CEILING, + }, + ) + { + error.details["exploreSuggestion"]["candidates"] = + json!(candidates.candidates); + error.details["exploreSuggestion"]["projectionTruncated"] = + json!(candidates.truncated); + screenshot_observations::rank( + &mut error.details["exploreSuggestion"], + observations.as_ref(), + ); + let found = error.details["exploreSuggestion"]["candidates"] + .as_array() + .map_or(0, Vec::len); + error.details["exploreSuggestion"]["foundCandidates"] = json!(found); + error.details["exploreSuggestion"]["candidatesTruncated"] = + json!(found > 10); + if let Some(candidates) = + error.details["exploreSuggestion"]["candidates"].as_array_mut() + { + candidates.truncate(10); + } + } + } + Err(error) + } } } } @@ -1028,6 +1127,9 @@ fn selection_refusal( "currentPage": file.context.current_page, "selection": file.context.selection, "selectionCount": file.context.selection_count, + "lastKnownSelection":file.context.last_known_selection, + "lastKnownPage":file.context.last_known_page, + "observedAt":file.context.observed_at, }, "nextAction": call, }), @@ -1241,6 +1343,17 @@ fn apply_explore_limit(mut response: Value, limit: usize) -> Value { #[tool_router] impl DevupServer { + #[tool(description = "Read only width, height, padding, gap, layoutMode and alignment for 1..100 explicit Figma nodeIds, including instance descendants. Uses one fixed node-list script: no tree walk, resources, assets, screenshot or code generation. Dimensions are Figma logical units for comparison with DOM CSS logical dimensions. Optional calibration uses caller-declared screen pixels and known node dimensions; zoom and plugin UI devicePixelRatio are diagnostic and never prove capture scale.", output_schema = permissive_object_output_schema())] + async fn devup_figma_measure( + &self, + Parameters(input): Parameters, + ) -> Result { + measurement::run(self, input) + .await + .map(tool_result) + .map_err(to_mcp_error) + } + #[tool( description = "Report which agent skills the code devup-mcp emits depends on and whether this workspace has them, then install the ones devup-mcp carries (action: status | install). \ The TSX devup_figma_export returns is devup-ui code, and an agent that has never seen devup-ui does not know its components are compile-time placeholders, that $token means devup.json, or that a style prop takes a responsive array - it guesses, and this server cannot see the guesses. \ @@ -1431,7 +1544,7 @@ impl DevupServer { } #[tool( - description = "Explore screen candidates spatially related to a linked Figma node to locate the right screen before devup_figma_export. \ + description = "Explore screen candidates spatially related to a linked Figma node to locate the right screen before devup_figma_export. Optional screenshotObservations (bounded labels/aspectRatio declared by the caller) rank real candidates using captured text and geometry; no screenshot pixels or visual matching are verified, and observations never auto-export a target. \ `limit` is the number of candidates returned and nothing else - it never changes how much of the design is read, so raising it can only lengthen the answer. \ `truncation` reports the two cuts apart: `candidates` means limit cut the list and raising it returns the rest, `projection` means the collected snapshot was itself incomplete and no limit will bring those screens back. \ `includeTextPreview` uses a separate budget; turning it on or off does not change candidate count, IDs, or `truncation.projection`. \ @@ -1442,11 +1555,14 @@ impl DevupServer { &self, Parameters(input): Parameters, ) -> Result { + if let Some(hints) = &input.screenshot_observations { + hints.validate().map_err(to_mcp_error)?; + } let target = self .bind_to_bridge( parse_link(input.url.as_deref()).map_err(to_mcp_error)?, Unnamed::Selection, - &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit } }), + &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit,"screenshotObservations":input.screenshot_observations } }), ) .await .map_err(to_mcp_error)?; @@ -1476,7 +1592,13 @@ impl DevupServer { // one anchor's projection can be reused for a neighbour's, since // every explore of a file now reads the same amount. projection_limit: EXPLORE_PROJECTION_LIMIT, - text_preview_limit: if input.include_text_preview { 160 } else { 0 }, + text_preview_limit: if input.include_text_preview + || input.screenshot_observations.is_some() + { + 160 + } else { + 0 + }, }); let result = self .start_operation( @@ -1493,11 +1615,13 @@ impl DevupServer { ) .await .map_err(to_mcp_error)?; + let mut result = result; + screenshot_observations::rank(&mut result, input.screenshot_observations.as_ref()); Ok(tool_result(apply_explore_limit(result, input.limit))) } #[tool( - description = "Export a small Figma selection; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. \ + description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. \ The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. \ This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. \ Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. \ @@ -1673,6 +1797,10 @@ impl DevupServer { } } validate_outputs(&input.outputs, input.debug).map_err(to_mcp_error)?; + input.response_selection.validate().map_err(to_mcp_error)?; + if let Some(hints) = &input.screenshot_observations { + hints.validate().map_err(to_mcp_error)?; + } design_drift::validate_request( &input.outputs, input.previous_design_fingerprints.as_deref(), @@ -1864,6 +1992,7 @@ impl DevupServer { asset_output_paths, asset_public_root: asset_public_root.clone(), fields: input.fields.clone(), + response_selection: input.response_selection.clone(), delivery, width_policy: width_policy.clone(), }, @@ -1912,6 +2041,7 @@ impl DevupServer { asset_output_paths, asset_public_root: asset_public_root.clone(), fields: input.fields.clone(), + response_selection: input.response_selection.clone(), delivery, width_policy: width_policy.clone(), }, @@ -1956,6 +2086,9 @@ impl DevupServer { }; let mut retry = json!({ "tool": "devup_figma_export", "arguments": { "outputs": input.outputs } }); + if let Some(hints) = &input.screenshot_observations { + retry["arguments"]["screenshotObservations"] = json!(hints); + } if !frame_ids.is_empty() { retry["arguments"]["frameIds"] = json!(frame_ids); } @@ -2015,6 +2148,7 @@ impl DevupServer { asset_output_paths, asset_public_root: asset_public_root.clone(), fields: input.fields.clone(), + response_selection: input.response_selection.clone(), delivery, width_policy: width_policy.clone(), }; @@ -2297,7 +2431,7 @@ impl DevupServer { #[tool( description = "Plan, then verify, a browser run of generated screens at several viewport widths that the consumer project executes (action: plan | ingest). This server never launches a browser or runs a command. \ - plan validates a declarative plan - baseUrl and allowedOrigins, viewports, screens by routePath or by steps that find elements by role and accessible name, testId or text, and geometry checks (no-horizontal-overflow, contained, aligned, spans, visible) - gives it a content id, and writes it under an allowed root with outputPath. Unknown fields, shell strings, other origins and URLs carrying credentials are refused. \ + Optional templatePath transactionally writes the fixed Playwright runner and package files alongside outputPath. Dimension checks bind explicit Figma artifactId/nodeId references to CSS logical DOM sizes at viewports (default 1440/1100/858/390); width tables disclose missing references and report tolerance exceedances only, never multiplying zoom or DPR. plan validates a declarative plan - baseUrl and allowedOrigins, viewports, screens by routePath or by steps that find elements by role and accessible name, testId or text, and geometry checks (no-horizontal-overflow, contained, aligned, spans, visible) - gives it a content id, and writes it under an allowed root with outputPath. Unknown fields, shell strings, other origins and URLs carrying credentials are refused. \ The consumer runs harness/runtime-audit against it in fresh isolated browser contexts. ingest reads the plan and the report.json it wrote, rejects a report not bound to that plan by id and file hash, recomputes every count from the items, verifies each screenshot's SHA-256, length and decoded width, and separates what it verified from what the executor only declared. A missing item is not-run, never a pass; a viewport with an environment error makes the verdict inconclusive. A visual original exists only at the widths a screen's Figma frame was drawn at.", output_schema = permissive_object_output_schema() )] @@ -2317,19 +2451,27 @@ impl DevupServer { let plan = input .plan .ok_or_else(|| invalid("action plan needs plan."))?; - runtime_audit::plan(plan, &self.output_policy, input.output_path.as_deref()) + runtime_audit::create_plan( + plan, + &self.output_policy, + input.output_path.as_deref(), + input.template_path.as_deref(), + &self.artifacts, + ) + .await } "ingest" => { let (Some(plan_path), Some(report_path)) = (input.plan_path, input.report_path) else { return Err(invalid("action ingest needs planPath and reportPath.")); }; - let policy = self.output_policy.clone(); - tokio::task::spawn_blocking(move || { - runtime_audit::ingest(&policy, &plan_path, &report_path) - }) + runtime_audit::ingest_with_artifacts( + &self.output_policy, + &plan_path, + &report_path, + &self.artifacts, + ) .await - .map_err(|_| invalid("The report could not be read to the end."))? } _ => return Err(invalid("action must be plan or ingest.")), } @@ -2902,6 +3044,10 @@ fn to_mcp_error(mut error: DevupError) -> ErrorData { error.details["nextAction"]["arguments"] = arguments; } } + if error.details["recoveryArguments"]["nodeIds"].is_array() { + error.details["nextAction"] = json!({"tool":"devup_figma_measure","arguments":error.details["recoveryArguments"], + "how":"Repeat the explicit measured node list; a measurement receipt never recovers by exporting the current selection."}); + } let mcp_code = if validation::is_caller_mistake(&error) { McpErrorCode::INVALID_PARAMS } else { @@ -3263,6 +3409,7 @@ mod section_retry_tests { asset_output_paths: Default::default(), asset_public_root: None, fields: vec![], + response_selection: Default::default(), delivery: DeliveryMode::Auto, width_policy: None, } diff --git a/crates/devup-mcp/src/server/operation.rs b/crates/devup-mcp/src/server/operation.rs index 8c1906c3..a45245b7 100644 --- a/crates/devup-mcp/src/server/operation.rs +++ b/crates/devup-mcp/src/server/operation.rs @@ -25,6 +25,7 @@ pub enum PendingOperation { artifact_key: ArtifactRequestKey, }, Export { + response_selection: super::compact::ResponseSelection, web_ready_images: bool, outputs: Vec, previous_design_fingerprints: Option, diff --git a/crates/devup-mcp/src/server/output.rs b/crates/devup-mcp/src/server/output.rs index 89d61541..f29a7a5c 100644 --- a/crates/devup-mcp/src/server/output.rs +++ b/crates/devup-mcp/src/server/output.rs @@ -681,11 +681,7 @@ fn unique_sibling(target: &Path, kind: &str) -> PathBuf { #[cfg(test)] mod tests { - use std::{ - fs, io, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, - }; + use std::{fs, io, path::PathBuf}; use super::{CommitHook, OutputPolicy, OutputTransaction}; @@ -718,13 +714,57 @@ mod tests { } fn unique_root() -> anyhow::Result { - let path = std::env::temp_dir().join(format!( - "devup-mcp-transaction-rollback-{}-{}", - std::process::id(), - SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() - )); - fs::create_dir_all(&path)?; - Ok(path) + // Clock precision can make concurrent fixtures share a timestamp. + // Reserve each root exclusively instead of reusing an existing directory. + for _ in 0..32 { + let path = std::env::temp_dir().join(format!( + "devup-mcp-transaction-rollback-{}-{:032x}", + std::process::id(), + rand::random::() + )); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "No exclusive fixture root available", + ) + .into()) + } + + #[test] + fn concurrent_fixture_roots_remain_distinct() -> anyhow::Result<()> { + let barrier = std::sync::Arc::new(std::sync::Barrier::new(16)); + let workers = (0..16) + .map(|_| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + (0..32) + .map(|_| { + barrier.wait(); + unique_root() + }) + .collect::>() + }) + }) + .collect::>(); + let mut roots = std::collections::BTreeSet::new(); + for worker in workers { + for root in worker.join().expect("fixture allocator worker panicked") { + assert!( + roots.insert(root?), + "concurrent fixtures reused a directory" + ); + } + } + assert_eq!(roots.len(), 512); + for root in roots { + fs::remove_dir(root)?; + } + Ok(()) } #[test] diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index c71af4cb..3335dbbb 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -271,6 +271,8 @@ pub(super) fn projected_outputs_from_result( ("assetCssMap", "asset-css-map.json"), ("webReadyImages", "web-ready-images.json"), ("pageScaffold", "page-scaffold.json"), + ("outline", "outline.json"), + ("selected", "selected.json"), ] { if let Some(value) = result.get(field) { if field == "imageFillAudit" && value["assets"].as_array().is_none_or(Vec::is_empty) { @@ -607,6 +609,9 @@ pub(super) async fn apply_delivery( .reserve_outputs(&artifact.artifact_id, &projection_key, outputs) .await?; let manifests = reservation.manifests().to_vec(); + if let Some(details) = manifests.iter().find(|m| m.name == "compact-details.json") { + result["selectionEvidence"]["uri"] = json!(details.manifest_uri); + } if let Some(old_uri) = result["metadataDelivery"]["uri"] .as_str() .map(str::to_owned) @@ -666,7 +671,15 @@ pub(super) async fn apply_delivery( "assetManifest", "pageScaffold", "referencePng", + "outline", ] { + if field == "outline" + && result + .get("deliverable") + .is_some_and(|d| d["kind"] == "outline-data") + { + continue; + } result.remove(field); } if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { @@ -679,7 +692,10 @@ pub(super) async fn apply_delivery( } } } - if let Some(deliverable) = result.get_mut("deliverable") { + if let Some(deliverable) = result + .get_mut("deliverable") + .filter(|d| d["kind"] != "selected-data" && d["kind"] != "outline-data") + { deliverable["note"] = json!(if deliverable["isFinal"] == true { "The final TSX deliverables are in resources. Read the linked resources to implement them." } else { @@ -1326,6 +1342,7 @@ fn reuse_summary(capabilities: &super::artifacts::ArtifactCapabilities) -> Value match capabilities.kind { ArtifactKind::Design => { with_artifact.extend([ + "outline", "tsx", "componentTsx", "responsiveTsx", @@ -1357,7 +1374,9 @@ fn reuse_summary(capabilities: &super::artifacts::ArtifactCapabilities) -> Value needs_url.push("design-outputs"); } ArtifactKind::SectionIndex => {} - ArtifactKind::Search | ArtifactKind::Explore => needs_url.push("export-outputs"), + ArtifactKind::Measurement | ArtifactKind::Search | ArtifactKind::Explore => { + needs_url.push("export-outputs") + } } let mut reuse = json!({ "withArtifactId": with_artifact, @@ -1699,6 +1718,7 @@ async fn project_operation( mut asset_output_paths, mut asset_public_root, fields, + response_selection, delivery, width_policy, } => { @@ -1738,6 +1758,15 @@ async fn project_operation( let asset_policies = asset_policy::policies(&payload.snapshot, &payload.assets, asset_names_per_node); let selected_fields = super::field_select::expand(&fields); + response_selection.validate()?; + let compact_roots = if frame_ids.is_empty() { + &payload.snapshot.roots + } else { + &frame_ids + }; + let node_summaries = (response_selection.active() + || outputs.iter().any(|o| o == "outline")) + .then(|| super::compact::node_summaries(&payload.snapshot, compact_roots)); page_scaffold::validate(page_scaffold.as_ref(), &outputs)?; super::validation::validate_width_policy_nodes( width_policy.as_ref(), @@ -1821,6 +1850,13 @@ async fn project_operation( if let Some(previous) = &previous_design_fingerprints { recovery_arguments["previousDesignFingerprints"] = json!(previous); } + for (key, value) in serde_json::to_value(&response_selection) + .unwrap() + .as_object() + .unwrap() + { + recovery_arguments[key] = value.clone(); + } if web_ready_images { recovery_arguments .as_object_mut() @@ -1845,6 +1881,10 @@ async fn project_operation( result.insert("completeness".to_owned(), json!(payload.completeness)); result.insert("collection".to_owned(), json!(collection)); result.insert("cache".to_owned(), artifact_metadata(artifact)); + result.insert( + "sectionChecklist".into(), + super::section_checklist::checklist(payload, compact_roots), + ); let mut failures = payload .failures .iter() @@ -2580,6 +2620,14 @@ async fn project_operation( } result.insert("responsiveTsx".to_owned(), json!(module)); result.insert("responsiveSlots".to_owned(), json!(merged.slots)); + result.insert( + "responsiveEvidence".into(), + responsive_groups::width_evidence(&responsive_snapshot), + ); + result.insert( + "responsiveReviewChecklist".into(), + responsive_groups::review(&responsive_snapshot), + ); responsive_widths = responsive_snapshot .roots .iter() @@ -3454,6 +3502,17 @@ async fn project_operation( )); } result = value.as_object().expect("result object").clone(); + if outputs.iter().any(|o| o == "outline") { + let outline = + super::compact::outline(node_summaries.as_ref().unwrap(), &response_selection); + if output_paths.contains_key("outline") { + pending_text_outputs.insert( + "outline".into(), + serde_json::to_string_pretty(&outline).unwrap(), + ); + } + result.insert("outline".into(), outline); + } for (name, contents) in &mut pending_text_outputs { if matches!( name.as_str(), @@ -3496,6 +3555,7 @@ async fn project_operation( "referencePng", "imageAudit", "assetCssMap", + "outline", ] { if result.contains_key(field) { supported_keys.push(field.to_owned()); @@ -3728,13 +3788,39 @@ async fn project_operation( for value in result.values_mut() { attach_diagnostic_screens(value, &owners); } + let outline_only = + !response_selection.active() && outputs.iter().all(|o| o == "outline"); + let compact_details = if let Some(summary) = &node_summaries { + let selected = if outline_only { + super::compact::outline_response(&mut result, summary) + } else { + super::compact::select(&mut result, summary, &response_selection) + }; + selected.map_err(|mut error| { + let mut recovery=recovery_arguments.clone(); + recovery.as_object_mut().unwrap().remove("path"); + recovery.as_object_mut().unwrap().remove("responseFields"); + if outputs.iter().all(|o|o=="outline") { recovery["responseLimit"]=json!(response_selection.response_limit.min(25)); } + error.details=json!({"artifactId":artifact.artifact_id,"maxSelectedBytes":65536, + "nextAction":{"tool":"devup_figma_export","arguments":recovery, + "how":"Read original outputs as bounded resources, or select a narrower path or lower responseLimit against this same artifact. Scalar values are never silently truncated."}}); + error + })? + } else { + None + }; let projected_outputs = projected_outputs_from_result(&result)?; let mut projected_outputs = projected_outputs; projected_outputs.extend(asset_resource_outputs); + projected_outputs.extend(compact_details); let mut result = Value::Object(result); let attachment = apply_delivery( &mut result, - delivery, + if response_selection.active() || outline_only { + DeliveryMode::Resource + } else { + delivery + }, artifact_store, artifact, projected_outputs, @@ -6756,6 +6842,7 @@ mod w1_regressions { asset_output_paths: BTreeMap::new(), asset_public_root: None, fields: vec![], + response_selection: Default::default(), delivery: DeliveryMode::Inline, width_policy: None, } diff --git a/crates/devup-mcp/src/server/projection/responsive_groups.rs b/crates/devup-mcp/src/server/projection/responsive_groups.rs index 208d4536..2a9a053a 100644 --- a/crates/devup-mcp/src/server/projection/responsive_groups.rs +++ b/crates/devup-mcp/src/server/projection/responsive_groups.rs @@ -12,12 +12,7 @@ pub fn discover(candidates: &[ExploreCandidate], artifact_id: &str) -> Vec Vec Value { + let found = devup_mcp_devup_ui::codegen::responsive::breakpoints(snapshot); + let mut items = devup_mcp_devup_ui::codegen::responsive::divergences(snapshot,&found).into_iter() + .map(|d|json!({"kind":"unmatched-structure","nodeId":d.node_id,"path":d.path,"reason":d.reason.as_str(),"state":"review-required"})).collect::>(); + let mut pending = snapshot + .roots + .iter() + .map(String::as_str) + .collect::>(); + let mut visited = std::collections::BTreeSet::new(); + while let Some(id) = pending.pop_front() { + if !visited.insert(id) || visited.len() > 100_000 { + continue; + } + let Some(node) = snapshot.nodes.get(id) else { + continue; + }; + pending.extend(node.typed_view().child_ids()); + let mut grouped = BTreeMap::<(String, String), Vec>::new(); + for id in node.typed_view().child_ids() { + if let Some(child) = snapshot.nodes.get(id) { + grouped + .entry(( + child.typed_view().name().unwrap_or("").into(), + child.node_type.clone(), + )) + .or_default() + .push(id.into()); + } + } + for ((name, node_type), ids) in grouped.into_iter().filter(|(_, ids)| ids.len() > 1) { + items.push(json!({"kind":"ambiguous-sibling-correspondence","parentId":node.id,"nodeIds":ids,"name":name,"nodeType":node_type, + "state":"review-required","reason":"Repeated sibling names and types cannot prove correspondence; existing occurrence-order merging is a candidate only."})); + } + } + json!({"items":items.iter().take(200).collect::>(),"total":items.len(),"truncated":items.len()>200, + "pairingEvidence":"explicit family names, node types and captured structure","implementationState":"unverified"}) +} + +pub fn width_evidence(snapshot: &devup_mcp_figma::Snapshot) -> Value { + let frames = snapshot + .roots + .iter() + .filter_map(|id| { + let n = snapshot.nodes.get(id)?; + let width = n.typed_view().number("width")?; + Some(json!({"nodeId":id,"width":width,"slot":width_slot(width)})) + }) + .collect::>(); + let slots=(0..5).map(|slot|json!({"slot":slot,"state":if frames.iter().any(|f|f["slot"]==slot) {"drawn-reference"} else {"undefined"}})).collect::>(); + json!({"frames":frames,"slots":slots,"intermediateWidths":"undefined unless explicitly drawn", + "arraySemantics":"null inherits the preceding CSS value; it does not verify an intermediate design or choose an interpolation rule.", + "fluidRecommendations":["Review equal columns with 1fr or minmax(0, 1fr)","Review flex wrapping and min-width constraints","Measure padding and card sizes at intermediate viewports"], + "evidenceLimit":"Recommendations are review options, not inferred designer intent or verified intermediate behavior."}) +} + pub fn from_roots(payload: &CollectedPayload, artifact_id: &str) -> Vec { let candidates = payload .snapshot @@ -124,3 +177,33 @@ pub fn completion(result: &mut Map, selected: &[String], all_scre "reportRequired":["implementedBreakpoints","remainingBreakpoints"], "guidance":"Implement and verify every exported width in the project, export remaining variants with responsiveTsx, and report both lists before calling the screen complete."})); } + +#[cfg(test)] +mod feedback_tests { + use super::*; + #[test] + fn responsive_ambiguity_and_missing_widths_are_reviewed_within_selected_roots() { + let snapshot:devup_mcp_figma::Snapshot=serde_json::from_value(json!({"fileKey":"Synthetic","roots":["1:1","1:2"],"nodes":{ + "1:1":{"id":"1:1","type":"FRAME","fields":{"name":"mobile","width":390,"childrenIds":["2:1","2:2"]}}, + "1:2":{"id":"1:2","type":"FRAME","fields":{"name":"desktop","width":1440,"childrenIds":["3:1"]}}, + "2:1":{"id":"2:1","type":"TEXT","fields":{"name":"Item"}},"2:2":{"id":"2:2","type":"TEXT","fields":{"name":"Item"}}, + "3:1":{"id":"3:1","type":"TEXT","fields":{"name":"Other"}}, + "9:1":{"id":"9:1","type":"FRAME","fields":{"childrenIds":["9:2","9:3"]}}, + "9:2":{"id":"9:2","type":"TEXT","fields":{"name":"unselected"}},"9:3":{"id":"9:3","type":"TEXT","fields":{"name":"unselected"}} + },"diagnostics":[]})).unwrap(); + let report = review(&snapshot); + assert!( + report["items"] + .as_array() + .unwrap() + .iter() + .any(|i| i["kind"] == "ambiguous-sibling-correspondence") + ); + assert!(!report.to_string().contains("unselected")); + assert_eq!(width_evidence(&snapshot)["slots"][2]["state"], "undefined"); + assert_eq!( + width_evidence(&snapshot)["slots"][4]["state"], + "drawn-reference" + ); + } +} diff --git a/crates/devup-mcp/src/server/review.rs b/crates/devup-mcp/src/server/review.rs index 0034445f..c5dbc3a5 100644 --- a/crates/devup-mcp/src/server/review.rs +++ b/crates/devup-mcp/src/server/review.rs @@ -71,6 +71,14 @@ fn has_line_height(value: &Value) -> bool { } fn collect(response: &Value, scope: &str, out: &mut Vec) { + for item in response["responsiveReviewChecklist"]["items"] + .as_array() + .into_iter() + .flatten() + { + out.push(json!({"severity":"required","scope":scope,"code":"RESPONSIVE_PAIRING_REVIEW","what":item, + "how":"Confirm node correspondence at the drawn widths; review undefined intermediate widths with flex, 1fr or minmax rules before claiming responsive completion."})); + } for (field, code, what, how) in [ ( "widgetCandidates", @@ -112,6 +120,11 @@ fn collect(response: &Value, scope: &str, out: &mut Vec) { /// Add `summary` and, only when something needs review, `reviewChecklist`. pub(super) fn attach(result: &mut Value) { let mut items = vec![]; + if result["selectionEvidence"]["requiresFullDetailRead"] == true { + items.push(json!({"severity":"required","scope":"response","code":"SELECTED_PROJECTION_DETAILS_RESOURCE", + "what":"Selection returns data only; full acquisition and projection gaps remain in a linked resource.", + "how":"Read selectionEvidence.uri before accepting completeness; displayed issue identities are a bounded preview."})); + } if result["metadataDelivery"]["requiresFullDetailRead"] == true { items.push(json!({"severity":"required","scope":"response","code":"PROJECTION_DETAILS_RESOURCE", "what":"Complete projection diagnostic details are delivered as a resource.", @@ -194,4 +207,17 @@ mod tests { "LINE_HEIGHT_CONVERSION" ); } + #[test] + fn selected_details_without_preview_findings_still_require_resource_review() { + let mut value = json!({"status":"partial","selectionEvidence":{"requiresFullDetailRead":true,"uri":"devup://artifact/synthetic/outputs/evidence/manifest"},"projectionIssues":[]}); + attach(&mut value); + assert_eq!(value["summary"]["required"], 1); + assert!( + value["reviewChecklist"] + .as_array() + .unwrap() + .iter() + .any(|item| item["code"] == "SELECTED_PROJECTION_DETAILS_RESOURCE") + ); + } } diff --git a/crates/devup-mcp/src/server/runtime_audit.rs b/crates/devup-mcp/src/server/runtime_audit.rs index 3a72f5f1..73f2507f 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; +use super::artifacts::ArtifactStore; use super::output::{OutputPolicy, OutputTransaction}; pub(crate) const PLAN_KIND: &str = "devup-runtime-audit-plan"; @@ -37,6 +38,8 @@ const MAX_REPORT_BYTES: usize = 8 * 1024 * 1024; const MAX_SCREENSHOT_BYTES: usize = 16 * 1024 * 1024; const MAX_SCREENSHOTS: usize = 400; const MAX_LISTED: usize = 50; +// Keep complete per-width rows in the response without unbounded duplication. +const MAX_DIMENSION_ROWS: usize = 100; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -52,6 +55,10 @@ pub struct RuntimeAuditInput { /// plan: where to write the plan JSON, under an allowed write root. #[serde(default)] pub output_path: Option, + /// plan: emit the consumer Playwright runner and its pinned npm manifest + /// into this directory under an allowed root; requires outputPath. + #[serde(default)] + pub template_path: Option, /// ingest: the plan JSON the executor ran. #[serde(default)] pub plan_path: Option, @@ -70,6 +77,8 @@ pub struct PlanInput { /// Origins the run may load from; every other request is blocked and /// recorded. Must include the baseUrl origin. pub allowed_origins: Vec, + /// Defaults to 1440, 1100, 858 and 390 CSS-pixel widths at height 900. + #[serde(default = "default_viewports")] pub viewports: Vec, /// Visited in order in one isolated browser context per viewport. pub screens: Vec, @@ -105,6 +114,19 @@ fn default_scale() -> f64 { 1.0 } +fn default_viewports() -> Vec { + [1440, 1100, 858, 390] + .into_iter() + .map(|width| ViewportInput { + width, + height: 900, + device_scale_factor: 1.0, + mobile: false, + touch: false, + }) + .collect() +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(extend("additionalProperties" = json!({"not": {}})))] @@ -185,7 +207,9 @@ pub struct CheckInput { /// shares its centre (center). /// spans: subject spans container's width less insetPx on each side. /// visible: subject renders with a non-empty box. - #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible"]))] + /// dimensions: compare the explicitly mapped element box in CSS pixels + /// with acquired Figma logical dimensions at each named viewport width. + #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions"]))] pub kind: String, #[serde(default)] pub subject: Option, @@ -208,6 +232,19 @@ pub struct CheckInput { pub measure: Option, #[serde(default)] pub label: Option, + /// Explicit mappings only. Widths omitted here have no design reference; + /// intermediate widths are never interpolated or inferred from screenshots. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub figma_dimensions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct DimensionReferenceInput { + pub viewport_width: u32, + pub artifact_id: String, + pub node_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -353,11 +390,38 @@ pub(crate) fn validate_plan(plan: &PlanInput) -> Result<(), DevupError> { ))); } } + let mut viewports = std::collections::BTreeSet::new(); + if plan + .viewports + .iter() + .any(|v| !viewports.insert((v.width, v.height, v.device_scale_factor.to_bits()))) + { + return Err(invalid( + "Duplicate viewports would make report attribution ambiguous.", + )); + } if plan.screens.is_empty() || plan.screens.len() > MAX_SCREENS { return Err(invalid(format!( "screens must hold 1 to {MAX_SCREENS} entries." ))); } + let dimension_rows = plan.viewports.len() + * plan + .screens + .iter() + .map(|screen| { + screen + .checks + .iter() + .filter(|check| check.kind == "dimensions") + .count() + }) + .sum::(); + if dimension_rows > MAX_DIMENSION_ROWS { + return Err(invalid(format!( + "A plan may contain at most {MAX_DIMENSION_ROWS} dimension rows (dimension checks x viewports); split the plan to keep every width row explicit." + ))); + } if plan.viewports.len() * plan.screens.len() > MAX_SCREENSHOTS { return Err(invalid(format!( "viewports x screens must be at most {MAX_SCREENSHOTS}, the screenshots ingest verifies for one report; split the plan." @@ -416,6 +480,15 @@ pub(crate) fn validate_plan(plan: &PlanInput) -> Result<(), DevupError> { } for check in &screen.checks { validate_check(check)?; + if check + .figma_dimensions + .iter() + .any(|r| !plan.viewports.iter().any(|v| v.width == r.viewport_width)) + { + return Err(invalid( + "figmaDimensions.viewportWidth must name a planned viewport width.", + )); + } } if let Some(figma) = &screen.figma { bounded_text("figma.nodeId", &figma.node_id)?; @@ -451,6 +524,17 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { match check.kind.as_str() { "no-horizontal-overflow" => needs(false, false)?, "visible" => needs(true, false)?, + "dimensions" => { + needs(true, false)?; + if check.tolerance_px.is_none() + || check.figma_dimensions.is_empty() + || !matches!(check.measure.as_deref(), None | Some("box")) + { + return Err(invalid( + "A dimensions check needs tolerancePx, figmaDimensions and measure box.", + )); + } + } "contained" | "spans" => needs(true, true)?, "aligned" => { needs(true, true)?; @@ -462,6 +546,21 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { } other => return Err(invalid(format!("Unknown check kind {other}."))), } + if check.kind != "dimensions" && !check.figma_dimensions.is_empty() { + return Err(invalid( + "figmaDimensions is only valid for dimensions checks.", + )); + } + let mut widths = std::collections::BTreeSet::new(); + for reference in &check.figma_dimensions { + bounded_text("figmaDimensions.artifactId", &reference.artifact_id)?; + bounded_text("figmaDimensions.nodeId", &reference.node_id)?; + if !widths.insert(reference.viewport_width) { + return Err(invalid( + "A dimensions check needs at most one explicit reference per width.", + )); + } + } for (field, locator) in [("subject", &check.subject), ("container", &check.container)] { if let Some(locator) = locator { validate_locator(&format!("checks[].{field}"), locator, true)?; @@ -486,6 +585,9 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { )); } } + if check.tolerance_px.is_some_and(|value| value < 0.0) { + return Err(invalid("tolerancePx must be nonnegative.")); + } if let Some(label) = &check.label { bounded_text("checks[].label", label)?; } @@ -500,12 +602,98 @@ fn plan_id(plan: &PlanInput) -> Result { Ok(sha256(&bytes)) } -pub(crate) fn plan( +#[cfg(test)] +fn plan( input: PlanInput, policy: &OutputPolicy, output_path: Option<&str>, +) -> Result { + write_plan(input, policy, output_path, None, Vec::new()) +} + +/// Resolve design dimensions from retained acquisitions, never caller numbers. +/// Missing/expired artifacts remain explicit so they cannot become a passing check. +async fn dimension_references(input: &PlanInput, artifacts: &ArtifactStore) -> Vec { + let mut references = Vec::new(); + for screen in &input.screens { + for (index, check) in screen.checks.iter().enumerate() { + for reference in &check.figma_dimensions { + let mut value = json!({"screen": screen.id, "check": index, + "viewportWidth": reference.viewport_width, "artifactId": reference.artifact_id, + "nodeId": reference.node_id, "status": "unverified", + "reason": "The retained acquisition is missing or expired; acquire the node again.", + "mapping": "caller-declared", "units": "figma-logical-px"}); + if let Some(artifact) = artifacts.get(&reference.artifact_id).await { + value["artifactContentHash"] = json!(artifact.content_hash); + value["staleness"] = json!("retained snapshot; not compared with live Figma"); + value["reason"] = + json!("The acquired node has no verified logical width and height."); + if let Some(node) = artifact.payload.snapshot.nodes.get(&reference.node_id) { + let view = node.typed_view(); + let width = (!node.field_errors.contains_key("width")) + .then(|| view.number("width")) + .flatten() + .or_else(|| { + (!node.field_errors.contains_key("size")) + .then(|| view.value("size")?.get("x")?.as_f64()) + .flatten() + }); + let height = (!node.field_errors.contains_key("height")) + .then(|| view.number("height")) + .flatten() + .or_else(|| { + (!node.field_errors.contains_key("size")) + .then(|| view.value("size")?.get("y")?.as_f64()) + .flatten() + }); + if let (Some(width), Some(height)) = (width, height) + && width.is_finite() + && height.is_finite() + && width >= 0.0 + && height >= 0.0 + { + value["status"] = json!("acquired"); + value["width"] = json!(width); + value["height"] = json!(height); + value["reason"] = Value::Null; + value["source"] = json!( + "node logical width/height or size; no screenshot, zoom, DPR or bounding-box inference" + ); + } + } + } + references.push(value); + } + } + } + references +} + +pub(crate) async fn create_plan( + input: PlanInput, + policy: &OutputPolicy, + output_path: Option<&str>, + template_path: Option<&str>, + artifacts: &ArtifactStore, ) -> Result { validate_plan(&input)?; + let references = dimension_references(&input, artifacts).await; + write_plan(input, policy, output_path, template_path, references) +} + +fn write_plan( + input: PlanInput, + policy: &OutputPolicy, + output_path: Option<&str>, + template_path: Option<&str>, + dimension_references: Vec, +) -> Result { + validate_plan(&input)?; + if template_path.is_some() && output_path.is_none() { + return Err(invalid( + "templatePath requires outputPath for the bound plan JSON.", + )); + } let id = plan_id(&input)?; let checks = input.screens.iter().map(|s| s.checks.len()).sum::(); let references = input @@ -533,8 +721,11 @@ pub(crate) fn plan( "runs": input.viewports.len(), "screensPerRun": input.screens.len(), "checkItems": checks * input.viewports.len(), + "dimensionRows": input.viewports.len() * input.screens.iter().map(|s| s.checks.iter().filter(|c| c.kind == "dimensions").count()).sum::(), }, + "limits": {"dimensionRows": MAX_DIMENSION_ROWS, "dimensionRowsOmitted": 0}, "figmaReferences": references, + "dimensionReferences": dimension_references, "executor": { "package": "harness/runtime-audit", "install": "npm ci && npx playwright install chromium", @@ -543,18 +734,55 @@ pub(crate) fn plan( }, }); let mut written = None; + let mut template_files = Vec::new(); + let mut transaction = OutputTransaction::new(); + if let Some(directory) = template_path { + transaction.refuse_existing(); + for (name, bytes) in [ + ( + "run.mjs", + include_bytes!("../../../../harness/runtime-audit/run.mjs").as_slice(), + ), + ( + "checks.mjs", + include_bytes!("../../../../harness/runtime-audit/checks.mjs").as_slice(), + ), + ( + "package.json", + include_bytes!("../../../../harness/runtime-audit/package.json").as_slice(), + ), + ( + "package-lock.json", + include_bytes!("../../../../harness/runtime-audit/package-lock.json").as_slice(), + ), + ] { + let requested = Path::new(directory).join(name); + let target = policy.resolve(&requested.to_string_lossy())?; + template_files.push(json!({"path": target.display_path().display().to_string(), "sha256": sha256(bytes)})); + transaction.stage(name, target, bytes)?; + } + } if let Some(path) = output_path { let target = policy.resolve(path)?; let display = target.display_path().display().to_string(); - let mut transaction = OutputTransaction::new(); let bytes = serde_json::to_vec_pretty(&document) .map_err(|error| invalid(format!("Cannot serialize the plan: {error}")))?; + if bytes.len() > MAX_PLAN_BYTES { + return Err(invalid( + "The bound plan exceeds the ingest size limit; split the plan.", + )); + } transaction.stage("plan", target, &bytes)?; - transaction.commit()?; written = Some(display); } + transaction.commit()?; let mut result = document; result["writtenTo"] = json!(written); + result["template"] = json!({"files": template_files, "executed": false, + "workingDirectory": template_path, + "commands": [ ["npm", "ci"], ["npx", "playwright", "install", "chromium"], + ["node", "run.mjs", "--plan", written.as_deref().unwrap_or(""), "--out", "report"] ], + "note": "Execute these argument arrays in the generated directory in your consumer project with its app running. The server executes nothing. The plan and report must remain under allowed roots for ingest."}); result["nextAction"] = json!({ "where": "the consumer project", "how": "Run the executor against this plan with the app served at baseUrl, then call devup_runtime_audit with action ingest, the plan path and the report.json it writes. Nothing here has run yet.", @@ -583,10 +811,43 @@ fn rejected(reason: &str, details: Value) -> Value { }) } -pub(crate) fn ingest( +#[cfg(test)] +fn ingest(policy: &OutputPolicy, plan_path: &str, report_path: &str) -> Result { + ingest_references(policy, plan_path, report_path, None) +} + +pub(crate) async fn ingest_with_artifacts( policy: &OutputPolicy, plan_path: &str, report_path: &str, + artifacts: &ArtifactStore, +) -> Result { + let read_policy = policy.clone(); + let read_path = plan_path.to_owned(); + let (_, document, _) = + tokio::task::spawn_blocking(move || read_json(&read_policy, &read_path, MAX_PLAN_BYTES)) + .await + .map_err(|_| invalid("The plan could not be read to the end."))??; + let references = match serde_json::from_value::(document["plan"].clone()) { + Ok(plan) if validate_plan(&plan).is_ok() => dimension_references(&plan, artifacts).await, + Ok(_) => Vec::new(), + Err(_) => Vec::new(), + }; + let policy = policy.clone(); + let plan_path = plan_path.to_owned(); + let report_path = report_path.to_owned(); + tokio::task::spawn_blocking(move || { + ingest_references(&policy, &plan_path, &report_path, Some(&references)) + }) + .await + .map_err(|_| invalid("The report could not be read to the end."))? +} + +fn ingest_references( + policy: &OutputPolicy, + plan_path: &str, + report_path: &str, + acquired: Option<&[Value]>, ) -> Result { let (plan_bytes, plan_document, _) = read_json(policy, plan_path, MAX_PLAN_BYTES)?; if plan_document["kind"] != PLAN_KIND || plan_document["schemaVersion"] != SCHEMA_VERSION { @@ -643,7 +904,25 @@ pub(crate) fn ingest( let mut integrity_problems = Vec::new(); let mut environment_invalid = false; let mut visual_comparisons = Vec::new(); + let mut dimension_rows = Vec::new(); for viewport in &plan.viewports { + let matching_runs = report["runs"] + .as_array() + .into_iter() + .flatten() + .filter(|run| { + run["viewport"]["width"] == viewport.width + && run["viewport"]["height"] == viewport.height + && run["viewport"]["deviceScaleFactor"].as_f64() + == Some(viewport.device_scale_factor) + }) + .count(); + if matching_runs > 1 { + return Ok(rejected( + "ambiguous-report-items", + json!({"viewport": viewport}), + )); + } let run = report["runs"].as_array().into_iter().flatten().find(|run| { run["viewport"]["width"] == viewport.width && run["viewport"]["height"] == viewport.height @@ -666,6 +945,20 @@ pub(crate) fn ingest( let mut screenshot_issues = Vec::new(); let mut drawn_width_shots = Vec::new(); for screen in &plan.screens { + if run.is_some_and(|run| { + run["screens"] + .as_array() + .into_iter() + .flatten() + .filter(|s| s["id"] == screen.id) + .count() + > 1 + }) { + return Ok(rejected( + "ambiguous-report-items", + json!({"screen": screen.id}), + )); + } let reported = run.and_then(|run| { run["screens"] .as_array()? @@ -673,14 +966,52 @@ pub(crate) fn ingest( .find(|candidate| candidate["id"] == screen.id.as_str()) }); for (index, check) in screen.checks.iter().enumerate() { + if reported.is_some_and(|s| { + s["checks"] + .as_array() + .into_iter() + .flatten() + .filter(|item| item["index"] == index) + .count() + > 1 + }) { + return Ok(rejected( + "ambiguous-report-items", + json!({"screen": screen.id, "check": index}), + )); + } let item = reported.and_then(|screen| { screen["checks"] .as_array()? .iter() .find(|item| item["index"] == index) }); - let status = match item.and_then(|item| item["status"].as_str()) { - Some(status @ ("pass" | "fail" | "inconclusive" | "skipped")) => status, + let dimension = (check.kind == "dimensions").then(|| { + dimension_row( + screen, + index, + check, + viewport, + item, + &plan_document, + acquired, + plan_file_matches == Some(true), + run.is_some() + && environment_errors.is_empty() + && reported.is_some_and(|s| s["status"] == "completed"), + ) + }); + let status = match dimension + .as_ref() + .map(|row| &row["status"]) + .or_else(|| item.map(|item| &item["status"])) + .and_then(Value::as_str) + { + Some("pass") => "pass", + Some("fail") => "fail", + Some("inconclusive") => "inconclusive", + Some("skipped") => "skipped", + Some("not-run") => "not-run", Some(_) => "inconclusive", None => "not-run", }; @@ -692,15 +1023,21 @@ pub(crate) fn ingest( "check": index, "kind": check.kind, "label": check.label, - "reason": item.map(|item| &item["reason"]), - "measured": item.map(|item| &item["measured"]), + "reason": dimension.as_ref().map(|row| &row["reason"]).or_else(|| item.map(|item| &item["reason"])), + "measured": dimension.as_ref().map(|row| &row["domCssPx"]).or_else(|| item.map(|item| &item["measured"])), })); } + if let Some(row) = dimension { + dimension_rows.push(row); + } } let Some(shot) = reported .map(|screen| &screen["screenshot"]) .filter(|shot| shot.is_object()) else { + screenshot_issues.push( + json!({"screen": screen.id, "issue": "not-verified: screenshot missing"}), + ); continue; }; if screenshot_budget == 0 { @@ -797,6 +1134,14 @@ pub(crate) fn ingest( "runs": runs, "figmaReferences": figma, "visualComparisons": visual_comparisons, + "widthTable": plan.viewports.iter().map(|viewport| json!({ + "width": viewport.width, "height": viewport.height, + "deviceScaleFactor": viewport.device_scale_factor, + "dimensions": dimension_rows.iter().filter(|row| row["viewport"] == json!(viewport)).collect::>() + })).collect::>(), + "toleranceExceedances": dimension_rows.iter().filter(|row| row["status"] == "fail").collect::>(), + "dimensionCoverage": {"plannedRows": dimension_rows.len(), "returnedRows": dimension_rows.len(), + "omittedRows": 0, "limit": MAX_DIMENSION_ROWS}, "executor": report["executor"], "environment": report["environment"], "evidence": { @@ -805,9 +1150,10 @@ pub(crate) fn ingest( "the report's binding to that plan", "every count above, recomputed from the reported items", "each screenshot's SHA-256, byte length, PNG decoding and width", + "dimension arithmetic and per-check tolerance filtering from reported CSS geometry; available retained Figma reference dimensions are rechecked", ], "executorDeclared": [ - "element geometry and each check's outcome", + "element geometry, locator counts, visibility and non-dimension check outcomes", "page, console and network errors", "timing, browser and environment versions", ], @@ -816,6 +1162,112 @@ pub(crate) fn ingest( })) } +#[allow(clippy::too_many_arguments)] +fn dimension_row( + screen: &ScreenInput, + index: usize, + check: &CheckInput, + viewport: &ViewportInput, + item: Option<&Value>, + document: &Value, + acquired: Option<&[Value]>, + hash_bound: bool, + environment_valid: bool, +) -> Value { + let matches = |reference: &&Value| { + reference["screen"] == screen.id + && reference["check"] == index + && reference["viewportWidth"] == viewport.width + }; + let reference = document["dimensionReferences"] + .as_array() + .into_iter() + .flatten() + .find(matches); + let verified = acquired + .and_then(|refs| refs.iter().find(matches)) + .filter(|fresh| { + fresh["status"] == "acquired" && reference.is_some_and(|saved| *fresh == saved) + }); + let measured = item.map(|item| &item["measured"]); + // Report files are untrusted: expose only bounded identity and numeric + // geometry, rather than echoing arbitrary objects attached to a check. + let mapping = check + .figma_dimensions + .iter() + .find(|r| r.viewport_width == viewport.width); + let reference_summary = mapping.map(|mapping| { + json!({ + "artifactId": mapping.artifact_id, "nodeId": mapping.node_id, + "status": if verified.is_some() { "acquired" } else { "unverified" }, + "width": reference.and_then(|r| r["width"].as_f64()), + "height": reference.and_then(|r| r["height"].as_f64()), + "units": "figma-logical-px", + "artifactContentHash": verified.and_then(|r| r["artifactContentHash"].as_str()), + "staleness": "retained snapshot; not compared with live Figma", + }) + }); + let dom = measured.map(|m| { + json!({"x": m["subject"]["x"].as_f64(), "y": m["subject"]["y"].as_f64(), + "width": m["subject"]["width"].as_f64(), "height": m["subject"]["height"].as_f64()}) + }); + let mut row = json!({"screen": screen.id, "check": index, "label": check.label, + "viewport": viewport, "subject": check.subject, "mapping": "caller-declared", + "reference": reference_summary, "referenceVerifiedAgainstRetainedAcquisition": verified.is_some(), + "domCssPx": dom, "domEvidence": "executor-declared", + "tolerancePx": check.tolerance_px, "deltaPx": null, "status": "inconclusive", + "reason": "Design reference or reported CSS geometry is unverified."}); + let declared_status = item.and_then(|item| item["status"].as_str()); + if declared_status.is_none() { + row["status"] = json!("not-run"); + row["reason"] = json!("No measurement was reported for this planned check."); + } else if declared_status == Some("skipped") { + row["status"] = json!("skipped"); + row["reason"] = json!("The executor skipped this measurement."); + } else if reference.is_none() { + row["reason"] = json!( + "No acquired Figma dimensions were explicitly mapped at this width; no interpolation was applied." + ); + } else if verified.is_none() { + row["reason"] = json!( + "The saved reference could not be matched to the retained acquisition; acquire and plan again." + ); + } else if !hash_bound || !environment_valid { + row["reason"] = + json!("The plan file hash, screen completion or runtime environment is unverified."); + } else if matches!(declared_status, Some("pass" | "fail")) { + let m = measured.unwrap(); + if m["units"] == "css-px" + && m["subjectCount"] == 1 + && m["subjectVisible"] == true + && let (Some(width), Some(height)) = ( + m["subject"]["width"].as_f64(), + m["subject"]["height"].as_f64(), + ) + && width.is_finite() + && height.is_finite() + && width >= 0.0 + && height >= 0.0 + { + let expected = verified.unwrap(); + let dw = width - expected["width"].as_f64().unwrap(); + let dh = height - expected["height"].as_f64().unwrap(); + let tolerance = check.tolerance_px.unwrap(); + row["deltaPx"] = json!({"width": dw, "height": dh}); + row["status"] = json!(if dw.abs() <= tolerance && dh.abs() <= tolerance { + "pass" + } else { + "fail" + }); + row["reason"] = json!( + "Compared acquired logical dimensions with declared DOM CSS pixels without DPR or screenshot scaling." + ); + row["executorOutcomeMatches"] = json!(row["status"] == item.unwrap()["status"]); + } + } + row +} + enum Integrity { Tampered(String), Unverified(String), @@ -973,6 +1425,256 @@ fn visual_comparison( mod tests { use super::*; + async fn dimension_fixture() -> (ArtifactStore, String) { + use devup_mcp_figma::{CollectedPayload, CollectionRequest, CollectionScope, FigmaTarget}; + let store = ArtifactStore::default(); + let target = FigmaTarget { + file_key: "synthetic".into(), + node_id: Some("target".into()), + branch_key: None, + }; + let request = CollectionRequest::new(target.clone(), CollectionScope::Node); + let payload: CollectedPayload = serde_json::from_value(json!({ + "target": target, "scope": "node", "metadata": {}, + "snapshot": {"fileKey": "synthetic", "version": "1", "roots": ["target"], + "nodes": {"target": {"id": "target", "type": "FRAME", "fields": {"width": 100, "height": 80}}, + "bounds-only": {"id": "bounds-only", "type": "FRAME", "fields": {"absoluteBoundingBox": {"width": 100, "height": 80}}}, + "errored": {"id": "errored", "type": "FRAME", "fields": {"width": 100, "height": 80}, "fieldErrors": {"width": "unavailable"}}}, "diagnostics": []}, + "variables": null, "styles": null, "completeness": "resolved-values-only", "sourceVersion": "1" + })).unwrap(); + let artifact = store + .get_or_acquire( + super::super::artifacts::ArtifactRequestKey::from_collection(&request), + false, + || async { Ok(payload) }, + ) + .await + .unwrap(); + (store, artifact.artifact_id) + } + + fn dimensions_plan(artifact: &str) -> PlanInput { + let widths = [1440, 1100, 858, 390]; + let mappings = widths + .iter() + .map( + |width| json!({"viewportWidth": width, "artifactId": artifact, "nodeId": "target"}), + ) + .collect::>(); + serde_json::from_value(json!({ + "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], + "viewports": widths.iter().map(|width| json!({"width": width, "height": 100, "deviceScaleFactor": 2})).collect::>(), + "screens": [{"id": "dimensions", "routePath": "/", "checks": [ + {"kind": "dimensions", "subject": {"testId": "target"}, "tolerancePx": 0.5, "figmaDimensions": mappings}, + {"kind": "dimensions", "subject": {"testId": "target"}, "tolerancePx": 0.49, "figmaDimensions": mappings} + ]}] + })).unwrap() + } + + #[tokio::test] + async fn acquired_dimensions_are_rechecked_and_tolerances_recomputed_at_all_widths() { + let (dir, policy) = root(); + let (store, artifact) = dimension_fixture().await; + let planned = create_plan( + dimensions_plan(&artifact), + &policy, + Some("plan.json"), + Some("runner"), + &store, + ) + .await + .unwrap(); + assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 4); + assert_eq!(planned["template"]["executed"], false); + assert_eq!( + std::fs::read(dir.join("runner/run.mjs")).unwrap(), + include_bytes!("../../../../harness/runtime-audit/run.mjs") + ); + let plan_bytes = std::fs::read(dir.join("plan.json")).unwrap(); + let mut report = json!({"schemaVersion": 1, "kind": REPORT_KIND, "planId": planned["planId"], + "planSha256": sha256(&plan_bytes), "status": "completed", "runs": []}); + for width in [1440_u32, 1100, 858, 390] { + let bytes = png(width * 2, 200); + let name = format!("{width}.png"); + std::fs::write(dir.join(&name), &bytes).unwrap(); + report["runs"].as_array_mut().unwrap().push(json!({ + "viewport": {"width": width, "height": 100, "deviceScaleFactor": 2}, "environmentErrors": [], + "screens": [{"id": "dimensions", "status": "completed", + "checks": (0..2).map(|index| json!({"index": index, "status": "pass", "measured": { + "units": "css-px", "subjectCount": 1, "subjectVisible": true, + "subject": {"x": 0, "y": 0, "width": 100.5, "height": 80, "extra": "untrusted-subject-extension"}}})).collect::>(), + "screenshot": {"path": name, "sha256": sha256(&bytes), "bytes": bytes.len()}}] + })); + } + let write = |value: &Value| { + std::fs::write(dir.join("report.json"), serde_json::to_vec(value).unwrap()).unwrap() + }; + write(&report); + let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap(); + assert_eq!(result["verdict"], "fail", "{result}"); + assert_eq!(result["summary"]["pass"], 4); + assert_eq!(result["summary"]["fail"], 4); + assert!(!result.to_string().contains("untrusted-subject-extension")); + assert_eq!(result["toleranceExceedances"].as_array().unwrap().len(), 4); + for row in result["widthTable"].as_array().unwrap() { + assert_eq!(row["dimensions"][0]["deltaPx"]["width"], 0.5); + assert_eq!( + row["dimensions"][0]["referenceVerifiedAgainstRetainedAcquisition"], + true + ); + assert_eq!(row["dimensions"][1]["executorOutcomeMatches"], false); + } + + let mut ambiguous = report.clone(); + let repeated = ambiguous["runs"][0]["screens"][0]["checks"][0].clone(); + ambiguous["runs"][0]["screens"][0]["checks"] + .as_array_mut() + .unwrap() + .push(repeated); + write(&ambiguous); + assert_eq!( + ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap()["reason"], + "ambiguous-report-items" + ); + + let mut missing = report.clone(); + missing["runs"].as_array_mut().unwrap().pop(); + missing["runs"][0]["screens"][0]["checks"][0]["status"] = json!("skipped"); + write(&missing); + let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap(); + assert_eq!(result["verdict"], "inconclusive"); + assert_eq!(result["summary"]["notRun"], 2); + assert_eq!(result["summary"]["skipped"], 1); + assert_eq!( + result["widthTable"][3]["dimensions"][0]["status"], + "not-run" + ); + + write(&report); + let empty_store = ArtifactStore::default(); + let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &empty_store) + .await + .unwrap(); + assert_eq!(result["summary"]["inconclusive"], 8); + assert_eq!(result["toleranceExceedances"], json!([])); + assert_eq!( + result["widthTable"][0]["dimensions"][0]["referenceVerifiedAgainstRetainedAcquisition"], + false + ); + + for (field, value) in [ + ("width", json!(100.5)), + ("height", json!(80.5)), + ("status", json!("caller-asserted")), + ("artifactContentHash", json!("forged")), + ] { + let mut forged = planned.clone(); + forged["dimensionReferences"][0][field] = value; + let forged_bytes = serde_json::to_vec(&forged).unwrap(); + std::fs::write(dir.join("plan.json"), &forged_bytes).unwrap(); + let mut rebound = report.clone(); + rebound["planSha256"] = json!(sha256(&forged_bytes)); + write(&rebound); + let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap(); + assert_eq!( + result["widthTable"][0]["dimensions"][0]["status"], "inconclusive", + "{field}" + ); + } + + std::fs::write(dir.join("plan.json"), &plan_bytes).unwrap(); + write(&report); + std::fs::write(dir.join("1440.png"), png(2880, 201)).unwrap(); + assert_eq!( + ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap()["reason"], + "artifact-changed" + ); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[tokio::test] + async fn dimension_references_and_template_outputs_refuse_unverified_or_unsafe_inputs() { + let (dir, policy) = root(); + let (store, artifact) = dimension_fixture().await; + let input = dimensions_plan(&artifact); + let mut oversized = input.clone(); + oversized.screens[0].checks = + vec![input.screens[0].checks[0].clone(); MAX_DIMENSION_ROWS / 4 + 1]; + assert!(validate_plan(&oversized).is_err()); + let mut defaults = serde_json::to_value(&input).unwrap(); + defaults.as_object_mut().unwrap().remove("viewports"); + assert_eq!( + serde_json::from_value::(defaults) + .unwrap() + .viewports + .iter() + .map(|v| v.width) + .collect::>(), + vec![1440, 1100, 858, 390] + ); + for node_id in ["bounds-only", "errored", "absent"] { + let mut missing = input.clone(); + missing.screens[0].checks[0].figma_dimensions[0].node_id = node_id.into(); + let value = create_plan(missing, &policy, None, None, &store) + .await + .unwrap(); + assert_eq!( + value["dimensionReferences"][0]["status"], "unverified", + "{node_id}" + ); + } + for tolerance in [-0.1, 10000.1] { + let mut invalid = input.clone(); + invalid.screens[0].checks[0].tolerance_px = Some(tolerance); + assert!(validate_plan(&invalid).is_err()); + } + let mut duplicate = input.clone(); + let mapping = duplicate.screens[0].checks[0].figma_dimensions[0].clone(); + duplicate.screens[0].checks[0] + .figma_dimensions + .push(mapping); + assert!(validate_plan(&duplicate).is_err()); + for (output, template) in [ + (Some("safe.json"), Some("../escape")), + (Some("../escape.json"), Some("safe")), + (None, Some("safe")), + ] { + assert!( + create_plan(input.clone(), &policy, output, template, &store) + .await + .is_err() + ); + assert!(!dir.join("safe.json").exists()); + assert!(!dir.join("safe/run.mjs").exists()); + } + std::fs::create_dir_all(dir.join("existing")).unwrap(); + std::fs::write(dir.join("existing/checks.mjs"), b"keep").unwrap(); + assert!( + create_plan(input, &policy, Some("safe.json"), Some("existing"), &store) + .await + .is_err() + ); + assert_eq!( + std::fs::read(dir.join("existing/checks.mjs")).unwrap(), + b"keep" + ); + assert!(!dir.join("safe.json").exists()); + assert!(!dir.join("existing/run.mjs").exists()); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + fn plan_input() -> PlanInput { serde_json::from_value(json!({ "baseUrl": "http://127.0.0.1:4173", diff --git a/crates/devup-mcp/src/server/screenshot_observations.rs b/crates/devup-mcp/src/server/screenshot_observations.rs new file mode 100644 index 00000000..47c7c93c --- /dev/null +++ b/crates/devup-mcp/src/server/screenshot_observations.rs @@ -0,0 +1,127 @@ +//! Caller observations rank actual Figma candidates; this is not vision. +use devup_mcp_figma::{DevupError, ErrorCode}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties"=serde_json::json!({"not":{}})))] +pub struct ScreenshotObservations { + /// Up to 8 visible text labels, each 1..80 characters/320 UTF-8 bytes. + #[serde(default)] + #[schemars(extend("maxItems"=8))] + pub labels: Vec, + /// Caller-observed whole-screen width/height ratio, never a logical size. + #[serde(default)] + pub aspect_ratio: Option, +} +impl ScreenshotObservations { + pub fn validate(&self) -> Result<(), DevupError> { + if self.labels.len() > 8 + || (self.labels.is_empty() && self.aspect_ratio.is_none()) + || self + .labels + .iter() + .any(|s| s.trim().is_empty() || s.chars().count() > 80 || s.len() > 320) + || self + .aspect_ratio + .is_some_and(|n| !n.is_finite() || !(0.01..=100.0).contains(&n)) + { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "screenshotObservations needs 1..8 bounded visible labels or a finite aspectRatio in 0.01..100. Observations are caller-declared, not verified screenshot perception.", + false, + )); + } + Ok(()) + } +} +pub fn rank(result: &mut Value, hints: Option<&ScreenshotObservations>) { + let Some(hints) = hints else { + return; + }; + let Some(candidates) = result["candidates"].as_array_mut() else { + return; + }; + let mut ranked = Vec::new(); + for candidate in candidates.drain(..) { + let mut candidate = candidate; + let name = candidate["node"]["name"] + .as_str() + .unwrap_or("") + .to_lowercase(); + let preview = candidate["node"]["textPreview"] + .as_str() + .unwrap_or("") + .to_lowercase(); + let matched = hints + .labels + .iter() + .filter_map(|label| { + let needle = label.trim().to_lowercase(); + if name.contains(&needle) { + Some(json!({"label":label,"field":"name"})) + } else if preview.contains(&needle) { + Some(json!({"label":label,"field":"capturedTextPreview"})) + } else { + None + } + }) + .collect::>(); + let ratio = candidate["node"]["bounds"]["width"] + .as_f64() + .zip(candidate["node"]["bounds"]["height"].as_f64()) + .filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) + .map(|(w, h)| w / h); + let difference = ratio + .zip(hints.aspect_ratio) + .map(|(actual, observed)| (actual / observed - 1.0).abs()); + let aspect_matches = difference.is_some_and(|delta| delta <= 0.1); + let score = matched.len() * 10 + usize::from(aspect_matches); + candidate["observationEvidence"] = json!({"matchedLabels":matched,"capturedAspectRatio":ratio,"relativeAspectDifference":difference, + "rankScore":score,"state":if score>0 {"observation-candidate"} else {"no-observation-match"},"visionVerified":false}); + ranked.push((score, candidate)); + } + ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + *candidates = ranked.into_iter().map(|(_, candidate)| candidate).collect(); + result["screenshotGuidance"] = json!({"source":"caller-declared-screenshot-observations","observations":hints, + "rankingBasis":"literal captured names/text previews and geometric aspect ratio; stable original order breaks ties", + "evidenceLimit":"No screenshot pixels or visual features were inspected. Text previews can be partial; matching observations only nominates real candidates and never verifies or automatically exports a selection."}); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn observations_only_rank_real_candidates_and_disclose_no_pixel_verification() { + let mut result = json!({"candidates":[{"node":{"nodeId":"1:1","name":"Other","textPreview":"","bounds":{"width":100,"height":100}}},{"node":{"nodeId":"1:2","name":"Landing","textPreview":"Learn more","bounds":{"width":400,"height":800}}}]}); + let hints = ScreenshotObservations { + labels: vec!["learn more".into()], + aspect_ratio: Some(0.5), + }; + rank(&mut result, Some(&hints)); + assert_eq!(result["candidates"][0]["node"]["nodeId"], "1:2"); + assert_eq!(result["candidates"].as_array().unwrap().len(), 2); + assert_eq!( + result["candidates"][0]["observationEvidence"]["visionVerified"], + false + ); + assert!( + ScreenshotObservations { + labels: vec!["x".repeat(81)], + aspect_ratio: None + } + .validate() + .is_err() + ); + assert!( + ScreenshotObservations { + labels: vec![], + aspect_ratio: Some(f64::INFINITY) + } + .validate() + .is_err() + ); + } +} diff --git a/crates/devup-mcp/src/server/section_checklist.rs b/crates/devup-mcp/src/server/section_checklist.rs new file mode 100644 index 00000000..d703b9dc --- /dev/null +++ b/crates/devup-mcp/src/server/section_checklist.rs @@ -0,0 +1,132 @@ +//! Review prompts grounded in captured node fields, never screenshot perception. +use devup_mcp_figma::CollectedPayload; +use serde_json::{Value, json}; + +pub(super) fn checklist(payload: &CollectedPayload, roots: &[String]) -> Value { + let snapshot = &payload.snapshot; + let sections = roots + .iter() + .filter_map(|id| snapshot.nodes.get(id)) + .collect::>(); + let mut records = Vec::new(); + for section in sections.iter().take(20) { + let mut items = Vec::new(); + let mut queue = std::collections::VecDeque::from([section.id.as_str()]); + let mut seen = std::collections::BTreeSet::new(); + let mut ids = Vec::new(); + while let Some(id) = queue.pop_front() { + if !seen.insert(id) { + continue; + } + if ids.len() >= 100 { + queue.push_front(id); + break; + } + ids.push(id); + if let Some(node) = snapshot.nodes.get(id) { + queue.extend(node.typed_view().child_ids()); + } + } + for id in &ids { + let Some(node) = snapshot.nodes.get(*id) else { + continue; + }; + let view = node.typed_view(); + let children = view.child_ids().collect::>(); + if view.string("layoutMode") == Some("HORIZONTAL") && children.len() >= 2 { + items.push(json!({"kind":"child-count-and-spacing","nodeId":id,"name":view.name(), + "observed":{"capturedChildCount":children.len(),"gap":view.value("itemSpacing"),"padding":[view.value("paddingTop"),view.value("paddingRight"),view.value("paddingBottom"),view.value("paddingLeft")]}, + "check":"Compare visible menu/item count and spacing with the implementation; hidden children and semantics require review.","state":"review-required"})); + } + let shapes = children + .iter() + .filter_map(|id| snapshot.nodes.get(*id)) + .filter(|n| { + matches!( + n.node_type.as_str(), + "VECTOR" | "ELLIPSE" | "LINE" | "POLYGON" + ) + }) + .collect::>(); + if !shapes.is_empty() { + items.push(json!({"kind":"decorative-shape-coverage","nodeId":id, + "evidence":shapes.iter().take(20).map(|n|json!({"nodeId":n.id,"type":n.node_type,"name":n.typed_view().name()})).collect::>(), + "capturedShapeCount":shapes.len(),"check":"Review connector, arrow and ring coverage where these captured shapes are used; shape type does not establish its semantic role.","state":"review-required"})); + } + if view + .value("fills") + .and_then(Value::as_array) + .is_some_and(|fills| { + fills + .iter() + .any(|p| p["type"] == "IMAGE" && p["visible"] != false) + }) + { + let evidence=payload.assets.iter().filter(|a|a.node_id==**id).map(|a|json!({"assetId":a.asset_id,"sourceSize":a.render_info.as_ref().map(|v|&v["sourceSize"]),"outputSize":a.render_info.as_ref().map(|v|&v["outputSize"]),"status":a.status})).collect::>(); + items.push(json!({"kind":"image-resolution-and-sharp-layer","nodeId":id,"displayLogicalSize":super::compact::layout(node), + "acquiredEvidence":evidence,"check":"Compare acquired source pixels against logical display size and required density; check that intended sharp image layers accompany blur layers.", + "sourceResolutionState":if evidence.iter().any(|v|v["sourceSize"].is_object()) {"captured"} else {"unavailable"},"state":"review-required"})); + } + } + records.push(json!({"nodeId":section.id,"name":section.typed_view().name(),"items":items.iter().take(30).collect::>(), + "totalItems":items.len(),"itemsTruncated":items.len()>30,"examinedNodes":ids.len().min(100),"nodeScanTruncated":!queue.is_empty()})); + } + json!({"sections":records,"totalSections":sections.len(),"sectionsTruncated":sections.len()>20, + "scope":"selected captured roots with at most 100 descendants examined per root","implementationState":"unverified", + "evidenceLimit":"These are review prompts from actual captured fields. No screenshot was interpreted, browser was rendered, design intent was inferred or missing source image resolution was verified."}) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn no_semantic_findings_without_captured_evidence() { + let snapshot:devup_mcp_figma::Snapshot=serde_json::from_value(json!({"fileKey":"Synthetic","roots":["1:1"],"nodes":{"1:1":{"id":"1:1","type":"FRAME","fields":{"name":"Header","childrenIds":[]}}},"diagnostics":[]})).unwrap(); + let payload = CollectedPayload { + target: devup_mcp_figma::FigmaTarget::bridge_current(), + scope: devup_mcp_figma::CollectionScope::Node, + metadata: Value::Null, + snapshot, + variables: None, + styles: None, + completeness: devup_mcp_figma::PayloadCompleteness::ResolvedValuesOnly, + source_version: None, + stats: Default::default(), + assets: vec![], + reference_png: None, + failures: vec![], + }; + assert_eq!( + checklist(&payload, &["1:1".into()])["sections"][0]["items"], + json!([]) + ); + } + #[test] + fn nested_sections_keep_spacing_shapes_and_unknown_image_resolution_evidence() { + let payload:CollectedPayload=serde_json::from_value(json!({"target":{"fileKey":"Synthetic","nodeId":"1:1","branchKey":null},"scope":"node","metadata":{},"variables":null,"styles":null,"completeness":"resolved-values-only","sourceVersion":null, + "snapshot":{"fileKey":"Synthetic","roots":["1:1"],"nodes":{ + "1:1":{"id":"1:1","type":"FRAME","fields":{"childrenIds":["1:2"]}}, + "1:2":{"id":"1:2","type":"SECTION","fields":{"childrenIds":["1:3"]}}, + "1:3":{"id":"1:3","type":"FRAME","fields":{"layoutMode":"HORIZONTAL","itemSpacing":18,"childrenIds":["1:4","1:5","1:6"]}}, + "1:4":{"id":"1:4","type":"VECTOR","fields":{}},"1:5":{"id":"1:5","type":"ELLIPSE","fields":{}}, + "1:6":{"id":"1:6","type":"RECTANGLE","fields":{"width":160,"height":90,"fills":[{"type":"IMAGE","imageHash":"synthetic"}]}} + },"diagnostics":[]}})).unwrap(); + let result = checklist(&payload, &["1:1".into()]); + let items = result["sections"][0]["items"].as_array().unwrap(); + for kind in [ + "child-count-and-spacing", + "decorative-shape-coverage", + "image-resolution-and-sharp-layer", + ] { + assert!(items.iter().any(|i| i["kind"] == kind), "{result}"); + } + assert_eq!( + items + .iter() + .find(|i| i["kind"] == "image-resolution-and-sharp-layer") + .unwrap()["sourceResolutionState"], + "unavailable" + ); + assert_eq!(result["sections"][0]["examinedNodes"], 6); + } +} diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index 14ab0613..e46f05b5 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -54,6 +54,13 @@ pub struct AuthInput { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct FigmaExportInput { + /// Caller-declared observations from an independently inspected screenshot. + /// Used only to rank actual explore candidates when selection is empty; + /// no screenshot pixels are received or interpreted by this server. + #[serde(default)] + pub screenshot_observations: Option, + #[serde(flatten)] + pub response_selection: super::compact::ResponseSelection, /// web-ready-images acquires every in-scope IMAGE full source as WebP q85, /// capped at display width*2 without upscaling, with asset-css-map.json and /// CSS effects, in sorted scheduled batches of at most six. @@ -188,6 +195,30 @@ pub struct FigmaExportInput { pub width_policy: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] +pub struct FigmaMeasureInput { + #[serde(default)] + pub url: Option, + /// Explicit 1..100 distinct node IDs. Instance descendant IDs are supported. + #[schemars(extend("minItems" = 1, "maxItems" = 100))] + pub node_ids: Vec, + /// Optional caller-declared screenshot pixel dimensions of this measured node. + /// A uniform scale is calibrated from actual node dimensions; UI DPR is not used. + #[serde(default)] + pub calibration: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] +pub struct MeasureCalibration { + pub node_id: String, + pub screen_width_px: f64, + pub screen_height_px: f64, +} + fn default_devup_json_mode() -> String { "full".into() } @@ -279,6 +310,8 @@ pub struct FigmaSearchInput { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct FigmaExploreInput { + #[serde(default)] + pub screenshot_observations: Option, /// The Figma link of the anchor node. Optional while exactly one Devup /// Bridge plugin is attached: omitted, the node selected in Figma. #[serde(default)] diff --git a/crates/devup-mcp/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index 675fe418..a4242235 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -43,7 +43,8 @@ pub(crate) const STACK_DIFF_LAYERS: [&str; 4] = [ "openapi-client", ]; -pub(crate) const EXPORT_OUTPUTS: [&str; 14] = [ +pub(crate) const EXPORT_OUTPUTS: [&str; 15] = [ + "outline", "imageAudit", "assetCssMap", "tsx", @@ -71,7 +72,8 @@ pub(super) fn validate_artifact_projection( let design_output_requested = outputs.iter().any(|output| { matches!( output.as_str(), - "tsx" + "outline" + | "tsx" | "componentTsx" | "responsiveTsx" | "pageScaffold" @@ -103,7 +105,7 @@ pub(super) fn validate_artifact_projection( | "designChanges" ) }), - ArtifactKind::Search | ArtifactKind::Explore => false, + ArtifactKind::Measurement | ArtifactKind::Search | ArtifactKind::Explore => false, }; if capabilities.kind == ArtifactKind::SectionIndex && !kind_compatible { return Err(DevupError::with_details( diff --git a/crates/devup-mcp/tests/bridge_first.rs b/crates/devup-mcp/tests/bridge_first.rs index edbb7377..771f8627 100644 --- a/crates/devup-mcp/tests/bridge_first.rs +++ b/crates/devup-mcp/tests/bridge_first.rs @@ -156,6 +156,24 @@ fn script_answer(script: &str) -> Result { "extra": {}, "fieldErrors": {} }); match script { + "explore" => { + let mut frame = frame_node(); + frame["fields"]["x"] = json!(0); + frame["fields"]["y"] = json!(0); + frame["fields"]["height"] = json!(600); + frame["fields"]["textPreview"] = json!("Featured catalog"); + let mut page = page; + page["fields"]["x"] = json!(0); + page["fields"]["y"] = json!(0); + page["fields"]["width"] = json!(2000); + page["fields"]["height"] = json!(1000); + Ok( + json!({"fileKey":"","version":null,"rootIds":["0:1"],"nodes":[page,frame],"diagnostics":[]}), + ) + } + "bridgeContext" => Ok( + json!({"kind":"devup-bridge-context","currentPage":{"id":"0:1","name":"Page 1"},"selection":[{"id":"1:2","name":"Synthetic Frame","type":"FRAME"}],"selectionCount":1,"observedAt":1}), + ), "pageCatalog" => Ok(json!({ "fileKey": "", "version": null, "rootIds": ["0:1"], "nodes": [page], "diagnostics": [] })), @@ -189,7 +207,16 @@ fn serve(mut socket: Socket) -> Arc>> { tokio::spawn(async move { while let Some(Ok(Message::Text(text))) = socket.next().await { let job: Value = serde_json::from_str(&text).expect("a job is JSON"); - let answer = match script_answer(job["script"].as_str().unwrap_or_default()) { + let data = if job["script"] == "measureNodes" { + Ok( + json!({"kind":"devup-measure","schemaVersion":1,"coordinateSpace":"figma-logical", + "nodes":job["params"]["rootIds"].as_array().unwrap().iter().map(|id|json!({"nodeId":id,"status":"measured","width":167,"height":94, + "padding":{"top":8,"right":8,"bottom":8,"left":8},"gap":12,"layoutMode":"HORIZONTAL","align":{"primary":"CENTER","counter":"MIN"}})).collect::>()}), + ) + } else { + script_answer(job["script"].as_str().unwrap_or_default()) + }; + let answer = match data { Ok(data) => { json!({ "kind": "devup-result", "requestId": job["requestId"], "data": data }) } @@ -210,6 +237,133 @@ fn serve(mut socket: Socket) -> Arc>> { jobs } +fn serve_with_context(mut socket: Socket, context: Option) -> Arc>> { + let jobs = Arc::new(Mutex::new(Vec::new())); + let log = jobs.clone(); + tokio::spawn(async move { + while let Some(Ok(Message::Text(text))) = socket.next().await { + let job: Value = serde_json::from_str(&text).unwrap(); + let data = if job["script"] == "bridgeContext" { + context.clone().ok_or("DEVUP_BRIDGE_UNKNOWN_SCRIPT") + } else { + script_answer(job["script"].as_str().unwrap_or_default()) + }; + let answer = match data { + Ok(data) => json!({"kind":"devup-result","requestId":job["requestId"],"data":data}), + Err(error) => { + json!({"kind":"devup-result","requestId":job["requestId"],"error":error}) + } + }; + log.lock().await.push(job); + if socket + .send(Message::Text(answer.to_string().into())) + .await + .is_err() + { + break; + } + } + }); + jobs +} + +#[tokio::test] +async fn operation_refresh_overrides_stale_events_and_empty_current_never_exports_history() +-> anyhow::Result<()> { + for empty in [false, true] { + let setup = setup(); + let mut hello = keyless_hello("Synthetic"); + hello["selection"] = json!([{"id":"7:8","name":"Stale","type":"FRAME"}]); + hello["currentPage"] = json!({"id":"0:9","name":"Stale page"}); + let current = json!({"kind":"devup-bridge-context","currentPage":{"id":"0:1","name":"Current"}, + "selection":if empty {json!([])} else {json!([{"id":"1:2","name":"Fresh","type":"FRAME"}])}, + "selectionCount":if empty {0} else {1},"lastKnownSelection":[{"id":"1:2","name":"Fresh","type":"FRAME"}],"lastKnownPage":{"id":"0:1","name":"Current"},"observedAt":2}); + let jobs = serve_with_context(attach(&setup.bridge, hello).await, Some(current)); + let (failed, value) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_export", + json!({"outputs":["outline"],"screenshotObservations":{"labels":["Featured catalog"],"aspectRatio":0.5333333333333333}}), + ) + .await?; + assert_eq!(failed, empty, "{value}"); + if empty { + let details = &value["error"]["details"]; + assert_eq!(details["selectionFreshness"]["state"], "current"); + assert_eq!(details["file"]["selectionCount"], 0); + assert_eq!( + details["selectionFreshness"]["lastKnown"]["selection"][0]["id"], + "7:8" + ); + assert_eq!( + details["exploreSuggestion"]["screenshotMatch"], + "unverified" + ); + assert_eq!( + details["exploreSuggestion"]["screenshotGuidance"]["source"], + "caller-declared-screenshot-observations" + ); + assert_eq!( + details["exploreSuggestion"]["candidates"][0]["node"]["nodeId"], + "1:2" + ); + assert_eq!( + details["exploreSuggestion"]["candidates"][0]["observationEvidence"]["visionVerified"], + false + ); + assert!( + !jobs + .lock() + .await + .iter() + .any(|job| matches!(job["script"].as_str(), Some("snapshot" | "fastSnapshot"))) + ); + } else { + assert_eq!(value["outline"]["nodes"][0]["nodeId"], "1:2"); + } + } + Ok(()) +} + +#[tokio::test] +async fn an_older_plugin_cannot_promote_last_delivered_selection_to_current() -> anyhow::Result<()> +{ + let setup = setup(); + let jobs = serve_with_context( + attach(&setup.bridge, keyless_hello("Synthetic")).await, + None, + ); + let (failed, value) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_export", + json!({"outputs":["outline"]}), + ) + .await?; + assert!(failed, "{value}"); + assert_eq!( + value["error"]["details"]["selectionFreshness"]["state"], + "refresh-failed" + ); + assert_eq!( + value["error"]["details"]["selectionFreshness"]["current"], + Value::Null + ); + assert_eq!( + value["error"]["details"]["nextAction"]["requiredArguments"], + json!(["url"]) + ); + assert!( + !value["error"]["details"]["nextAction"]["arguments"]["url"] + .as_str() + .unwrap() + .contains("1-2") + ); + assert_eq!(jobs.lock().await.len(), 1); + assert_eq!(setup.remote_calls.load(Ordering::SeqCst), 0); + Ok(()) +} + /// Calls one tool and returns its structured answer, error or not, polling an /// export job to completion the way a client does. async fn call( @@ -376,7 +530,11 @@ async fn an_export_without_url_reads_the_selection_through_the_bridge() -> anyho assert!(!jobs.is_empty()); assert!( jobs.iter() - .filter(|job| job["script"] != "pageCatalog") + .filter(|job| !matches!( + job["script"].as_str(), + Some("pageCatalog" | "bridgeContext") + )) + .filter(|job| job["script"] != "bridgeContext") .all(|job| job["params"]["nodeId"] == "1:2"), "every read targets the selected frame: {jobs:?}" ); @@ -620,3 +778,204 @@ async fn login_with_a_plugin_attached_warns_and_still_logs_in() -> anyhow::Resul assert_eq!(answer["activePath"], "bridge"); Ok(()) } + +#[tokio::test] +async fn node_measurements_are_lightweight_retained_and_cannot_be_reused_as_design_exports() +-> anyhow::Result<()> { + let setup = setup(); + let jobs = serve(attach(&setup.bridge, keyless_hello("Synthetic")).await); + let server = DevupServer::new(Services::new( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + )); + let (a, b) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(a).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(b).await?; + let measured=client.call_tool(CallToolRequestParams::new("devup_figma_measure").with_arguments(json!({"nodeIds":["I1:2;3:4","1:2"],"calibration":{"nodeId":"1:2","screenWidthPx":334,"screenHeightPx":188}}).as_object().unwrap().clone())).await?; + assert_ne!(measured.is_error, Some(true), "{measured:?}"); + let value = measured.structured_content.unwrap(); + assert_eq!(value["nodes"][0]["nodeId"], "I1:2;3:4"); + assert_eq!(value["nodes"][0]["width"], 167); + assert_eq!(value["source"]["fileKey"], Value::Null); + assert_eq!(value["cache"]["capabilities"]["kind"], "measurement"); + assert_eq!(value["calibration"]["uniformScale"], 2.0); + assert!(!shows_a_routing_key(&value), "{value}"); + let id = value["cache"]["artifactId"].as_str().unwrap(); + let refused = client + .call_tool( + CallToolRequestParams::new("devup_figma_export").with_arguments( + json!({"artifactId":id,"outputs":["tsx"]}) + .as_object() + .unwrap() + .clone(), + ), + ) + .await?; + assert_eq!(refused.is_error, Some(true)); + let planned=client.call_tool(CallToolRequestParams::new("devup_runtime_audit").with_arguments(json!({"action":"plan","plan":{"baseUrl":"http://127.0.0.1:4173","allowedOrigins":["http://127.0.0.1:4173"],"viewports":[{"width":390,"height":844}],"screens":[{"id":"page","routePath":"/","checks":[{"kind":"dimensions","subject":{"testId":"card"},"measure":"box","tolerancePx":0.5,"figmaDimensions":[{"viewportWidth":390,"artifactId":id,"nodeId":"1:2"}]}]}]}}).as_object().unwrap().clone())).await?; + assert_ne!(planned.is_error, Some(true), "{planned:?}"); + assert!( + planned + .structured_content + .unwrap() + .to_string() + .contains("167") + ); + let invalid = client + .call_tool( + CallToolRequestParams::new("devup_figma_measure").with_arguments( + json!({"nodeIds":["1:2;alert(1)"]}) + .as_object() + .unwrap() + .clone(), + ), + ) + .await?; + assert_eq!(invalid.is_error, Some(true)); + let jobs = jobs.lock().await; + assert_eq!( + jobs.iter() + .filter(|j| j["script"] == "measureNodes") + .count(), + 1 + ); + assert!( + jobs.iter() + .all(|j| matches!(j["script"].as_str(), Some("measureNodes" | "bridgeContext"))) + ); + assert_eq!(setup.remote_calls.load(Ordering::SeqCst), 0); + drop(jobs); + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn bounded_outline_and_path_selection_use_the_same_artifact_and_resource_evidence() +-> anyhow::Result<()> { + let setup = setup(); + let jobs = serve(attach(&setup.bridge, keyless_hello("Synthetic")).await); + let server = DevupServer::new(Services::new( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + )); + let (a, b) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(a).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(b).await?; + let exported=client.call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(json!({"url":"figma-bridge://current?node-id=1-2","outputs":["outline"],"responseLimit":1}).as_object().unwrap().clone())).await?; + assert_ne!(exported.is_error, Some(true), "{exported:?}"); + let value = exported.structured_content.unwrap(); + let id = value["cache"]["artifactId"].as_str().unwrap(); + assert_eq!(value["outline"]["nodes"][0]["layout"]["width"], 320.0); + assert!(value.get("tsx").is_none()); + assert_eq!(value["deliverable"]["kind"], "outline-data"); + assert!( + value["selectionEvidence"]["uri"] + .as_str() + .unwrap() + .starts_with("devup://artifact/") + ); + assert!(value.to_string().len() < 32000); + let reads = jobs.lock().await.len(); + let args = json!({"artifactId":id,"outputs":["outline"],"path":"nodes[name=\"Synthetic Frame\"].layout"}); + let selected = client + .call_tool( + CallToolRequestParams::new("devup_figma_export") + .with_arguments(args.as_object().unwrap().clone()), + ) + .await?; + assert_ne!(selected.is_error, Some(true), "{selected:?}"); + let selected = selected.structured_content.unwrap(); + assert_eq!(selected["cache"]["artifactId"], id); + assert_eq!(selected["cache"]["cacheHit"], true); + assert_eq!( + selected["selected"]["selections"][0]["nodeIds"], + json!(["1:2"]) + ); + assert_eq!( + selected["selected"]["selections"][0]["values"][0]["width"], + 320.0 + ); + assert_eq!(selected["deliverable"]["kind"], "selected-data"); + assert!( + selected["selectionEvidence"]["uri"] + .as_str() + .unwrap() + .starts_with("devup://artifact/") + ); + assert!( + selected.to_string().len() < 32000, + "compact response: {}", + selected.to_string().len() + ); + let again = client + .call_tool( + CallToolRequestParams::new("devup_figma_export") + .with_arguments(args.as_object().unwrap().clone()), + ) + .await? + .structured_content + .unwrap(); + assert_eq!( + again["selectionEvidence"]["uri"], + selected["selectionEvidence"]["uri"] + ); + assert_eq!(jobs.lock().await.len(), reads, "reprojection called Figma"); + let invalid = client + .call_tool( + CallToolRequestParams::new("devup_figma_export").with_arguments( + json!({"artifactId":id,"outputs":["outline"],"path":"nodes.filter(x)"}) + .as_object() + .unwrap() + .clone(), + ), + ) + .await?; + assert_eq!(invalid.is_error, Some(true)); + assert_eq!(jobs.lock().await.len(), reads); + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn measurement_batches_stay_bounded_and_keep_request_order() -> anyhow::Result<()> { + let setup = setup(); + let jobs = serve(attach(&setup.bridge, keyless_hello("Synthetic")).await); + let ids = (1..=25).map(|n| format!("8:{n}")).collect::>(); + let (failed, result) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_measure", + json!({"nodeIds":ids}), + ) + .await?; + assert!(!failed, "{result}"); + assert_eq!( + result["nodes"] + .as_array() + .unwrap() + .iter() + .map(|n| n["nodeId"].as_str().unwrap()) + .collect::>(), + ids.iter().map(String::as_str).collect::>() + ); + let jobs = jobs.lock().await; + let reads = jobs + .iter() + .filter(|j| j["script"] == "measureNodes") + .collect::>(); + assert_eq!(reads.len(), 3); + assert!( + reads + .iter() + .all(|j| j["params"]["rootIds"].as_array().unwrap().len() <= 10) + ); + Ok(()) +} diff --git a/crates/devup-mcp/tests/bridge_handover.rs b/crates/devup-mcp/tests/bridge_handover.rs index 3337743a..04159ebf 100644 --- a/crates/devup-mcp/tests/bridge_handover.rs +++ b/crates/devup-mcp/tests/bridge_handover.rs @@ -236,6 +236,9 @@ fn script_answer(script: &str) -> Result { "extra": {}, "fieldErrors": {} }); match script { + "bridgeContext" => Ok( + json!({"kind":"devup-bridge-context","currentPage":{"id":"0:1","name":"Page 1"},"selection":[{"id":"1:2","name":"Synthetic Frame","type":"FRAME"}],"selectionCount":1,"observedAt":1}), + ), "pageCatalog" => Ok(json!({ "fileKey": "", "version": null, "rootIds": ["0:1"], "nodes": [page], "diagnostics": [] })), diff --git a/crates/devup-mcp/tests/bridge_relay.rs b/crates/devup-mcp/tests/bridge_relay.rs index 1ae0d09e..d385caa1 100644 --- a/crates/devup-mcp/tests/bridge_relay.rs +++ b/crates/devup-mcp/tests/bridge_relay.rs @@ -163,6 +163,9 @@ fn script_answer(script: &str) -> Result { "extra": {}, "fieldErrors": {} }); match script { + "bridgeContext" => Ok( + json!({"kind":"devup-bridge-context","currentPage":{"id":"0:1","name":"Page 1"},"selection":[{"id":"1:2","name":"Synthetic Frame","type":"FRAME"}],"selectionCount":1,"observedAt":1}), + ), "pageCatalog" => Ok(json!({ "fileKey": "", "version": null, "rootIds": ["0:1"], "nodes": [page], "diagnostics": [] })), diff --git a/crates/devup-mcp/tests/ground_truth_tools.rs b/crates/devup-mcp/tests/ground_truth_tools.rs index ffdb59e2..f2c76ac8 100644 --- a/crates/devup-mcp/tests/ground_truth_tools.rs +++ b/crates/devup-mcp/tests/ground_truth_tools.rs @@ -580,3 +580,21 @@ async fn ui_validate_double_effect_uses_actual_baked_asset_evidence() -> anyhow: ); Ok(()) } + +#[tokio::test] +async fn ui_validate_blur_only_and_double_effect_warnings_are_complementary() -> anyhow::Result<()> +{ + let result=call_tool("devup_ui_validate",json!({"tsx":"","strict":true,"assetPolicies":{"art":{"renderMode":"node","assetPath":"/images/art.webp","effectsBaked":true}}})).await?.structured_content.unwrap(); + for rule in ["blur-only-image", "double-effect"] { + assert!( + result["violations"] + .as_array() + .unwrap() + .iter() + .any(|v| v["rule"] == rule), + "{result}" + ); + } + assert_eq!(result["ok"], false); + Ok(()) +} diff --git a/crates/devup-mcp/tests/runtime_audit_tool.rs b/crates/devup-mcp/tests/runtime_audit_tool.rs index f20e4a97..ecede467 100644 --- a/crates/devup-mcp/tests/runtime_audit_tool.rs +++ b/crates/devup-mcp/tests/runtime_audit_tool.rs @@ -151,3 +151,89 @@ async fn a_plan_is_written_and_a_report_bound_to_it_is_ingested() -> anyhow::Res std::fs::remove_dir_all(&root)?; Ok(()) } + +#[tokio::test] +async fn consumer_template_is_allowlisted_and_missing_acquisitions_never_pass() -> anyhow::Result<()> +{ + let root = std::env::temp_dir().join(format!("devup-runtime-template-{}", std::process::id())); + std::fs::create_dir_all(&root)?; + let root = dunce::canonicalize(root)?; + let widths = [1440, 1100, 858, 390]; + let plan = json!({ + "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], + "viewports": widths.iter().map(|width| json!({"width": width, "height": 100, "deviceScaleFactor": 2})).collect::>(), + "screens": [{"id": "fixture", "routePath": "/", "checks": [{"kind": "dimensions", + "subject": {"testId": "target"}, "tolerancePx": 0.5, + "figmaDimensions": widths.iter().map(|width| json!({"viewportWidth": width, "artifactId": "missing", "nodeId": "target"})).collect::>() + }]}] + }); + let (failed, planned) = call( + &root, + json!({"action": "plan", "plan": plan, + "outputPath": "plan.json", "templatePath": "consumer-audit"}), + ) + .await?; + assert!(!failed, "{planned}"); + assert_eq!(planned["template"]["executed"], false); + assert_eq!(planned["dimensionReferences"][0]["status"], "unverified"); + assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 4); + let runner = std::fs::read_to_string(root.join("consumer-audit/run.mjs"))?; + assert!(runner.contains("runPlan")); + let manifest: Value = + serde_json::from_slice(&std::fs::read(root.join("consumer-audit/package.json"))?)?; + assert_eq!(manifest["dependencies"]["playwright"], "1.63.0"); + + let plan_bytes = std::fs::read(root.join("plan.json"))?; + let runs = widths.iter().map(|width| { + let bytes = png(width * 2, 200); + let name = format!("{width}.png"); + std::fs::write(root.join(&name), &bytes).unwrap(); + json!({"viewport": {"width": width, "height": 100, "deviceScaleFactor": 2}, "environmentErrors": [], + "screens": [{"id": "fixture", "status": "completed", "checks": [{"index": 0, "status": "pass", "measured": { + "units": "css-px", "subjectCount": 1, "subjectVisible": true, + "subject": {"width": 100, "height": 80}}}], + "screenshot": {"path": name, "sha256": sha256(&bytes), "bytes": bytes.len()}}]}) + }).collect::>(); + let mut report = json!({"schemaVersion": 1, "kind": "devup-runtime-audit-report", "planId": planned["planId"], + "planSha256": sha256(&plan_bytes), "status": "completed", "runs": runs}); + std::fs::write(root.join("report.json"), serde_json::to_vec(&report)?)?; + let (failed, ingested) = call( + &root, + json!({"action": "ingest", "planPath": "plan.json", "reportPath": "report.json"}), + ) + .await?; + assert!(!failed, "{ingested}"); + assert_eq!(ingested["verdict"], "inconclusive"); + assert_eq!(ingested["summary"]["inconclusive"], 4); + assert_eq!(ingested["widthTable"].as_array().unwrap().len(), 4); + assert_eq!(ingested["toleranceExceedances"], json!([])); + + report["planSha256"] = json!("edited"); + std::fs::write(root.join("report.json"), serde_json::to_vec(&report)?)?; + let (_, refused) = call( + &root, + json!({"action": "ingest", "planPath": "plan.json", "reportPath": "report.json"}), + ) + .await?; + assert_eq!(refused["reason"], "plan-file-differs"); + for request in [ + json!({"action": "plan", "plan": plan, "outputPath": "other.json", "templatePath": "../outside"}), + json!({"action": "plan", "plan": plan, "templatePath": "unbound"}), + json!({"action": "plan", "plan": plan, "outputPath": "other.json", "command": "node run.mjs"}), + ] { + assert!(call(&root, request).await?.0); + assert!(!root.join("other.json").exists()); + } + let mut invalid = plan; + invalid["screens"][0]["checks"][0]["tolerancePx"] = json!(-1); + assert!( + call( + &root, + json!({"action": "plan", "plan": invalid, "outputPath": "other.json"}) + ) + .await? + .0 + ); + std::fs::remove_dir_all(root)?; + Ok(()) +} diff --git a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs index 9226dfc0..829c1a15 100644 --- a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs +++ b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs @@ -211,8 +211,8 @@ fn tools_list_over_raw_stdio_has_no_boolean_schemas_and_object_output_types() -> .expect("tools/list result must contain a tools array"); assert_eq!( tools.len(), - 11, - "expected all 11 devup-mcp tools (4 devup_figma_* + devup_skills + devup_project_context + devup_ui_validate + devup_stack_diff + devup_visual_compare + devup_feature_trace + devup_runtime_audit) to be listed: {tools:?}" + 12, + "expected all 12 devup-mcp tools (5 devup_figma_* + devup_skills + devup_project_context + devup_ui_validate + devup_stack_diff + devup_visual_compare + devup_feature_trace + devup_runtime_audit) to be listed: {tools:?}" ); let mut boolean_schema_hits = Vec::new(); diff --git a/crates/devup-mcp/tests/stdio_smoke.rs b/crates/devup-mcp/tests/stdio_smoke.rs index c053ec52..9822b546 100644 --- a/crates/devup-mcp/tests/stdio_smoke.rs +++ b/crates/devup-mcp/tests/stdio_smoke.rs @@ -81,7 +81,8 @@ async fn fresh_binary_initializes_lists_tools_and_reports_auth_status() -> anyho assert!(names.contains(&"devup_feature_trace")); assert!(names.contains(&"devup_skills")); assert!(names.contains(&"devup_runtime_audit")); - assert_eq!(names.len(), 11); + assert!(names.contains(&"devup_figma_measure")); + assert_eq!(names.len(), 12); send( &mut stdin, diff --git a/crates/devup-mcp/tests/stdio_tools.rs b/crates/devup-mcp/tests/stdio_tools.rs index 0db0f9b1..76fbf944 100644 --- a/crates/devup-mcp/tests/stdio_tools.rs +++ b/crates/devup-mcp/tests/stdio_tools.rs @@ -171,6 +171,7 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { "devup_figma_auth", "devup_figma_explore", "devup_figma_export", + "devup_figma_measure", "devup_figma_search", "devup_project_context", "devup_runtime_audit", @@ -199,6 +200,10 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { "frameIds", "allScreens", "delivery", + "responseFields", + "path", + "responseLimit", + "responseOffset", ] { assert!(export_text.contains(field), "missing export field {field}"); } diff --git a/harness/runtime-audit/checks.mjs b/harness/runtime-audit/checks.mjs index 18bc32cc..5483c555 100644 --- a/harness/runtime-audit/checks.mjs +++ b/harness/runtime-audit/checks.mjs @@ -116,6 +116,31 @@ export function evaluateCheck(check, measured) { return { status: 'inconclusive', reason: `unknown check kind ${check.kind}`, measured: {} }; } +/** Pure dimension comparison; raw CSS pixels are never multiplied by DPR. */ +export function evaluateDimensions(check, measured, reference) { + const tolerance = check.tolerancePx; + if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 10_000) { + throw new Error('dimensions tolerancePx must be finite and in [0, 10000]'); + } + const evidence = { + units: 'css-px', subjectCount: measured.subjectCount, + subjectVisible: measured.subjectVisible === true, subject: measured.subject ?? null, + }; + const unknown = (reason) => ({ status: 'inconclusive', reason, measured: evidence }); + const problem = missing('subject', measured.subjectCount); + if (problem) return unknown(problem); + if (!reference || reference.status !== 'acquired') return unknown('no acquired Figma logical dimensions explicitly mapped at this width'); + if (![reference.width, reference.height, measured.subject?.width, measured.subject?.height] + .every((value) => Number.isFinite(value) && value >= 0)) return unknown('logical dimensions or DOM CSS geometry is invalid'); + if (!evidence.subjectVisible) return unknown('the mapped element is hidden'); + const delta = { width: measured.subject.width - reference.width, height: measured.subject.height - reference.height }; + return { + status: Math.abs(delta.width) <= tolerance && Math.abs(delta.height) <= tolerance ? 'pass' : 'fail', + reason: 'DOM CSS pixels compared with acquired Figma logical dimensions', + measured: evidence, deltaPx: delta, tolerancePx: tolerance, + }; +} + /** Counts recomputed from items, never taken from a declaration. */ export function summarize(runs) { const summary = { pass: 0, fail: 0, inconclusive: 0, skipped: 0 }; diff --git a/harness/runtime-audit/fixtures/dimensions.html b/harness/runtime-audit/fixtures/dimensions.html new file mode 100644 index 00000000..f26937a8 --- /dev/null +++ b/harness/runtime-audit/fixtures/dimensions.html @@ -0,0 +1,8 @@ + + +Dimension fixture + +
diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index 48f24da3..55cf5b1f 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -14,7 +14,8 @@ import { createHash } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { release } from 'node:os'; import { join, resolve } from 'node:path'; -import { evaluateCheck, summarize } from './checks.mjs'; +import { pathToFileURL } from 'node:url'; +import { evaluateCheck, evaluateDimensions, summarize } from './checks.mjs'; const PLAN_KIND = 'devup-runtime-audit-plan'; const REPORT_KIND = 'devup-runtime-audit-report'; @@ -184,7 +185,7 @@ async function checkExpectations(page, expect, timeout) { return null; } -async function runViewport(browser, plan, viewport, out, deadline) { +async function runViewport(browser, plan, viewport, out, deadline, references) { const allowed = new Set(plan.allowedOrigins.map((origin) => new URL(origin).origin)); const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, @@ -193,6 +194,7 @@ async function runViewport(browser, plan, viewport, out, deadline) { hasTouch: Boolean(viewport.touch), locale: plan.locale ?? undefined, timezoneId: plan.timezone ?? undefined, + serviceWorkers: 'block', }); const environmentErrors = []; const blocked = []; @@ -280,14 +282,14 @@ async function runViewport(browser, plan, viewport, out, deadline) { const container = await measureBox(page, check.container); const result = subject.error ? { status: 'inconclusive', reason: subject.error, measured: {} } - : evaluateCheck(check, { + : (check.kind === 'dimensions' ? evaluateDimensions : evaluateCheck)(check, { page: metrics, subject: subject.box, subjectCount: subject.count, subjectVisible: subject.visible, container: container.box, containerCount: container.count, - }); + }, references.find((r) => r.screen === screen.id && r.check === index && r.viewportWidth === viewport.width)); record.checks.push({ index, kind: check.kind, label: check.label, ...result }); } const name = `${viewport.width}x${viewport.height}@${viewport.deviceScaleFactor ?? 1}-${safeName(screen.id)}.png`; @@ -317,6 +319,8 @@ export async function runPlan({ plan: planPath, out, maxMinutes }) { throw new Error(`${planPath} is not a devup runtime audit plan; create one with devup_runtime_audit action plan`); } const plan = document.plan; + validateDimensionsPlan(plan); + if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) throw new Error('maxMinutes must be finite and positive'); const outDir = resolve(out); await mkdir(outDir, { recursive: true }); const { chromium } = await import('playwright'); @@ -350,7 +354,7 @@ export async function runPlan({ plan: planPath, out, maxMinutes }) { try { for (const viewport of plan.viewports) { if (aborted || Date.now() > deadline) break; - report.runs.push(await runViewport(browser, plan, viewport, outDir, deadline)); + report.runs.push(await runViewport(browser, plan, viewport, outDir, deadline, document.dimensionReferences ?? [])); } const ranEverything = report.runs.length === plan.viewports.length && report.runs.every((run) => run.screens.every((screen) => screen.status === 'completed')); @@ -366,7 +370,33 @@ export async function runPlan({ plan: planPath, out, maxMinutes }) { return report; } -if (import.meta.url === new URL(process.argv[1], 'file:').href || process.argv[1]?.endsWith('run.mjs')) { +export function validateDimensionsPlan(plan) { + for (const screen of plan.screens) { + for (const check of screen.checks) { + if (check.tolerancePx != null && (!Number.isFinite(check.tolerancePx) || check.tolerancePx < 0 || check.tolerancePx > 10_000)) { + throw new Error('tolerancePx must be finite and in [0, 10000]'); + } + if (check.kind !== 'dimensions') continue; + const mappings = check.figmaDimensions; + if (!check.subject || check.container || !Number.isFinite(check.tolerancePx) + || (check.measure != null && check.measure !== 'box') || !Array.isArray(mappings) || !mappings.length) { + throw new Error('dimensions requires subject, tolerancePx, figmaDimensions and box measurement'); + } + const widths = new Set(); + for (const reference of mappings) { + if (widths.has(reference.viewportWidth) || !plan.viewports.some((v) => v.width === reference.viewportWidth) + || typeof reference.nodeId !== 'string' || !reference.nodeId.trim() + || typeof reference.artifactId !== 'string' || !reference.artifactId.trim() + || Object.keys(reference).some((key) => !['viewportWidth', 'nodeId', 'artifactId'].includes(key))) { + throw new Error('dimensions references require unique planned widths and explicit artifactId/nodeId only'); + } + widths.add(reference.viewportWidth); + } + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { try { const report = await runPlan(parseArgs(process.argv.slice(2))); const { pass, fail, inconclusive, skipped } = report.summary; diff --git a/harness/runtime-audit/test/browser.test.mjs b/harness/runtime-audit/test/browser.test.mjs index b6b8993c..5c30eea3 100644 --- a/harness/runtime-audit/test/browser.test.mjs +++ b/harness/runtime-audit/test/browser.test.mjs @@ -172,3 +172,50 @@ test('a request outside allowedOrigins is blocked and recorded as an environment await rm(out, { recursive: true, force: true }); } }); + +test('dimensions across 1440/1100/858/390 retain CSS pixels at DPR 2 and filter tolerances', async () => { + const server = await serve(); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const out = await mkdtemp(join(tmpdir(), 'devup-runtime-dimensions-')); + const widths = [1440, 1100, 858, 390]; + try { + const mappings = widths.map((viewportWidth) => ({ viewportWidth, artifactId: 'synthetic-acquisition', nodeId: `target-${viewportWidth}` })); + const checks = [ + { kind: 'dimensions', label: 'within tolerance', subject: { testId: 'target' }, tolerancePx: 0.5, figmaDimensions: mappings }, + { kind: 'dimensions', label: 'exceeds tolerance', subject: { testId: 'target' }, tolerancePx: 0.49, figmaDimensions: mappings }, + { kind: 'dimensions', label: 'missing element', subject: { testId: 'absent' }, tolerancePx: 1, figmaDimensions: mappings }, + { kind: 'dimensions', label: 'no intermediate reference', subject: { testId: 'target' }, tolerancePx: 1, figmaDimensions: [mappings[0]] }, + ]; + const planPath = join(out, 'plan.json'); + await writeFile(planPath, JSON.stringify({ + schemaVersion: 1, kind: 'devup-runtime-audit-plan', planId: 'synthetic-dimensions', + plan: { baseUrl, allowedOrigins: [baseUrl], viewports: widths.map((width) => ({ width, height: 240, deviceScaleFactor: 2 })), + screens: [{ id: 'dimensions', routePath: '/dimensions.html', checks }] }, + // Synthetic acquired snapshots for executor testing; ingest independently rechecks these. + dimensionReferences: checks.flatMap((check, index) => check.figmaDimensions.map((r) => ({ + ...r, screen: 'dimensions', check: index, status: 'acquired', width: r.viewportWidth / 2 - 20.5, height: 80, + }))), + })); + const report = await runPlan({ plan: planPath, out, maxMinutes: 2 }); + assert.equal(report.status, 'completed'); + const table = []; + for (const run of report.runs) { + assert.deepEqual(run.environmentErrors, []); + const screen = run.screens[0]; + assert.deepEqual(screen.checks.map((c) => c.status), ['pass', 'fail', 'inconclusive', run.viewport.width === 1440 ? 'pass' : 'inconclusive']); + assert.equal(screen.checks[0].measured.subject.width, run.viewport.width / 2 - 20); + assert.equal(screen.checks[0].measured.units, 'css-px'); + const png = await readFile(join(out, screen.screenshot.path)); + assert.equal(png.readUInt32BE(16), run.viewport.width * 2, 'DPR changes screenshot pixels only'); + table.push({ width: run.viewport.width, figmaWidth: run.viewport.width / 2 - 20.5, + domWidth: screen.checks[0].measured.subject.width, delta: screen.checks[0].deltaPx.width, + exceedances: screen.checks.filter((c) => c.status === 'fail').length }); + } + assert.deepEqual(table.map((r) => r.exceedances), [1, 1, 1, 1]); + assert.deepEqual(report.summary, { pass: 5, fail: 4, inconclusive: 7, skipped: 0 }); + console.log(JSON.stringify({ dimensionTable: table })); + } finally { + server.close(); + await rm(out, { recursive: true, force: true }); + } +}); diff --git a/harness/runtime-audit/test/checks.test.mjs b/harness/runtime-audit/test/checks.test.mjs index 8c20e2a6..d03396cc 100644 --- a/harness/runtime-audit/test/checks.test.mjs +++ b/harness/runtime-audit/test/checks.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { evaluateCheck, summarize } from '../checks.mjs'; +import { evaluateCheck, evaluateDimensions, summarize } from '../checks.mjs'; +import { validateDimensionsPlan } from '../run.mjs'; const page = { viewportWidth: 360, scrollWidth: 360, maxRight: 340, overflowHidden: false }; const box = (x, y, width, height) => ({ x, y, width, height }); @@ -52,3 +53,28 @@ test('the summary is recomputed from the items', () => { const runs = [{ screens: [{ checks: [{ status: 'pass' }, { status: 'fail' }] }, { checks: [{ status: 'skipped' }] }] }]; assert.deepEqual(summarize(runs), { pass: 1, fail: 1, inconclusive: 0, skipped: 1 }); }); + +test('dimensions use raw CSS geometry, inclusive per-check tolerance and explicit references', () => { + const check = { kind: 'dimensions', tolerancePx: 0.25 }; + const measured = { subjectCount: 1, subjectVisible: true, subject: box(0, 0, 100.25, 80) }; + const reference = { status: 'acquired', width: 100, height: 80 }; + assert.equal(evaluateDimensions(check, measured, reference).status, 'pass'); + assert.equal(evaluateDimensions({ ...check, tolerancePx: 0.249 }, measured, reference).status, 'fail'); + assert.equal(evaluateDimensions(check, measured, null).status, 'inconclusive'); + for (const subjectCount of [0, 2]) assert.equal(evaluateDimensions(check, { ...measured, subjectCount }, reference).status, 'inconclusive'); + assert.equal(evaluateDimensions(check, { ...measured, subjectVisible: false }, reference).status, 'inconclusive'); + for (const tolerancePx of [-1, NaN, Infinity, 10001]) assert.throws(() => evaluateDimensions({ ...check, tolerancePx }, measured, reference), /tolerance/); +}); + +test('dimension runner refuses inferred values, duplicate mappings and invalid tolerances before browser launch', () => { + const mapping = { viewportWidth: 390, artifactId: 'fixture', nodeId: 'target' }; + const check = { kind: 'dimensions', subject: { testId: 'target' }, tolerancePx: 0, figmaDimensions: [mapping] }; + const plan = (entry) => ({ viewports: [{ width: 390 }], screens: [{ checks: [entry] }] }); + validateDimensionsPlan(plan(check)); + for (const entry of [ + { ...check, tolerancePx: -1 }, { ...check, measure: 'painted-mask' }, + { ...check, figmaDimensions: [mapping, mapping] }, + { ...check, figmaDimensions: [{ ...mapping, width: 100 }] }, + { ...check, figmaDimensions: [{ ...mapping, viewportWidth: 858 }] }, + ]) assert.throws(() => validateDimensionsPlan(plan(entry))); +}); diff --git a/plugin/dist/code.js b/plugin/dist/code.js index 46d339dd..6b9712c3 100644 --- a/plugin/dist/code.js +++ b/plugin/dist/code.js @@ -1 +1 @@ -(()=>{"use strict";let e={assets:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=e.asset;function r(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],o=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=o(r[e-2],17)^o(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,l,s,d,f,c,u,p]=a;for(let e=0;e<64;e+=1){let n=p+(o(f,6)^o(f,11)^o(f,25))+(f&c^~f&u)+t[e]+r[e]>>>0,a=(o(i,2)^o(i,13)^o(i,22))+(i&l^i&s^l&s)>>>0;p=u,u=c,c=f,f=d+n>>>0,d=s,s=l,l=i,i=n+a>>>0}for(let[e,t]of[i,l,s,d,f,c,u,p].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}async function n(e){try{let t=await figma.getNodeByIdAsync(e);if(t)return t}catch(r){if(!e.startsWith("I")||!e.includes(";"))throw r;try{return await t()}catch(e){throw Error(`${r.message||r}; nested lookup fallback: ${e.message||e}`)}}if(!e.startsWith("I")||!e.includes(";"))return null;return t();async function t(){let t=await figma.getNodeByIdAsync(e.slice(1).split(";")[0]),r=t?[t]:[],n=new Set;for(;r.length;){let t=r.pop();if(t.id===e)return t;if(!n.has(t.id)){if(n.add(t.id),n.size>1e4)throw Error("nested lookup exceeds 10000 nodes");Array.isArray(t.children)&&r.push(...t.children)}}throw Error(`nested node ${e} not found in its instance`)}}async function i(e,t,r=85e3,n=()=>{}){let a,o=Date.now();try{let i=await Promise.race([e.exportAsync(t),new Promise((e,t)=>{a=setTimeout(()=>t(Error(`DEVUP_ASSET_TIMEOUT: native export exceeded ${r} ms`)),r)})]);return n("native-export",Date.now()-o),i}catch(i){if(n("native-export",Date.now()-o,String(i.message||i).slice(0,500)),"SVG_STRING"!==t.format)throw i;let r=Date.now();try{let t=function(e){let t=e=>"number"==typeof e&&Number.isFinite(e),r=(e,t)=>{throw Error(`geometry fallback unsupported: ${e.type||"unknown"} ${e.id||""}: ${t}`)},n=0,i=0;function a(e,i,a){(void 0!==a&&!Array.isArray(a)||void 0===a&&Array.isArray(i)&&i.length)&&r(e,"unresolved paint requires native export");let o=Array.isArray(a)?a.filter(e=>!1!==e.visible):[];return o.length?(Array.isArray(i)&&i.length||r(e,"paint has no rendered geometry"),o.map(a=>{("SOLID"!==a.type||a.blendMode&&"NORMAL"!==a.blendMode)&&r(e,"paint type or blending requires native export");let o=a.color;o&&[o.r,o.g,o.b].every(e=>t(e)&&e>=0&&e<=1)||r(e,"invalid solid color");let l=void 0===a.opacity?1:a.opacity;return(!t(l)||l<0||l>1)&&r(e,"invalid paint opacity"),["NONZERO","EVENODD"].map(t=>{let a=i.filter(e=>e.windingRule===t);if(!a.length)return"";for(let t of a)"string"==typeof t.data&&t.data.trim()||r(e,"empty path"),n++;return`"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e])}" fill="rgb(${100*o.r}%,${100*o.g}%,${100*o.b}%)" fill-opacity="${l}" fill-rule="${"EVENODD"===t?"evenodd":"nonzero"}"/>`}).join("")}).join("")):""}let o=e.absoluteRenderBounds,l=e.absoluteTransform;(!o||![o.x,o.y,o.width,o.height].every(t)||o.width<=0||o.height<=0)&&r(e,"missing render bounds"),(!Array.isArray(l)||2!==l.length||!l.every(e=>Array.isArray(e)&&3===e.length&&e.every(t))||0!==l[0][1]||0!==l[1][0]||l[0][0]<=0||l[1][1]<=0)&&r(e,"root rotation, reflection or shear requires native export");let s=(o.x-l[0][2])/l[0][0],d=(o.y-l[1][2])/l[1][1],f=o.width/l[0][0],c=o.height/l[1][1],u=function e(n,o){if(++i>1e4&&r(n,"subtree exceeds 10000 nodes"),n.isMask&&r(n,"mask requires native export"),!1===n.visible||0===n.opacity)return"";n.clipsContent&&r(n,"clipping requires native export"),Array.isArray(n.effects)&&n.effects.some(e=>!1!==e.visible)&&r(n,"effect requires native export"),n.blendMode&&!["NORMAL","PASS_THROUGH"].includes(n.blendMode)&&r(n,"layer blending requires native export");let l=void 0===n.opacity?1:n.opacity;(!t(l)||l<0||l>1)&&r(n,"invalid layer opacity");let s="";if(!o){let e=n.relativeTransform;Array.isArray(e)&&2===e.length&&e.every(e=>Array.isArray(e)&&3===e.length&&e.every(t))||r(n,"missing relative transform"),s=` transform="matrix(${e[0][0]} ${e[1][0]} ${e[0][1]} ${e[1][1]} ${e[0][2]} ${e[1][2]})"`}let d="";if(["GROUP","FRAME","INSTANCE","COMPONENT"].includes(n.type))(Array.isArray(n.fills)&&n.fills.some(e=>!1!==e.visible)||Array.isArray(n.strokes)&&n.strokes.some(e=>!1!==e.visible))&&r(n,"container paint requires native export"),Array.isArray(n.children)||r(n,"missing children"),d=(!0===n.itemReverseZIndex?[...n.children].reverse():n.children).map(t=>e(t,!1)).join("");else{for(let e of(["VECTOR","BOOLEAN_OPERATION","RECTANGLE","ELLIPSE","POLYGON","STAR","LINE"].includes(n.type)||r(n,"node type requires native export"),Array.isArray(n.strokes)&&n.strokes.some(e=>!1!==e.visible)&&"CENTER"!==n.strokeAlign&&r(n,"non-center stroke alignment requires native export"),Array.isArray(n.dashPattern)&&n.dashPattern.length&&r(n,"dashed stroke requires native export"),[n.fillGeometry,n.strokeGeometry]))void 0!==e&&(!Array.isArray(e)||e.some(e=>!["NONZERO","EVENODD"].includes(e.windingRule)))&&r(n,"unknown winding rule");d=a(n,n.fillGeometry,n.fills)+a(n,n.strokeGeometry,n.strokes)}return`${d}`}(e,!0);return n||r(e,"no painted geometry"),`${u}`}(e);return n("geometry-fallback",Date.now()-r),t}catch(e){throw Error(`native export: ${i.message||i}; ${e.message||e}`)}}finally{clearTimeout(a)}}let a=Date.now(),o="lookup",l="unknown",s=Math.min(6e5,Math.max(1e3,Number(t.timeoutMs)||9e4)),d={method:"native",stages:[]};function f(e,t,r){d.stages.push({stage:e,elapsedMs:t}),r&&(d.nativeError=r),"geometry-fallback"===e&&(d.method="geometry")}async function c(e){let t;try{return await Promise.race([e(),new Promise((e,r)=>{t=setTimeout(()=>r(Error(`DEVUP_ASSET_TIMEOUT: ${o} exceeded ${s} ms asset budget`)),Math.max(0,s-(Date.now()-a)))})])}finally{clearTimeout(t)}}function u(e){let r=e&&"string"==typeof e.message?e.message:String(e),n="string"==typeof figma.editorType?` (editor: ${figma.editorType})`:"";return`${o} failed after ${Date.now()-a} ms${n} (node: ${t.nodeId}, type: ${l}): ${r}`.slice(0,500)}function p(e,r){return e&&d.renderInfo&&(d.renderInfo.attemptedRepresentation=d.renderInfo.representation,d.renderInfo.representation="not-exported",d.renderInfo.renderedAs="not-exported",d.renderInfo.effectsBakedIntoAsset=!1,d.renderInfo.fidelity="unverified"),{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:t.scale,renderMode:y,usageAspectRatio:t.usageAspectRatio??null,maxWidth:t.maxWidth??null,srcset:!0===t.srcset,visibleScopeNodeId:t.visibleScopeNodeId??null,status:"failed",byteLength:null,sha256:null,errorCode:e,exportDiagnostics:d,...r?{errorDetail:r}:{}}}let g="string"==typeof t.field&&t.field.startsWith("$original-image/fills/"),y=void 0===t.renderMode?g?"fill":["svg","pdf"].includes(t.format)?"node":"original-source":t.renderMode,h=["auto","original-source"].includes(y)?"auto"===y?"node":"fill":y;function m(e,r){let n=e=>"number"==typeof e&&Number.isFinite(e)&&e>0,i=Array.isArray(e.effects)?e.effects:null,a=[],o=e=>[...e].slice(0,128).join(""),l=["string"==typeof e.name?o(e.name):"layer"],s=new Set,d=e;for(let t=e.parent;t&&a.length<32&&!s.has(t);t=t.parent)s.add(t),l.length<4&&"string"==typeof t.name&&t.name.length&&!["PAGE","DOCUMENT"].includes(t.type)&&l.push(o(t.name)),a.push({type:t.type||"unknown",clipsContent:t.clipsContent??null,isMask:t.isMask??null,effects:Array.isArray(t.effects)?t.effects:null,opacity:t.opacity??null,blendMode:t.blendMode??null,visible:t.visible??null,siblingMaskPresent:Array.isArray(t.children)?t.children.some(e=>e!==d&&!0===e.isMask):null}),d=t;return{sourceKind:r?"image-fill":t.imageHash?"image-node":"vector-node",renderedAs:"fill"===h?"raw-fill":"node-render",representation:"fill"===h?"original-fill":"native-node",layerHierarchy:l.reverse(),scope:"fill"===h?"selected-paint-original-bytes":"exact-node-subtree",exportSettings:"node"===h?{contentsOnly:!0,useAbsoluteBounds:!1}:null,requestedScale:t.scale,scaleSemantics:"fill"===h?"intrinsic-original-bytes-no-resize":["png","jpg"].includes(t.format)?"native-node-SCALE-constraint":"not-applied-to-vector-document",nodeSize:n(e.width)&&n(e.height)?{width:e.width,height:e.height,aspectRatio:e.width/e.height}:null,outputSize:null,dimensionEvidence:"unknown-until-byte-inspection",aspectRatioComparison:"unknown",scaleMode:r?r.scaleMode??null:null,imageTransform:r?r.imageTransform??null:null,visibleSourceRatio:null,visibleSourceRatioEvidence:"not-computed",effects:i,extraFills:Array.isArray(e.fills)?Math.max(0,e.fills.filter(e=>!1!==e.visible).length-!!r):null,effectsBakedIntoAsset:"node"===h&&["png","jpg"].includes(t.format)&&null!==i,bakedEffects:"node"===h&&["png","jpg"].includes(t.format)&&i?i.filter(e=>!1!==e.visible):[],instanceOverride:"unknown",instanceOverrideEvidence:"no-component-paint-comparison",sourceProperties:{scaleMode:r?r.scaleMode??null:null,imageTransform:r?r.imageTransform??null:null,rotation:r?r.rotation??null:null,filters:r?r.filters??null:null,opacity:r?r.opacity??null:null,effects:i,blurEffects:i?i.filter(e=>!1!==e.visible&&String(e.type).includes("BLUR")):null,isMask:e.isMask??null,clipsContent:e.clipsContent??null,ancestors:a},unappliedProperties:"fill"===h?["scaleMode","imageTransform","rotation","filters","paintOpacity","nodeGeometry","nodeTransforms","nodeEffectsIncludingBlur","nodeAndAncestorMasks","ancestorClipsAndEffects"]:[],warnings:"fill"===h?["original-fill-properties-unapplied","aspect-ratio-unknown"]:["ancestor-canvas-appearance-unverified","aspect-ratio-unknown"],fidelity:"unverified"}}function b(e,t){let r=0;for(let n=e;n&&r++<32;n=n.parent){if("INSTANCE"===n.type&&Array.isArray(n.overrides)&&n.overrides.find(t=>t.id===e.id&&Array.isArray(t.overriddenFields)&&t.overriddenFields.includes("fills"))){t.instanceOverride=!0,t.instanceOverrideEvidence={kind:"figma-instance-overrides",instanceId:n.id,nodeId:e.id,overriddenFields:["fills"]};return}if(["PAGE","DOCUMENT"].includes(n.type))break}}try{let e;if(!["original-source","fill","node","visible","auto"].includes(y))return p("DEVUP_ASSET_RENDER_MODE_UNSUPPORTED");if(!g&&"fill"===y&&(!String(t.field).startsWith("fills/")||!["png","jpg","webp"].includes(t.format)||1!==t.scale))return p("DEVUP_ASSET_FILL_SETTINGS_UNSUPPORTED","Original fill requires fills/N, png or jpg matching its codec, and scale 1; no resizing or transcoding.");if((g||["fill","original-source"].includes(y))&&"bridge"!==t.transport)return p(g?"DEVUP_ORIGINAL_IMAGE_REQUIRES_BRIDGE":"DEVUP_ASSET_FILL_REQUIRES_BRIDGE");let x=await c(()=>n(t.nodeId));if(f("lookup",Date.now()-a),l=x&&x.type||"unknown",!x||["node","visible"].includes(y)&&"function"!=typeof x.exportAsync)return p("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM",u(Error(x?"node has no exportAsync":"node not found")));let E=null;if(g||"string"==typeof t.field&&t.field.startsWith("fills/")){let e=Number(t.field.slice(g?22:6)),r="fills"in x&&Array.isArray(x.fills)?x.fills:[],n=Number.isInteger(e)?r[e]:null,i=n&&"IMAGE"===n.type?n.imageHash||n.imageRef:null;if(!n||i!==t.imageHash)return p("DEVUP_ASSET_SOURCE_CHANGED");E=n}else if("node"!==t.field)return p("DEVUP_ASSET_FIELD_UNSUPPORTED");if("original-source"===y&&(!E||!t.imageHash))return p("DEVUP_ORIGINAL_SOURCE_UNAVAILABLE","No IMAGE fill source exists for this selection. Choose an IMAGE fill or explicitly renderMode node; vector assets should retain SVG.");let A=x;if("visible"===y){let e=t.visibleScopeNodeId;if(!e)return p("DEVUP_VISIBLE_CONTEXT_UNAVAILABLE","Recollect the parent frame and provide visibleScopeNodeId.");let r=new Set;for(;A&&A.id!==e&&!r.has(A)&&r.size<64;)r.add(A),A=A.parent;if(!A||A.id!==e||["PAGE","DOCUMENT"].includes(A.type)||"function"!=typeof A.exportAsync)return p("DEVUP_VISIBLE_SCOPE_UNSUPPORTED","The selected capture scope is not an exportable ancestor of the requested node.")}if(d.renderInfo=m(x,E),"visible"===y){let e=d.renderInfo;e.representation="native-visible",e.renderedAs="visible-scoped-capture",e.captureNodeId=A.id,e.captureBox={width:A.width,height:A.height,aspectRatio:A.width/A.height},e.contextScope="selected-ancestor-subtree",e.captureEffects=Array.isArray(A.effects)?A.effects:null,e.requestedNodeEffects=e.effects,e.effects=e.captureEffects,e.sourceProperties.captureEffects=e.captureEffects,e.visibleContext={requestedNodeId:x.id,captureNodeId:A.id,included:["selected-scope-children","selected-scope-clipping","selected-scope-masks","selected-scope-rounding"],outsideScope:"unknown; ancestors, page backdrop and external layers are not included",canvasReferenceCompared:!1},e.warnings.push("visible-scoped-capture","outside-capture-context-unverified")}b(x,d.renderInfo);let I=null;if(E)try{let r=figma.getImageByHash(t.imageHash),n=r&&await Promise.race([r.getSizeAsync(),new Promise(t=>{e=setTimeout(()=>t(null),250)})]);I=n&&n.width>0&&n.height>0&&Number.isFinite(n.width)&&Number.isFinite(n.height)?{...n,aspectRatio:n.width/n.height}:null}catch(e){}finally{clearTimeout(e)}let S=null;if("auto"===y){let e=function(e,r,n){let i,a=[],o=(e,t)=>{e||a.push(t)},l=e=>Array.isArray(e)&&2===e.length&&e.every(e=>Array.isArray(e)&&3===e.length&&e.every(Number.isFinite))&&1===e[0][0]&&1===e[1][1]&&0===e[0][1]&&0===e[1][0]&&Number.isInteger(e[0][2])&&Number.isInteger(e[1][2]);for(let a of(o("bridge"===t.transport,"raw-requires-bridge"),o(1===t.scale&&["png","jpg"].includes(t.format),"raw-requires-unscaled-raster"),o(!!r&&!0===r.visible&&1===r.opacity&&"NORMAL"===r.blendMode,"paint-compositing-or-unknown"),o(r&&"FILL"===r.scaleMode&&Array.isArray(i=r.imageTransform)&&2===i.length&&i.every((e,t)=>Array.isArray(e)&&3===e.length&&e.every((e,r)=>e===[[1,0,0],[0,1,0]][t][r]))&&0===r.rotation,"paint-transform-or-unknown"),o(r&&r.filters&&Object.values(r.filters).every(e=>0===e),"image-filters-or-unknown"),o(Array.isArray(e.fills)&&1===e.fills.length,"multiple-fills-or-unknown"),o("RECTANGLE"===e.type&&0===e.cornerRadius&&Array.isArray(e.strokes)&&0===e.strokes.length&&(!e.children||0===e.children.length),"node-geometry-or-unknown"),o(Array.isArray(e.effects)&&0===e.effects.length&&!1===e.isMask&&(void 0===e.clipsContent||!1===e.clipsContent)&&1===e.opacity&&!0===e.visible&&"NORMAL"===e.blendMode,"node-effects-mask-compositing-or-unknown"),o(l(e.absoluteTransform)&&l(e.relativeTransform),"node-transform-or-unknown"),o(n.sourceProperties.ancestors.length>0&&n.sourceProperties.ancestors.some(e=>["PAGE","DOCUMENT"].includes(e.type)),"ancestor-chain-incomplete"),o(n.sourceProperties.ancestors.every(e=>!1===e.siblingMaskPresent),"ancestor-sibling-mask-or-unknown"),n.sourceProperties.ancestors.filter(e=>!["PAGE","DOCUMENT"].includes(e.type))))o(!1===a.clipsContent&&!1===a.isMask&&!1===a.siblingMaskPresent&&Array.isArray(a.effects)&&0===a.effects.length&&1===a.opacity&&!0===a.visible&&["NORMAL","PASS_THROUGH"].includes(a.blendMode),"ancestor-dependency-or-unknown");return{eligible:0===a.length,reasons:[...new Set(a)],evidence:"explicit-plugin-properties; intrinsic-dimensions-and-codec-required"}}(x,E,d.renderInfo);if(e.eligible)try{let r=figma.getImageByHash(t.imageHash),n=I;n&&n.width===x.width&&n.height===x.height?(S=await c(()=>r.getBytesAsync()),(137===S[0]&&80===S[1]&&78===S[2]&&71===S[3]?"png":255===S[0]&&216===S[1]&&255===S[2]?"jpg":"unknown")!==t.format&&e.reasons.push("source-codec-mismatch-or-unknown")):e.reasons.push("intrinsic-dimensions-mismatch-or-unknown")}catch(t){e.reasons.push("source-inspection-unavailable")}e.eligible=0===e.reasons.length,h=e.eligible?"fill":"node",d.renderInfo=m(x,E),b(x,d.renderInfo),d.renderInfo.autoDecision={...e,selected:h}}if(d.renderInfo.sourceSize=I,d.renderInfo.sourceSizeEvidence=I?"Figma-image-getSizeAsync":"unknown",g||"fill"===h){d.method="original-fill",o="reading the original image";let e=figma.getImageByHash(t.imageHash);if(!e)return p("DEVUP_ORIGINAL_IMAGE_NOT_FOUND");let n=S||await c(()=>e.getBytesAsync());if(0===n.length||n.length>0x5000000)return p("DEVUP_ASSET_RESPONSE_TOO_LARGE",`${n.length} bytes; one answer carries 1 to 83886080`);let i=e=>e.every((e,t)=>n[t]===e),a=i([137,80,78,71,13,10,26,10])?"image/png":i([255,216,255])?"image/jpeg":i([71,73,70,56])?"image/gif":i([82,73,70,70])&&87===n[8]&&69===n[9]&&66===n[10]&&80===n[11]?"image/webp":null;if(!a)return p("DEVUP_ORIGINAL_IMAGE_CODEC_UNSUPPORTED");if(d.renderInfo.originalMimeType=a,!g&&"fill"===y&&"webp"!==t.format&&a!==({png:"image/png",jpg:"image/jpeg"})[t.format])return p("DEVUP_ASSET_FILL_CODEC_MISMATCH",`Original fill codec is ${a}, but ${t.format} was requested; choose the matching format or renderMode node. No transcoding was performed.`);if(!g)return{...p(null),status:"exported",mimeType:a,byteLength:n.length,sha256:r(n),data:figma.base64Encode(n)};let l=await c(()=>e.getSizeAsync());return{...p(null),status:"exported",kind:"devupOriginalImage",representation:"original-image-v1",format:null,scale:null,mimeType:a,width:l.width,height:l.height,byteLength:n.length,sha256:r(n),data:figma.base64Encode(n)}}let w="webp"===t.format?"PNG":String(t.format||"").toUpperCase();if(!["PNG","JPG","SVG","PDF"].includes(w))return p("DEVUP_ASSET_FORMAT_UNSUPPORTED");let v=Number(t.scale??2);if(!Number.isFinite(v)||v<=0||v>4)return p("DEVUP_ASSET_SCALE_INVALID");let N="SVG"===w,O={format:N?"SVG_STRING":w,contentsOnly:!0,useAbsoluteBounds:!1};("PNG"===w||"JPG"===w)&&(O.constraint={type:"SCALE",value:v}),o="export";let T=Math.max(1,s-(Date.now()-a)),P=N?Math.max(1,T-Math.min(5e3,Math.floor(T/5))):T,_=Date.now(),k=await c(()=>"visible"===y?A.exportAsync(O):E?x.exportAsync(O):i(x,O,P,f));if(E&&f("native",Date.now()-_),"geometry"===d.method&&(d.renderInfo.representation="geometry-fallback",d.renderInfo.renderedAs="geometry-fallback",d.renderInfo.effectsBakedIntoAsset=!1,d.renderInfo.warnings.push("geometry-fallback-not-native-render")),Date.now()-a>=s)throw Error(`DEVUP_ASSET_TIMEOUT: export exceeded ${s} ms asset budget`);o="encode";let C=Date.now(),M=N&&"string"==typeof k?k:null,R=null===M?k instanceof Uint8Array?k:new Uint8Array(k):function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(M);if(0===R.length||R.length>0x5000000)return p("DEVUP_ASSET_RESPONSE_TOO_LARGE",`${w} at scale ${v} is ${R.length} bytes; one answer carries 1 to 83886080`);let D=r(R);f("encode",Date.now()-C);let $=figma.io&&"function"==typeof figma.io.write,L="PNG"===w&&R.length>786432;if($&&(null!==M&&R.length>12288||L))return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:v,renderMode:y,usageAspectRatio:t.usageAspectRatio??null,maxWidth:t.maxWidth??null,srcset:!0===t.srcset,visibleScopeNodeId:t.visibleScopeNodeId??null,status:"chunked",byteLength:R.length,sha256:D,cursor:{nextOffset:0,maxChunkBytes:12288},errorCode:null,exportDiagnostics:d};return $&&figma.io.write(`devup-asset-${t.assetId.replace(/[^A-Za-z0-9_-]/g,"_")}.${w.toLowerCase()}`,R),{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:v,renderMode:y,usageAspectRatio:t.usageAspectRatio??null,maxWidth:t.maxWidth??null,srcset:!0===t.srcset,visibleScopeNodeId:t.visibleScopeNodeId??null,status:"exported",byteLength:R.length,sha256:D,mimeType:$?null===M?null:"image/svg+xml":({PNG:"image/png",JPG:"image/jpeg",SVG:"image/svg+xml",PDF:"application/pdf"})[w],...!$&&null===M?{data:figma.base64Encode(R)}:{},text:M,errorCode:null,exportDiagnostics:d}}catch(e){return p(String(e.message||e).includes("DEVUP_ASSET_TIMEOUT:")?"DEVUP_ASSET_TIMEOUT":g||["fill","original-source"].includes(y)?"DEVUP_ORIGINAL_IMAGE_READ_FAILED":"DEVUP_ASSET_EXPORT_FAILED",u(e))}},explore:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=e.explore,n=Math.max(20,Math.min(400,Number.isInteger(r.projectionLimit)?r.projectionLimit:200)),i=Math.max(0,Math.min(500,Number.isInteger(r.textPreviewLimit)?r.textPreviewLimit:160));function a(e){return"string"==typeof e?e.slice(0,240):""}let o=t,l="SECTION"===t.type?t:null;for(;o.parent&&"PAGE"!==o.parent.type;)o=o.parent,l||"SECTION"!==o.type||(l=o);let s="PAGE"===t.type?t:o.parent;if(!s||"PAGE"!==s.type)throw Error("DEVUP_PAGE_NOT_FOUND");function d(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function f(e){let t=d(e);return t&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&t.width/Math.max(1,t.height)>=.25&&t.width/Math.max(1,t.height)<=2.5}function c(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type&&t.length<12;){let e=a(r.name);e&&t.push(e),r=r.parent}return t.reverse()}await figma.setCurrentPageAsync(s);let u=!f(t)&&l?l:o,p=d(u)||d(t);if(!p)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let g="children"in s?s.children:[],y=g.map((e,t)=>({node:e,pageChildIndex:t,bounds:d(e)})).filter(e=>e.bounds).filter(e=>{var t;return e.node.id===u.id||e.bounds.y>=p.y-240&&e.bounds.y<=p.y+12e3&&(t=e.bounds,Math.min(t.x+t.width,p.x+p.width)-Math.max(t.x,p.x)>0)}).sort((e,t)=>e.bounds.y-t.bounds.y||e.bounds.x-t.bounds.x||e.node.id.localeCompare(t.node.id)),h="SECTION"===u.type?y.filter(e=>e.node.id===u.id):y.slice(0,n),m=new Map(h.map(e=>[e.node.id,e]));m.has(o.id)||m.set(o.id,{node:o,pageChildIndex:-1,bounds:d(o)}),m.has(u.id)||m.set(u.id,{node:u,pageChildIndex:-1,bounds:d(u)}),m.has(t.id)||m.set(t.id,{node:t,pageChildIndex:-1,bounds:d(t)});let b=t.parent;for(;b&&"PAGE"!==b.type&&(m.has(b.id)||m.set(b.id,{node:b,pageChildIndex:-1,bounds:d(b)}),b.id!==u.id);)b=b.parent;if("children"in u)for(let e of u.children.slice(0,n))m.has(e.id)||m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});let x="SECTION"===u.type&&"children"in u&&u.children.length>n;if("SECTION"===u.type&&"children"in u){let e=8*n,t=u.children.filter(e=>!f(e)).map(e=>({node:e,ancestors:[]})),r=0;for(;t.length&&r!m.has(e.id));if(m.size+t.length>n+2){x=!0;continue}for(let e of t)m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});continue}if("children"in e)for(let r of e.children)t.push({node:r,ancestors:[...i,e]})}x||=t.length>0}let E=[...m.values()].filter(e=>e.bounds).slice(0,n+2).map(({node:e,pageChildIndex:t,bounds:r})=>({id:e.id,type:e.type,fields:{name:a(e.name),parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:[],x:r.x,y:r.y,width:r.width,height:r.height,childCount:"children"in e?e.children.length:0,textPreview:"",pageChildIndex:t>=0?t:null,visible:!("visible"in e)||!1!==e.visible,breadcrumb:c(e)},extra:{},fieldErrors:{}})),A=E.reduce((e,t)=>{let r=t.fields;return e?{x:Math.min(e.x,r.x),y:Math.min(e.y,r.y),right:Math.max(e.right,r.x+r.width),bottom:Math.max(e.bottom,r.y+r.height)}:{x:r.x,y:r.y,right:r.x+r.width,bottom:r.y+r.height}},null)||{x:0,y:0,right:0,bottom:0},I="SECTION"!==u.type&&y.length>h.length||m.size>n+2||x,S={id:s.id,type:s.type,fields:{name:a(s.name),parentId:null,childrenIds:[],x:A.x,y:A.y,width:A.right-A.x,height:A.bottom-A.y,childCount:g.length,textPreview:"",projectionTruncated:I,visible:!0,breadcrumb:c(s),pageChildIndex:null},extra:{},fieldErrors:{}},w={fileKey:figma.fileKey||"",version:null,rootIds:[s.id],nodes:[S,...E.filter(e=>e.id!==s.id)],diagnostics:[]},v=new Set([s.id,o.id,u.id,t.id]);for(;JSON.stringify(w).length>14e3;){let e=w.nodes.length-1;for(;e>=0&&v.has(w.nodes[e].id);)e-=1;if(e<0)break;w.nodes.splice(e,1),S.fields.projectionTruncated=!0}if(JSON.stringify(w).length>14e3&&(w.nodes=w.nodes.filter(e=>v.has(e.id)).map(e=>({...e,fields:{...e.fields,name:a(e.fields.name).slice(0,80),textPreview:"",breadcrumb:e.fields.breadcrumb.slice(-4).map(e=>e.slice(0,80)),projectionTruncated:e.id===s.id||e.fields.projectionTruncated}}))),JSON.stringify(w).length>14e3)throw Error("DEVUP_EXPLORE_PROJECTION_TOO_LARGE");let N=14e3-JSON.stringify(w).length;if(i>0&&N>0){let e=w.nodes.filter(e=>m.get(e.id)&&e.id!==s.id),t=e.length;for(let r of e){let e=Math.floor(N/t);if(t-=1,e<=0)continue;let n="",a=0;for(let t of function(e){if(0===i)return"";let t=[],r=[e],n=0;for(;r.length&&n<80&&t.join(" ").lengthe)break;n+=t,a+=r}r.fields.textPreview=n,N-=a}}return w},fastSnapshot:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=e.rootIds;if(!Array.isArray(t)||0===t.length)throw Error("DEVUP_ROOTS_INVALID");let r=await Promise.all(t.map(e=>figma.getNodeByIdAsync(e)));if(r.some(e=>!e))throw Error("DEVUP_NODE_NOT_FOUND");if(1===r.length&&"SECTION"===r[0].type)throw Error("DEVUP_TARGET_IS_SECTION");let n=["mobile","tablet","desktop"],i=e=>{let t=String(e.name||"").trim().toLowerCase(),r=n.indexOf(t);if(r>=0)return r;let i=t.match(/^(\d+)\s*[~–]\s*(\d+)$/);if(!i)return -1;let a=Number(i[1]),o=Number(i[2]);return Number.isSafeInteger(a)&&Number.isSafeInteger(o)&&a>0&&o>=a&&o<=0xffffffff?1:-1};if(1===r.length&&i(r[0])>=0){let e=r[0].parent;if(e&&"SECTION"===e.type&&"children"in e){let t=e.children.filter(e=>e.id===r[0].id||i(e)>=0).filter(e=>!1!==e.visible);t.length>1&&(r.length=0,r.push(...t))}}if(1===t.length){let e=[],t=new Set(r.map(e=>e.id)),n=[],i=e=>{if("reactions"in e&&Array.isArray(e.reactions)){for(let r of e.reactions)if(r&&r.trigger&&"AFTER_TIMEOUT"===r.trigger.type)for(let e of r.actions||[])e&&"NODE"===e.type&&e.transition&&"SMART_ANIMATE"===e.transition.type&&"string"==typeof e.destinationId&&!t.has(e.destinationId)&&(t.add(e.destinationId),n.push(e.destinationId));if("children"in e)for(let t of e.children)i(t)}};for(let e of r)i(e);for(;n.length>0;){let t=n.shift(),r=await figma.getNodeByIdAsync(t);r&&"DOCUMENT"!==r.type&&"PAGE"!==r.type&&(e.push(r),i(r))}r.push(...e)}let a=e.nodeId,o=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],l=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],s=e.snapshot,d=Math.max(0,Math.floor(Number(s.offset)||0)),f=Math.max(8192,Math.floor(Number(s.maxEnvelopeBytes)||19456)),c=Math.min(s.maxEnvelopeBytes?f-1024:18e3,Math.max(4096,Math.floor(Number(s.maxPayloadBytes)||15e3))),u=new Set(["backgroundStyleId","effectStyleId","fillStyleId","gridStyleId","strokeStyleId","textStyleId"]),p=new Set(["maxWidth","maxHeight","absoluteRenderBounds"]),g=new Map([["rotation",0],["cornerRadius",0],["isAsset",!1],["isMask",!1],["clipsContent",!1],["blendMode","PASS_THROUGH"],["strokeAlign","INSIDE"],["textCase","ORIGINAL"],["textDecoration","NONE"],["textAlignHorizontal","LEFT"],["textAlignVertical","TOP"],["counterAxisAlignItems","MIN"],["primaryAxisAlignItems","MIN"],["gridColumnCount",0],["gridRowCount",0],["gridColumnGap",0],["gridRowGap",0],["gridColumnAnchorIndex",-1],["gridRowAnchorIndex",-1],["gridColumnSpan",1],["gridRowSpan",1]]),y=new Set(["start","end","characters","fontWeight","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]),h=new Set(["parent","children","consumers"]);function m(e,t=!1,r=new WeakSet,n=0){let i;if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>m(e,t,r,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(r.has(e))return{$circular:!0};if(r.add(e),t){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}i=[...t].sort().filter(e=>!e.startsWith("_")&&!h.has(e))}else i=Object.keys(e).sort();let a={};for(let o of i)try{let i=m(e[o],t,r,n+1);i&&"function"===i.$unsupported||(a[o]=i)}catch(e){a[o]=t?{$error:"unavailable"}:{$error:String(e&&e.message?e.message:e)}}return r.delete(e),a}let b=new Map;async function x(e,t,r){if("INSTANCE"!==e.type||"function"!=typeof e.getMainComponentAsync)return;b.has(e.id)||b.set(e.id,(async()=>{try{let t=await e.getMainComponentAsync();return{fields:{mainComponentId:t?t.id:null,mainComponentName:t?t.name:null,mainComponentSetId:t&&t.parent&&"COMPONENT_SET"===t.parent.type?t.parent.id:null}}}catch(e){return{error:String(e&&e.message?e.message:e)}}})());let n=await b.get(e.id);n.error?r.mainComponentId=n.error:Object.assign(t,n.fields)}async function E(e){let t={},r={};e.parent&&(t.parentId=e.parent.id),e.parent&&("PAGE"===e.parent.type||"SECTION"===e.parent.type||"COMPONENT_SET"===e.parent.type)&&(t.parentType=e.parent.type,"SECTION"===e.parent.type&&(t.parentName=e.parent.name));let n="children"in e?e.children.map(e=>e.id):[];for(let i of(n.length>0&&(t.childrenIds=n),o)){let n;if("componentPropertyDefinitions"!==i||"COMPONENT"!==e.type||!e.parent||"COMPONENT_SET"!==e.parent.type)try{if(!(i in e)){"overflowDirection"===i&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t[i]=null);continue}if(n=e[i],"function"==typeof n)continue;let r=m(n);if("overflowDirection"===i&&null==n){t[i]=null;continue}(null===r?!p.has(i):Array.isArray(r)?0===r.length:"object"==typeof r?0===Object.keys(r).length:!!(""===r&&u.has(i))||g.has(i)&&g.get(i)===r)||(t[i]=r)}catch(e){r[i]=String(e&&e.message?e.message:e)}}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{let r=m(e.getStyledTextSegments(l));if(1===r.length){let e=r[0];for(let t of Object.keys(e))y.has(t)||delete e[t]}r.length>0&&(t.styledTextSegments=r)}catch(e){r.styledTextSegments=String(e&&e.message?e.message:e)}await x(e,t,r);let i={id:e.id,type:e.type,fields:t};return Object.keys(r).length>0&&(i.fieldErrors=r),i}let A=[],I=[...r],S=new Set;for(let e=0;e=A.length&&A.length>0)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");function w(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1({id:e,styleType:t})).sort((e,t)=>e.id.localeCompare(t.id));async function a(e){try{let t=await figma.variables.getVariableByIdAsync(e);return t?{kind:"variable",value:m(t,!0),collectionId:t.variableCollectionId,aliasIds:Object.values(t.valuesByMode||{}).filter(e=>e&&"VARIABLE_ALIAS"===e.type&&"string"==typeof e.id&&e.id).map(e=>e.id)}:{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}}}}let o=await Promise.all([...n.map(a),...i.map(async({id:e,styleType:t})=>{try{let r=await figma.getStyleByIdAsync(e);if(!r)return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}};return{kind:"style",value:{...m(r,!0),styleType:t,value:m("PAINT"===t?r.paints:"EFFECT"===t?r.effects:"GRID"===t?r.layoutGrids:r,!0)}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}}}})]),l=new Set(n),s=o.flatMap(e=>e.aliasIds||[]);for(;s.length>0;){let e=[...new Set(s)].filter(e=>!l.has(e)).sort();for(let t of e)l.add(t);let t=await Promise.all(e.map(a));o.push(...t),s=t.flatMap(e=>e.aliasIds||[])}let d=[...new Set(o.filter(e=>"variable"===e.kind&&e.collectionId).map(e=>e.collectionId))].sort(),f=(await Promise.all(d.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?m(t,!0):null}catch(e){return null}}))).filter(e=>null!==e),c=o.filter(e=>"variable"===e.kind).map(e=>e.value),u=o.filter(e=>"style"===e.kind).map(e=>e.value),p=o.filter(e=>"unresolved"===e.kind).map(e=>e.value);return{collections:f,variables:c,styles:u,usedRemoteVariables:c.filter(e=>!0===e.remote),usedVariableIds:n,usedStyleIds:i.map(e=>e.id),localComplete:!1,usedRemoteComplete:0===p.length,unresolved:p,$variableRefCount:n.length,$styleRefCount:i.length}}async function N(e){let t=[],r=2;for(let n=d;ne)break;t.push(i),r+=a}return{pageNodes:t,packedBytes:r}}let O=c-1024,T=null;for(let e=0;e<5;e+=1){let{pageNodes:e,packedBytes:t}=await N(O);if(0===e.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let n=function(e,t){let n=Math.min(A.length,d+e.length),{$variableRefCount:i,$styleRefCount:o,...l}=t,s=[...e,{id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:d,nextOffset:n,complete:n>=A.length,totalNodes:A.length},extra:{},fieldErrors:{}}],f={kind:"devupFastSnapshotEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",rootId:a},snapshot:{fileKey:figma.fileKey||"",version:null,rootIds:r.map(e=>e.id),nodes:s,diagnostics:[]},resources:l,integrity:{nodeCount:s.length,variableRefCount:i,styleRefCount:o,utf8Bytes:0}},c=0;for(let e=0;e<8&&(c=w(JSON.stringify(f)),f.integrity.utf8Bytes!==c);e+=1)f.integrity.utf8Bytes=c;if(f.integrity.utf8Bytes!==w(JSON.stringify(f)))throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:f,bytes:c}}(e,await v(e));if(n.bytes<=f){T=n;break}if(1===e.length)throw Error("DEVUP_ENVELOPE_TOO_LARGE");O=Math.max(1,Math.min(O-1,t-(n.bytes-f)-256))}if(!T)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return T.envelope},fastTheme:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=Math.max(0,Math.floor(Number(e.theme.offset)||0));function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={};for(let a of function(e){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}return[...t].sort()}(e))if(!(a.startsWith("_")||["parent","children","consumers"].includes(a)))try{let o=r(e[a],t,n+1);o&&"function"===o.$unsupported||(i[a]=o)}catch(e){i[a]={$error:"unavailable"}}return t.delete(e),i}let n=new Set,i=new Map;for(let e of figma.root.children)await e.loadAsync();for(let e of[figma.root,...figma.root.findAll(()=>!0)])for(let t of["boundVariables","fills","strokes","effects","layoutGrids","textStyleId","fillStyleId","strokeStyleId","backgroundStyleId","effectStyleId","gridStyleId"])try{!function e(t,r="",a=new WeakSet,o=0){if(!(o>16)&&null!==t&&"object"==typeof t&&!a.has(t)){if(a.add(t),Array.isArray(t)){for(let n of t)e(n,r,a,o+1);return}for(let[l,s]of("VARIABLE_ALIAS"===t.type&&"string"==typeof t.id&&t.id&&"figma.mixed"!==t.id&&"MIXED"!==t.id&&n.add(t.id),Object.entries(t))){let t="textStyleId"===l?"TEXT":["fillStyleId","strokeStyleId","backgroundStyleId"].includes(l)?"PAINT":"effectStyleId"===l?"EFFECT":"gridStyleId"===l?"GRID":null;t&&"string"==typeof s&&s&&"figma.mixed"!==s&&"MIXED"!==s&&!i.has(s)&&i.set(s,t),e(s,l||r,a,o+1)}}}({[t]:e[t]})}catch(e){}let[a,o,l,s,d,f]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.variables.getLocalVariablesAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),c=new Set(o.map(e=>e.id)),u=new Set([...l,...s,...d,...f].map(e=>e.id)),p=[];async function g(e){try{return await figma.variables.getVariableByIdAsync(e)||null}catch(t){return p.push({id:e,kind:"variable",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}),null}}function y(e){return Object.values(e.valuesByMode||{}).filter(e=>e&&"VARIABLE_ALIAS"===e.type&&"string"==typeof e.id&&e.id).map(e=>e.id)}let h=[...i.entries()].filter(([e])=>!u.has(e)).sort(([e],[t])=>e.localeCompare(t)).map(async([e,t])=>{try{let r=await figma.getStyleByIdAsync(e);return r?{style:r,styleType:t}:null}catch(t){return p.push({id:e,kind:"style",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}),null}}),m=new Set,b=[],x=[...n,...o.flatMap(y)];for(;x.length>0;){let e=[...new Set(x)].filter(e=>!c.has(e)&&!m.has(e)).sort();for(let t of e)m.add(t);let t=(await Promise.all(e.map(g))).filter(Boolean);b.push(...t),x=t.flatMap(y)}let E=(await Promise.all(h)).filter(Boolean);for(let e of m)b.some(t=>t.id===e)||p.some(t=>t.id===e)||p.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"});for(let[e]of[...i.entries()].filter(([e])=>!u.has(e)))E.some(t=>t.style.id===e)||p.some(t=>t.id===e)||p.push({id:e,kind:"style",reason:"notFoundOrUnavailable"});let A=[...new Set(b.map(e=>e.variableCollectionId))].filter(e=>!a.some(t=>t.id===e)).sort(),I=(await Promise.all(A.map(async e=>{try{return await figma.variables.getVariableCollectionByIdAsync(e)}catch(e){return null}}))).filter(Boolean);function S(e,t){return{...r(e),styleType:t,value:r("PAINT"===t?e.paints:"EFFECT"===t?e.effects:"GRID"===t?e.layoutGrids:e)}}let w=[...l.map(e=>S(e,"PAINT")),...s.map(e=>S(e,"TEXT")),...d.map(e=>S(e,"EFFECT")),...f.map(e=>S(e,"GRID")),...E.map(({style:e,styleType:t})=>S(e,t))].sort((e,t)=>e.id.localeCompare(t.id)),v=[...o,...b].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id)),N=[...a,...I].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id));function O(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}p.sort((e,t)=>e.kind.localeCompare(t.kind)||e.id.localeCompare(t.id));let T=[...N.map(e=>({kind:"collection",value:e})),...v.map(e=>({kind:"variable",value:e})),...w.map(e=>({kind:"style",value:e})),...[...n].sort().map(e=>({kind:"usedVariableId",value:e})),...[...i.keys()].sort().map(e=>({kind:"usedStyleId",value:e})),...p.map(e=>({kind:"unresolved",value:e}))];if(t>T.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let P=T.map(e=>O(JSON.stringify(e.value)).length+1),_=18432,k=null;for(let e=0;e<6;e+=1){let{pageItems:e,packed:r}=function(e){let r=[],n=0;for(let i=t;i0)||!(n+P[i]>e));i+=1)r.push(T[i]),n+=P[i];return{pageItems:r,packed:n}}(_),n=function(e,r){let n=t=>e.filter(e=>e.kind===t).map(e=>e.value),i=n("collection"),a=n("variable"),o=n("style"),l=n("unresolved"),s={kind:"devupFastThemeEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",version:null},resources:{collections:i,variables:a,styles:o,usedRemoteVariables:[],usedVariableIds:n("usedVariableId"),usedStyleIds:n("usedStyleId"),localComplete:!0,usedRemoteComplete:0===p.length,unresolved:l},page:{offset:t,nextOffset:r,complete:r>=T.length,totalItems:T.length},integrity:{collectionCount:i.length,variableCount:a.length,styleCount:o.length,unresolvedCount:l.length,utf8Bytes:0}},d=new Uint8Array;for(let e=0;e<8&&(d=O(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length);e+=1)s.integrity.utf8Bytes=d.length;if(d=O(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length)throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:s,bytes:d.length}}(e,t+e.length);if(n.bytes<=19456){k=n;break}if(e.length<=1)break;_=Math.max(1,Math.min(_-1,r-(n.bytes-19456)-256))}if(null===k||k.bytes>8388608)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return k.envelope},largeValue:async function(e){let t;figma.skipInvisibleInstanceChildren=!1;let r=e.largeValue;async function n(e){try{let t=await figma.getNodeByIdAsync(e);if(t)return t}catch(r){if(!e.startsWith("I")||!e.includes(";"))throw r;try{return await t()}catch(e){throw Error(`${r.message||r}; nested lookup fallback: ${e.message||e}`)}}if(!e.startsWith("I")||!e.includes(";"))return null;return t();async function t(){let t=await figma.getNodeByIdAsync(e.slice(1).split(";")[0]),r=t?[t]:[],n=new Set;for(;r.length;){let t=r.pop();if(t.id===e)return t;if(!n.has(t.id)){if(n.add(t.id),n.size>1e4)throw Error("nested lookup exceeds 10000 nodes");Array.isArray(t.children)&&r.push(...t.children)}}throw Error(`nested node ${e} not found in its instance`)}}async function i(e,t,r=85e3,n=()=>{}){let a,o=Date.now();try{let i=await Promise.race([e.exportAsync(t),new Promise((e,t)=>{a=setTimeout(()=>t(Error(`DEVUP_ASSET_TIMEOUT: native export exceeded ${r} ms`)),r)})]);return n("native-export",Date.now()-o),i}catch(i){if(n("native-export",Date.now()-o,String(i.message||i).slice(0,500)),"SVG_STRING"!==t.format)throw i;let r=Date.now();try{let t=function(e){let t=e=>"number"==typeof e&&Number.isFinite(e),r=(e,t)=>{throw Error(`geometry fallback unsupported: ${e.type||"unknown"} ${e.id||""}: ${t}`)},n=0,i=0;function a(e,i,a){(void 0!==a&&!Array.isArray(a)||void 0===a&&Array.isArray(i)&&i.length)&&r(e,"unresolved paint requires native export");let o=Array.isArray(a)?a.filter(e=>!1!==e.visible):[];return o.length?(Array.isArray(i)&&i.length||r(e,"paint has no rendered geometry"),o.map(a=>{("SOLID"!==a.type||a.blendMode&&"NORMAL"!==a.blendMode)&&r(e,"paint type or blending requires native export");let o=a.color;o&&[o.r,o.g,o.b].every(e=>t(e)&&e>=0&&e<=1)||r(e,"invalid solid color");let l=void 0===a.opacity?1:a.opacity;return(!t(l)||l<0||l>1)&&r(e,"invalid paint opacity"),["NONZERO","EVENODD"].map(t=>{let a=i.filter(e=>e.windingRule===t);if(!a.length)return"";for(let t of a)"string"==typeof t.data&&t.data.trim()||r(e,"empty path"),n++;return`"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e])}" fill="rgb(${100*o.r}%,${100*o.g}%,${100*o.b}%)" fill-opacity="${l}" fill-rule="${"EVENODD"===t?"evenodd":"nonzero"}"/>`}).join("")}).join("")):""}let o=e.absoluteRenderBounds,l=e.absoluteTransform;(!o||![o.x,o.y,o.width,o.height].every(t)||o.width<=0||o.height<=0)&&r(e,"missing render bounds"),(!Array.isArray(l)||2!==l.length||!l.every(e=>Array.isArray(e)&&3===e.length&&e.every(t))||0!==l[0][1]||0!==l[1][0]||l[0][0]<=0||l[1][1]<=0)&&r(e,"root rotation, reflection or shear requires native export");let s=(o.x-l[0][2])/l[0][0],d=(o.y-l[1][2])/l[1][1],f=o.width/l[0][0],c=o.height/l[1][1],u=function e(n,o){if(++i>1e4&&r(n,"subtree exceeds 10000 nodes"),n.isMask&&r(n,"mask requires native export"),!1===n.visible||0===n.opacity)return"";n.clipsContent&&r(n,"clipping requires native export"),Array.isArray(n.effects)&&n.effects.some(e=>!1!==e.visible)&&r(n,"effect requires native export"),n.blendMode&&!["NORMAL","PASS_THROUGH"].includes(n.blendMode)&&r(n,"layer blending requires native export");let l=void 0===n.opacity?1:n.opacity;(!t(l)||l<0||l>1)&&r(n,"invalid layer opacity");let s="";if(!o){let e=n.relativeTransform;Array.isArray(e)&&2===e.length&&e.every(e=>Array.isArray(e)&&3===e.length&&e.every(t))||r(n,"missing relative transform"),s=` transform="matrix(${e[0][0]} ${e[1][0]} ${e[0][1]} ${e[1][1]} ${e[0][2]} ${e[1][2]})"`}let d="";if(["GROUP","FRAME","INSTANCE","COMPONENT"].includes(n.type))(Array.isArray(n.fills)&&n.fills.some(e=>!1!==e.visible)||Array.isArray(n.strokes)&&n.strokes.some(e=>!1!==e.visible))&&r(n,"container paint requires native export"),Array.isArray(n.children)||r(n,"missing children"),d=(!0===n.itemReverseZIndex?[...n.children].reverse():n.children).map(t=>e(t,!1)).join("");else{for(let e of(["VECTOR","BOOLEAN_OPERATION","RECTANGLE","ELLIPSE","POLYGON","STAR","LINE"].includes(n.type)||r(n,"node type requires native export"),Array.isArray(n.strokes)&&n.strokes.some(e=>!1!==e.visible)&&"CENTER"!==n.strokeAlign&&r(n,"non-center stroke alignment requires native export"),Array.isArray(n.dashPattern)&&n.dashPattern.length&&r(n,"dashed stroke requires native export"),[n.fillGeometry,n.strokeGeometry]))void 0!==e&&(!Array.isArray(e)||e.some(e=>!["NONZERO","EVENODD"].includes(e.windingRule)))&&r(n,"unknown winding rule");d=a(n,n.fillGeometry,n.fills)+a(n,n.strokeGeometry,n.strokes)}return`${d}`}(e,!0);return n||r(e,"no painted geometry"),`${u}`}(e);return n("geometry-fallback",Date.now()-r),t}catch(e){throw Error(`native export: ${i.message||i}; ${e.message||e}`)}}finally{clearTimeout(a)}}let a=await n(r.nodeId);if(!a)throw Error("DEVUP_NODE_NOT_FOUND");function o(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}let l=null;try{if("$export:svg"===r.field){if("function"!=typeof a.exportAsync)throw Error("unsupported");let e=await i(a,{format:"SVG_STRING"},Math.max(1,(Number(r.timeoutMs)||9e4)-5e3));if("string"!=typeof e)throw Error("unsupported");l=o(e)}else if("string"==typeof r.field&&r.field.startsWith("$export:png")){if("function"!=typeof a.exportAsync)throw Error("unsupported");let e=Number(r.field.split("@")[1]||2);if(!Number.isFinite(e)||e<=0||e>4)throw Error("invalid scale");let t=r.field.split("@"),n=a;if("visible"===t[2]){let e=new Set;for(;n&&n.id!==t[3]&&!e.has(n)&&e.size<64;)e.add(n),n=n.parent;if(!n||n.id!==t[3]||["PAGE","DOCUMENT"].includes(n.type))throw Error("invalid visible scope")}else if(t.length>2)throw Error("invalid export field");let i=await n.exportAsync({format:"PNG",contentsOnly:!0,useAbsoluteBounds:!1,constraint:{type:"SCALE",value:e}});l=i instanceof Uint8Array?i:new Uint8Array(i)}else if("styledTextSegments"===r.field&&"TEXT"===a.type&&"function"==typeof a.getStyledTextSegments)t=a.getStyledTextSegments(["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]);else if(r.field in a)t=a[r.field];else throw Error("unsupported")}catch(e){return{kind:"devupLargeValueUnsupported",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,byteLength:r.byteLength,sha256:r.sha256,errorCode:"DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM",errorDetail:`${r.field} failed: ${e.message||e}`.slice(0,500)}}let s=null===l?o(JSON.stringify(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let o=e(t[a],r,n+1);o&&"function"===o.$unsupported||(i[a]=o)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(t))):l,d=function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=e.length,n=64*Math.ceil((r+9)/64),i=new Uint8Array(n);i.set(e),i[r]=128;let a=8*r;for(let e=0;e<8;e+=1)i[n-1-e]=255&Math.floor(a/2**(8*e));let o=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[n,a,s,d,f,c,u,p]=o;for(let e=0;e<64;e+=1){let i=p+(l(f,6)^l(f,11)^l(f,25))+(f&c^~f&u)+t[e]+r[e]>>>0,o=(l(n,2)^l(n,13)^l(n,22))+(n&a^n&s^a&s)>>>0;p=u,u=c,c=f,f=d+i>>>0,d=s,s=a,a=n,n=i+o>>>0}for(let[e,t]of[n,a,s,d,f,c,u,p].entries())o[e]=o[e]+t>>>0}return o.map(e=>e.toString(16).padStart(8,"0")).join("")}(s);if(s.length!==r.byteLength||d!==r.sha256)throw Error("DEVUP_LARGE_VALUE_CHANGED");let f=Math.max(0,Math.floor(Number(r.offset)||0)),c=Math.min(65536,Math.max(1,Math.floor(Number(r.maxChunkBytes)||8192)));if(f>=s.length)throw Error("DEVUP_LARGE_VALUE_RANGE_INVALID");let u=Math.min(s.length,f+c);return{kind:"devupLargeValueFragment",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,offset:f,nextOffset:u,byteLength:s.length,sha256:d,dataBase64:function(e){let t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",r="";for(let n=0;n>2],r+=t[(3&i)<<4|a>>4],r+=n+1>6]:"=",r+=n+20;){let e=t[t.length-1],i=e.node,a="children"in i?i.children:[];if(!e.visited){e.visited=!0;for(let e=a.length-1;e>=0;e-=1)t.push({node:a[e],visited:!1});continue}t.pop();let o=0,l=[];for(let e of a)l.push(e.id),o+=1+(r.get(e.id)||0);r.set(i.id,o),n.push({id:i.id,type:i.type,name:i.name,childrenIds:l,descendantCount:o})}}(t),{fileKey:figma.fileKey||"",version:null,rootId:t.id,nodes:n}},pageCatalog:async function(e){let t=figma.root.children;return{fileKey:figma.fileKey||"",version:null,rootIds:t.map(e=>e.id),nodes:t.map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:null,childrenIds:[]},extra:{},fieldErrors:{}})),diagnostics:[]}},referencePng:async function(e){let t,r=e.nodeId;globalThis.__devupReferencePngAnswers||(globalThis.__devupReferencePngAnswers=new Map);let n=globalThis.__devupReferencePngAnswers,i=0;for(let[e,t]of n)Date.now()-t.at>12e4?n.delete(e):i+=t.text.length;if(i>=0xc000000)throw Error(`DEVUP_REFERENCE_PNG_BUSY ${i} characters are held for other reads`);let a=await figma.getNodeByIdAsync(r);if(!a||"function"!=typeof a.exportAsync)throw Error(`DEVUP_NODE_NOT_FOUND ${r}`);let o=a.absoluteRenderBounds||a.absoluteBoundingBox,l=Math.max(o.width,o.height),s=l>8192?8192/l:1,d=(e,t)=>({format:"PNG",constraint:t&&s<1?{type:o.width>=o.height?"WIDTH":"HEIGHT",value:8192}:{type:"SCALE",value:s},contentsOnly:e,useAbsoluteBounds:!1,colorProfile:"SRGB"}),f=e=>(Array.isArray(e)?e:[]).filter(e=>e&&!1!==e.visible),c=(e,t)=>!!e&&!!t&&e.x!!e&&e.x+t<=o.x&&e.y+t<=o.y&&e.x+e.width-t>=o.x+o.width&&e.y+e.height-t>=o.y+o.height,p=e=>`${e.type} ${JSON.stringify(e.name)}`,g=e=>JSON.parse(JSON.stringify(e===figma.mixed?null:e)),y=e=>(void 0===e.opacity||1===e.opacity)&&(!e.blendMode||["PASS_THROUGH","NORMAL"].includes(e.blendMode)),h=[],m=a.parent;for(;m&&"PAGE"!==m.type;)h.unshift(m),m=m.parent;let b=[255,255,255];for(let e of f(m&&m.backgrounds)){if("SOLID"!==e.type)continue;let t=void 0===e.opacity?1:e.opacity;["r","g","b"].forEach((r,n)=>{b[n]=t*e.color[r]*255+(1-t)*b[n]})}let x=0,E=e=>!1!==e.visible&&!e.isMask&&!!c(e.absoluteRenderBounds,o)&&((x+=1)>5e3||"TEXT"===e.type||!!f(e.fills).length||!!f(e.strokes).length||!!f(e.effects).length||"children"in e&&e.children.some(E)),A=e=>y(e)&&u(e.absoluteBoundingBox,0)&&1e-9>Math.abs(e.absoluteTransform[0][1])&&1e-9>Math.abs(e.absoluteTransform[1][0])&&0===Math.max(0,...[e.topLeftRadius,e.topRightRadius,e.bottomRightRadius,e.bottomLeftRadius].filter(e=>"number"==typeof e))&&f(e.fills).some(e=>"NORMAL"===(e.blendMode||"NORMAL")&&(void 0===e.opacity||1===e.opacity)&&("SOLID"===e.type||e.type.startsWith("GRADIENT_")&&e.gradientStops.every(e=>1===e.color.a))),I=[...h,a],S=[],w=[],v=new Map;for(let e=0;eA(e)&&i.slice(0,t).every(y));r.forEach((e,r)=>{e===t||(e.isMask&&!1!==e.visible&&r{let t=await a.exportAsync(d(!1,!0));return{kind:"devupReferencePng",nodeId:r,mimeType:"image/png",byteLength:t.length,data:figma.base64Encode(t),backdrop:N,...e}},T=(e,t)=>{let r=[...new Set(e)];return{approximate:r.join("; "),note:`Drawn as the canvas shows this area: devup-mcp cannot yet compose what get_screenshot draws here (${r.slice(0,3).join("; ")}). Layers reaching into it may show here: ${t}; get_screenshot leaves other layers out.`}},P=e=>{let t=JSON.stringify(e).replace(/[\u0080-\uffff]/g,e=>`\\u${e.charCodeAt(0).toString(16).padStart(4,"0")}`);if(t.length>0x5a00000){if(e.compose){let{compose:r,reaching:n,...i}=e;return P({...i,...T([`the answer would be ${t.length} characters`],n)})}throw Error(`DEVUP_REFERENCE_PNG_TOO_LARGE ${t.length} characters, over the 94371840 one answer is read back up to`)}if(t.length<=6291456)return e;let i=`${r}@${Date.now()}.${Math.random().toString(36).slice(2,10)}`;return n.set(i,{text:t,at:Date.now()}),{kind:"devupReferencePngChunked",nodeId:r,id:i,length:t.length}};if(0===S.length)return P(await O({}));let _=I.some(e=>e.isMask),k=[],C=new Set,M=(e,t)=>{let r=p(e);for(let t of f(e.fills))"IMAGE"===t.type?((t.rotation||f([t.filters]).some(e=>Object.values(e).some(e=>e)))&&k.push(`${r} has an image fill with a rotation or filters`),C.add(t.imageHash)):"SOLID"===t.type||t.type.startsWith("GRADIENT_")||k.push(`${r} is painted with ${t.type}`);for(let t of f(e.effects))["LAYER_BLUR","DROP_SHADOW","INNER_SHADOW","BACKGROUND_BLUR"].includes(t.type)||k.push(`${r} has a ${t.type}`);let n=f(e.strokes);if((e=>{let t=0===f(e.strokes).length?[]:[e.strokeWeight,e.strokeTopWeight,e.strokeRightWeight,e.strokeBottomWeight,e.strokeLeftWeight].filter(e=>"number"==typeof e&&e>0);if(0===t.length)return!1;let r=Math.max(...t),n="OUTSIDE"===e.strokeAlign?0:"CENTER"===e.strokeAlign?r/2:r,i="INSIDE"===e.strokeAlign?0:"CENTER"===e.strokeAlign?r/2:r,a=e.absoluteBoundingBox;return c(a&&{x:a.x-i,y:a.y-i,width:a.width+2*i,height:a.height+2*i},o)&&!u(a,n)})(e)){let t=["FRAME","RECTANGLE","COMPONENT","COMPONENT_SET","INSTANCE","SECTION"].includes(e.type),i=n.every(e=>"SOLID"===e.type||e.type.startsWith("GRADIENT_")),a=Array.isArray(e.dashPattern)&&e.dashPattern.length>0,o="INSIDE"===e.strokeAlign||!e.strokeJoin||"MITER"===e.strokeJoin;t&&i&&!a&&o||k.push(`${r}'s stroke reaches it`)}return{transform:g(e.absoluteTransform),width:e.width,height:e.height,radii:[e.topLeftRadius,e.topRightRadius,e.bottomRightRadius,e.bottomLeftRadius].map(e=>"number"==typeof e?e:0),fills:g(f(e.fills)),opacity:void 0===e.opacity?1:e.opacity,blendMode:e.blendMode||null,clips:!!e.clipsContent,effects:g(f(e.effects)),strokes:g(n),strokeAlign:e.strokeAlign||null,strokeWeights:["strokeTopWeight","strokeRightWeight","strokeBottomWeight","strokeLeftWeight"].map(t=>"number"==typeof e[t]?e[t]:"number"==typeof e.strokeWeight?e.strokeWeight:0)}},R=async e=>{let t=await e.exportAsync(d(!0,e===a));return{data:figma.base64Encode(t),area:e.absoluteRenderBounds||e.absoluteBoundingBox,blendMode:e.blendMode||null,size:[16,20].map(e=>(t[e]<<24|t[e+1]<<16|t[e+2]<<8|t[e+3])>>>0)}},D=async e=>{let t=v.get(e);if(!t)return null;let r=await t.exportAsync({...d(!0,!1),useAbsoluteBounds:!0});return{data:figma.base64Encode(r),area:t.absoluteBoundingBox,luminance:"LUMINANCE"===t.maskType}},$=[],L={},U={};try{for(let e of h){let t=M(e,!1),r=await D(e);r&&(t.mask=r),$.push(t)}let e=e=>f(e.effects).some(e=>"BACKGROUND_BLUR"===e.type);if(w.length>0&&(e(a)||"findOne"in a&&a.findOne(e))){let r="children"in a&&a.children.some(t=>e(t)||"findOne"in t&&!!t.findOne(e));if("children"in a&&!r&&!f(a.strokes).length){for(let e of(L.own=M(a,!0),L.children=[],a.children)){if(!1===e.visible||e.isMask||!e.absoluteRenderBounds)continue;let t=await R(e);L.children.push({data:t.data,area:t.area,blendMode:t.blendMode})}t=[Math.ceil(o.width*s-1e-6),Math.ceil(o.height*s-1e-6)]}else k.push(`${p(a)} blurs what lies behind it`)}else if(_)t=[Math.ceil(o.width*s-1e-6),Math.ceil(o.height*s-1e-6)];else{let e=await R(a);L.picture={data:e.data,area:e.area,blendMode:e.blendMode},t=e.size}let r=await D(a);r&&(L.mask=r),t&&t[0]*t[1]>8e6&&k.push(`the area is ${t[0]}x${t[1]} pixels`);let n=globalThis.__devupNormalizeImage,i=Date.now()+2e4,l=0;for(let e of C){let t=e&&figma.getImageByHash(e),r=t&&await t.getBytesAsync();if(!r){k.push(`the image ${e} could not be read`);continue}let a=null;if("function"==typeof n){let{width:e,height:o}=await t.getSizeAsync(),l=(...e)=>e.every((e,t)=>r[t]===e),s=l(137,80,78,71)||l(255,216)||l(71,73,70),d=Math.min(1,8192/Math.max(e,o,1)),f=Math.ceil(e*d)*Math.ceil(o*d),c=5*f+65536,u=5*r.length+4*e*o*(d<1?2:1)+4*f+4*c+1048576,p=i-Date.now();if((!s||Math.max(e,o)>8192||r.length>4*e*o+1048576)&&u<=0x20000000&&p>0){let t=await n(r,8192,p,e,o);t&&(r=t,a={width:e,height:o})}}if(l+r.length>0x4000000){k.push("its ancestors' images are over 67108864 bytes");break}l+=r.length,U[e]=a?{data:figma.base64Encode(r),...a}:figma.base64Encode(r)}}catch(e){k.push(`a part could not be exported: ${e instanceof Error?e.message:String(e)}`)}let B=`${S.slice(0,5).join(", ")}${S.length>5?` and ${S.length-5} more`:""}`;return k.length>0?P(await O(T(k,B))):P(await O({reaching:B,compose:{area:o,scale:s,width:t[0],height:t[1],page:b,layers:$,node:L,images:U}}))},referencePngChunk:async function(e){let t=e.largeValue,r=globalThis.__devupReferencePngAnswers,n=r&&r.get(t.id);if(!n||"string"!=typeof n.text)throw Error(`DEVUP_REFERENCE_PNG_ANSWER_GONE ${t.id}`);let i=n.text,a=Math.max(0,Math.floor(Number(t.offset)||0)),o=Math.min(i.length,a+4194304);return o>=i.length&&r.delete(t.id),{kind:"devupReferencePngChunk",id:t.id,offset:a,nextOffset:o,complete:o>=i.length,text:i.slice(a,o)}},search:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=t;for(;r&&"PAGE"!==r.type;)r=r.parent;if(!r||"PAGE"!==r.type)throw Error("DEVUP_PAGE_NOT_FOUND");await figma.setCurrentPageAsync(r);let n=e.search,i=new Set((n.nodeTypes.length?n.nodeTypes:["PAGE","SECTION","FRAME","COMPONENT_SET","COMPONENT"]).map(e=>e.toUpperCase()));function a(e){return e.normalize("NFC").toLocaleLowerCase().replace(/\s/gu,"")}let o=t===r?r.findAll(()=>!0):"findAll"in t?t.findAll(()=>!0):[],l=[t,...o].filter(e=>i.has(e.type)&&"string"==typeof e.name).map(e=>({node:e,score:function(e){if(e===n.query)return 400;if("exact"===n.matchKind)return null;let t=a(e),r=a(n.query);if(t===r)return 300;if(t.startsWith(r))return 200;if(t.includes(r))return 100;if("fuzzy"!==n.matchKind)return null;let i=function(e,t){let r=Array.from({length:t.length+1},(e,t)=>t);for(let n=0;nnull!==e.score).sort((e,t)=>t.score-e.score||e.node.name.localeCompare(t.node.name)||e.node.id.localeCompare(t.node.id)).slice(0,n.limit),s=new Map([[r.id,r]]);for(let e of[t,...l.map(e=>e.node)]){let t=e;for(;t&&"DOCUMENT"!==t.type;)s.set(t.id,t),t=t.parent}return{fileKey:figma.fileKey||"",version:null,rootIds:[r.id],nodes:[...s.values()].map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:"children"in e?e.children.map(e=>e.id):[]},extra:{},fieldErrors:{}})),diagnostics:[]}},sectionIndex:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");if("SECTION"!==t.type){let e=t.parent,r=new Set;for(;e&&"SECTION"!==e.type&&!r.has(e.id);)r.add(e.id),e=e.parent;let n=e&&"SECTION"===e.type?e.id:null,i=n&&figma.fileKey?`https://www.figma.com/design/${figma.fileKey}?node-id=${n.replace(/:/g,"-")}`:null;throw Error("DEVUP_SECTION_REQUIRED "+JSON.stringify({pluginCode:"DEVUP_SECTION_REQUIRED",stage:"section-index",nodeId:t.id,nodeType:t.type,sectionId:n,nextAction:{tool:"devup_figma_export",how:n?`The url must point to a SECTION. This capture's SECTION is ${n}; select frames with frameIds.`:"The url must point to a SECTION. This node has no ancestor SECTION; choose the intended SECTION in Figma and copy its link.",arguments:i?"FRAME"===t.type&&t.parent===e?{url:i,frameIds:[t.id]}:{url:i}:null,requiredArguments:i?[]:["url"]}}))}let r=2048;function n(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function i(e,t){if("FRAME"!==e.type||!1===e.visible||!t)return!1;let r=t.width/Math.max(1,t.height);return t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&r>=.25&&r<=2.5}function a(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1[e.id,t.id])),s=[],d=0;for(let e=0;ee.id));for(let r of t.children){if(e.has(r.id))continue;let t=n(r);t&&!1!==r.visible&&s.push({node:r,box:t})}}s.sort((e,t)=>e.box.y-t.box.y||e.box.x-t.box.x||e.node.id.localeCompare(t.node.id));let f=o.length>d||s.length>100,c=s.slice(0,100),u=new Set(c.map(({node:e})=>e.id)),p=new Set(e.rootIds),g={},y=new Map([[t.id,null]]);for(let e=0;e{let r=e.parent;for(;r&&r.id!==t.id&&!u.has(r.id);)r=r.parent;return[e.id,r&&u.has(r.id)?r.id:t.id]})),m=e=>c.filter(({node:t})=>h.get(t.id)===e).map(({node:e})=>e.id),b=n(t);if(!b)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let x={id:t.id,type:t.type,fields:{name:t.name,parentId:t.parent&&"DOCUMENT"!==t.parent.type?t.parent.id:null,childrenIds:m(t.id),absoluteBoundingBox:b,visible:!1!==t.visible,projectionTruncated:f,nodeScreenIds:g},extra:{},fieldErrors:{}},E=c.length,A=c.map(({node:e,box:t})=>{let n=function(e){let t=[e],r=0,n=0;for(let e=0;er}}(e),o=Math.floor(r/E);E-=1;let l=function(e,t){let r=[e],n="",i=0,o=0,l=!1;if(t<=0)return{text:"",state:"budget-exhausted",spent:0};for(let e=0;e=120||o+e>t)return{text:n.trimEnd(),state:o+e>t?"budget-exhausted":"truncated",spent:o};n+=r,i+=1,o+=e}}if("children"in s){let e=Math.max(0,64-r.length);s.children.length>e&&(l=!0),r.push(...s.children.slice(0,e))}}}return{text:n,state:l?"truncated":n?"available":"no-text",spent:o}}(e,o);return r-=l.spent,{id:e.id,type:e.type,fields:{name:"string"==typeof e.name?e.name:"",parentId:h.get(e.id),childrenIds:m(e.id),absoluteBoundingBox:t,visible:!1!==e.visible,breadcrumb:function(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type;)"string"==typeof r.name&&r.name&&t.push(r.name),r=r.parent;return t.reverse()}(e),directChildCount:"children"in e?e.children.length:0,textPreview:l.text,textPreviewState:l.state,subtreeNodeCount:n.subtreeNodeCount,estimatedSerializedBytes:n.estimatedSerializedBytes,selectionReasons:[i(e,t)?"screen-like":"explicit-selection-only","inside-section"],estimateTruncated:n.truncated},extra:{},fieldErrors:{}}}),I={fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:[x,...A],diagnostics:[]},S=a(JSON.stringify(I));for(let e=A.length-1;e>=0&&S>19456;e-=1)A[e].fields.textPreview="",A[e].fields.textPreviewState="budget-exhausted",S=a(JSON.stringify(I));let w=()=>I.nodes.length-1,v=A.length;for(;S>19456&&w()>1;){let e=I.nodes.pop();x.fields.projectionTruncated=!0,x.fields.childrenIds=x.fields.childrenIds.filter(t=>t!==e.id),S=a(JSON.stringify(I))}if(S>19456)throw Error("DEVUP_SECTION_INDEX_TOO_LARGE "+JSON.stringify({pluginCode:"DEVUP_SECTION_INDEX_TOO_LARGE",stage:"section-index",responseBytes:S,maxResponseBytes:19456,discoveredCandidates:v,retainedCandidates:w()}));return I},snapshot:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],n=new Set(r),i=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],a=e.snapshot,o=Math.max(0,Math.floor(Number(a.offset)||0)),l=Math.min(a.maxEnvelopeBytes?Math.max(8192,Math.floor(Number(a.maxEnvelopeBytes))):16e3,Math.max(4096,Math.floor(Number(a.maxPayloadBytes)||15e3))),s=Math.min(l-1024,Math.max(512,Math.floor(Number(a.maxFieldBytes)||4096))),d=new Set(["id","type","parent","children","mainComponent","masterComponent","instances"]),f=new Set(["parentId","childrenIds","name","characters","styledTextSegments","boundVariables"]);function c(e,t,r){let n=function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(JSON.stringify(r));return n.length>0x1000000?{$truncated:"max-large-value-bytes",byteLength:n.length}:{$largeValue:{nodeId:e,field:t,byteLength:n.length,sha256:function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],o=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=o(r[e-2],17)^o(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,l,s,d,f,c,u,p]=a;for(let e=0;e<64;e+=1){let n=p+(o(f,6)^o(f,11)^o(f,25))+(f&c^~f&u)+t[e]+r[e]>>>0,a=(o(i,2)^o(i,13)^o(i,22))+(i&l^i&s^l&s)>>>0;p=u,u=c,c=f,f=d+n>>>0,d=s,s=l,l=i,i=n+a>>>0}for(let[e,t]of[i,l,s,d,f,c,u,p].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}(n),cursor:{nextOffset:0,maxChunkBytes:12288}}}}function u(e){return function(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+112)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let o=e(t[a],r,n+1);o&&"function"===o.$unsupported||(i[a]=o)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(r),a=u(i);if(a<=s)return i;let o=c(e,t,i);return o.$truncated&&(n[t]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${a}>16777216`),o}let g=new Map;async function y(e,t,r){if("INSTANCE"!==e.type||"function"!=typeof e.getMainComponentAsync)return;g.has(e.id)||g.set(e.id,(async()=>{try{let t=await e.getMainComponentAsync();return{fields:{mainComponentId:t?t.id:null,mainComponentName:t?t.name:null,mainComponentSetId:t&&t.parent&&"COMPONENT_SET"===t.parent.type?t.parent.id:null}}}catch(e){return{error:String(e&&e.message?e.message:e)}}})());let n=await g.get(e.id);n.error?r.mainComponentId=n.error:Object.assign(t,n.fields)}async function h(e){let t={},a={},o={};for(let i of(t.parentId=e.parent?e.parent.id:null,e.parent&&("PAGE"===e.parent.type||"SECTION"===e.parent.type||"COMPONENT_SET"===e.parent.type)&&(t.parentType=e.parent.type,"SECTION"===e.parent.type&&(t.parentName=e.parent.name)),t.childrenIds="children"in e?e.children.map(e=>e.id):[],!("overflowDirection"in e)&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t.overflowDirection=null),function(e){let t=new Set,n=e;for(;n&&n!==Object.prototype;){for(let e of Object.getOwnPropertyNames(n))t.add(e);n=Object.getPrototypeOf(n)}for(let n of r)try{n in e&&t.add(n)}catch(e){}return[...t].sort()}(e)))if(!(d.has(i)||i.startsWith("_")||"componentPropertyDefinitions"===i&&"COMPONENT"===e.type&&e.parent&&"COMPONENT_SET"===e.parent.type))try{let r=e[i];if("function"==typeof r)continue;let l=p(e.id,i,r,o);(n.has(i)?t:a)[i]=l}catch(e){o[i]=String(e&&e.message?e.message:e)}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{t.styledTextSegments=p(e.id,"styledTextSegments",e.getStyledTextSegments(i),o)}catch(e){o.styledTextSegments=String(e&&e.message?e.message:e)}return await y(e,t,o),{id:e.id,type:e.type,fields:t,extra:a,fieldErrors:o}}let m=[],b=[t];for(;b.length;){let e=b.shift();m.push(e),"children"in e&&b.push(...e.children)}let x=[],E=l-1024,A=2;for(let e=o;eNumber(e.protected)-Number(t.protected)||t.byteLength-e.byteLength),n)){if(r<=t)break;let n=c(e.id,i.name,e[i.sectionName][i.name]);e[i.sectionName][i.name]=n,n.$truncated&&(e.fieldErrors[i.name]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${i.byteLength}>16777216`),r=u(e)}return e}(await h(m[e]),E),r=u(t)+ +!!x.length;if(x.length&&A+r>E)break;x.push(t),A+=r}let I=Math.min(m.length,o+x.length);return x.push({id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:o,nextOffset:I,complete:I>=m.length,totalNodes:m.length},extra:{},fieldErrors:{}}),{fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:x,diagnostics:[]}},usedResources:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),o=e;for(;o&&o!==Object.prototype;){for(let e of Object.getOwnPropertyNames(o))a.add(e);o=Object.getPrototypeOf(o)}for(let o of[...a].sort())if(!(o.startsWith("_")||["parent","children","consumers"].includes(o)))try{let a=r(e[o],t,n+1);a&&"function"===a.$unsupported||(i[o]=a)}catch(e){i[o]={$error:"unavailable"}}return t.delete(e),i}let n=new Map,i="ok";try{for(let e of(await figma.variables.getLocalVariablesAsync()))n.set(e.id,e)}catch(e){i="unavailable"}function a(e){return Object.values(e.valuesByMode||{}).filter(e=>e&&"VARIABLE_ALIAS"===e.type&&"string"==typeof e.id&&e.id).map(e=>e.id)}async function o(e){let t=n.get(e);if(t)return{value:r(t),collectionId:t.variableCollectionId,aliasIds:a(t)};try{let t=await figma.variables.getVariableByIdAsync(e);return t?{value:r(t),collectionId:t.variableCollectionId,aliasIds:a(t)}:{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupEmpty"}}}catch(t){return{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupThrew",error:String(t&&t.message||t).slice(0,300)}}}}let l=await Promise.all(t.variableIds.map(o)),s=new Set(t.variableIds),d=l.flatMap(e=>e.aliasIds||[]);for(;d.length>0;){let e=[...new Set(d)].filter(e=>!s.has(e)).sort();for(let t of e)s.add(t);let t=await Promise.all(e.map(o));l.push(...t),d=t.flatMap(e=>e.aliasIds||[])}let f=[...new Set(l.flatMap(e=>e.collectionId?[e.collectionId]:[]))].sort(),c=await Promise.all(f.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?[r(t)]:[]}catch(e){return[]}})),u=await Promise.all(t.styles.map(async e=>{try{let t=await figma.getStyleByIdAsync(e.id);if(!t)return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}};return{value:{...r(t),styleType:e.styleType,value:r("PAINT"===e.styleType?t.paints:"EFFECT"===e.styleType?t.effects:"GRID"===e.styleType?t.layoutGrids:t)}}}catch(t){return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}}}}));return{collections:c.flat(),variables:l.flatMap(e=>e.value?[e.value]:[]),styles:u.flatMap(e=>e.value?[e.value]:[]),usedVariableIds:t.variableIds,usedStyleIds:t.styles.map(e=>e.id),localVariableListing:i,localVariableCount:n.size,unresolved:[...l,...u].flatMap(e=>e.unresolved?[e.unresolved]:[])}},variableCatalog:async function(e){let[t,r,n,i,a]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),o=["PAINT","TEXT","EFFECT","GRID"];return{collections:t.map(e=>(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(r.has(t))return{$circular:!0};r.add(t);let i={},a=new Set(Object.keys(t)),o=t;for(;o&&o!==Object.prototype;){for(let e of Object.getOwnPropertyNames(o))a.add(e);o=Object.getPrototypeOf(o)}for(let o of[...a].sort())if(!o.startsWith("_"))try{let a=e(t[o],r,n+1);a&&"function"===a.$unsupported||(i[o]=a)}catch(e){i[o]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i})(e)),variableIds:[...new Set(t.flatMap(e=>e.variableIds))].sort(),styles:[r,n,i,a].flatMap((e,t)=>e.map(e=>({id:e.id,styleType:o[t]}))).sort((e,t)=>e.id.localeCompare(t.id)),localComplete:!0,usedRemoteComplete:!1}},variables:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),o=e;for(;o&&o!==Object.prototype;){for(let e of Object.getOwnPropertyNames(o))a.add(e);o=Object.getPrototypeOf(o)}for(let o of[...a].sort())if(!(o.startsWith("_")||["parent","children","consumers"].includes(o)))try{let a=r(e[o],t,n+1);a&&"function"===a.$unsupported||(i[o]=a)}catch(e){i[o]={$error:String(e&&e.message?e.message:e)}}return t.delete(e),i}let[n,i]=await Promise.all([Promise.all(t.variableIds.map(e=>figma.variables.getVariableByIdAsync(e))),Promise.all(t.styles.map(e=>figma.getStyleByIdAsync(e.id)))]),a=new Map(t.styles.map(e=>[e.id,e])),o=await Promise.all(i.filter(Boolean).map(async e=>{let t=a.get(e.id),n=t.styleType;if(Number.isInteger(t.consumerStart)&&Number.isInteger(t.consumerEnd)){let i=await e.getStyleConsumersAsync();return{id:e.id,styleType:n,$consumerStart:t.consumerStart,$consumerEntries:i.slice(t.consumerStart,t.consumerEnd).map(e=>[e.node.id,e.node.type,r(e.fields)])}}let i=await e.getStyleConsumersAsync();return{...r(e),styleType:n,$consumerCount:i.length,value:r("PAINT"===n?e.paints:"EFFECT"===n?e.effects:"GRID"===n?e.layoutGrids:e)}}));return{variables:n.filter(Boolean).map(e=>r(e)),styles:o}}},t=new Map,r=0;function n(){let e=figma.currentPage,t=e.selection;return{currentPage:{id:e.id,name:e.name},selection:t.slice(0,20).map(e=>({id:e.id,name:e.name,type:e.type})),selectionCount:t.length}}function i(){let e={kind:"devup-context",...n()};figma.ui.postMessage(e)}async function a(t){let r=e[t.script];if(!r)return{kind:"devup-result",requestId:t.requestId,error:`DEVUP_BRIDGE_UNKNOWN_SCRIPT: ${t.script} (아는 스크립트: ${Object.keys(e).join(", ")})`};try{var n;let e=await r({nodeId:(n=t.params).nodeId??"",rootIds:n.rootIds??[],snapshot:n.snapshot??{},search:n.search??{},explore:n.explore??{},resources:n.resources??{variableIds:[],styles:[]},largeValue:n.largeValue??{},asset:n.asset??{},theme:n.theme??{offset:0}});return{kind:"devup-result",requestId:t.requestId,data:e}}catch(e){return{kind:"devup-result",requestId:t.requestId,error:e instanceof Error?e.message:String(e)}}}globalThis.__devupNormalizeImage=(e,n,i,a,o)=>new Promise(l=>{r+=1;let s=`decode-${r}`,d=setTimeout(()=>{t.delete(s),l(null)},i);t.set(s,e=>{clearTimeout(d),t.delete(s),l(e)}),figma.ui.postMessage({kind:"devup-decode",id:s,bytes:e,maxEdge:n,width:a,height:o})});let o=[],l=!1,s=null,d=new Set;async function f(){if(!l){l=!0;try{for(;o.length>0;){let e=o.shift();s=e.requestId;let t=await a(e).catch(t=>({kind:"devup-result",requestId:e.requestId,error:t instanceof Error?t.message:String(t)}));s=null,d.delete(e.requestId)||figma.ui.postMessage(t)}}finally{l=!1}}}let c=null;function u(){c??(c=(async()=>{try{await figma.loadAllPagesAsync(),await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.variables.getLocalVariablesAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()])}catch{}finally{c=null}})())}figma.showUI(__html__,{width:320,height:220}),u(),figma.on("selectionchange",i),figma.on("currentpagechange",i),figma.ui.onmessage=e=>{if("object"==typeof e&&null!==e){if("devup-ready"===e.kind){let e={kind:"devup-status",fileKey:figma.fileKey??null,fileName:figma.root.name,port:1993,...n()};figma.ui.postMessage(e);return}if("devup-job"===e.kind){o.push(e),f();return}if("devup-cancel"===e.kind){var r;let t;return void(r=e.requestId,(t=o.findIndex(e=>e.requestId===r))>=0?o.splice(t,1):s===r&&d.add(r))}if("devup-decoded"===e.kind)return void t.get(e.id)?.(e.png??null);"devup-in-view"===e.kind&&u()}}})(); \ No newline at end of file +(()=>{"use strict";let e={assets:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=e.asset;function r(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],o=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=o(r[e-2],17)^o(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,l,s,d,f,c,u,p]=a;for(let e=0;e<64;e+=1){let n=p+(o(f,6)^o(f,11)^o(f,25))+(f&c^~f&u)+t[e]+r[e]>>>0,a=(o(i,2)^o(i,13)^o(i,22))+(i&l^i&s^l&s)>>>0;p=u,u=c,c=f,f=d+n>>>0,d=s,s=l,l=i,i=n+a>>>0}for(let[e,t]of[i,l,s,d,f,c,u,p].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}async function n(e){try{let t=await figma.getNodeByIdAsync(e);if(t)return t}catch(r){if(!e.startsWith("I")||!e.includes(";"))throw r;try{return await t()}catch(e){throw Error(`${r.message||r}; nested lookup fallback: ${e.message||e}`)}}if(!e.startsWith("I")||!e.includes(";"))return null;return t();async function t(){let t=await figma.getNodeByIdAsync(e.slice(1).split(";")[0]),r=t?[t]:[],n=new Set;for(;r.length;){let t=r.pop();if(t.id===e)return t;if(!n.has(t.id)){if(n.add(t.id),n.size>1e4)throw Error("nested lookup exceeds 10000 nodes");Array.isArray(t.children)&&r.push(...t.children)}}throw Error(`nested node ${e} not found in its instance`)}}async function i(e,t,r=85e3,n=()=>{}){let a,o=Date.now();try{let i=await Promise.race([e.exportAsync(t),new Promise((e,t)=>{a=setTimeout(()=>t(Error(`DEVUP_ASSET_TIMEOUT: native export exceeded ${r} ms`)),r)})]);return n("native-export",Date.now()-o),i}catch(i){if(n("native-export",Date.now()-o,String(i.message||i).slice(0,500)),"SVG_STRING"!==t.format)throw i;let r=Date.now();try{let t=function(e){let t=e=>"number"==typeof e&&Number.isFinite(e),r=(e,t)=>{throw Error(`geometry fallback unsupported: ${e.type||"unknown"} ${e.id||""}: ${t}`)},n=0,i=0;function a(e,i,a){(void 0!==a&&!Array.isArray(a)||void 0===a&&Array.isArray(i)&&i.length)&&r(e,"unresolved paint requires native export");let o=Array.isArray(a)?a.filter(e=>!1!==e.visible):[];return o.length?(Array.isArray(i)&&i.length||r(e,"paint has no rendered geometry"),o.map(a=>{("SOLID"!==a.type||a.blendMode&&"NORMAL"!==a.blendMode)&&r(e,"paint type or blending requires native export");let o=a.color;o&&[o.r,o.g,o.b].every(e=>t(e)&&e>=0&&e<=1)||r(e,"invalid solid color");let l=void 0===a.opacity?1:a.opacity;return(!t(l)||l<0||l>1)&&r(e,"invalid paint opacity"),["NONZERO","EVENODD"].map(t=>{let a=i.filter(e=>e.windingRule===t);if(!a.length)return"";for(let t of a)"string"==typeof t.data&&t.data.trim()||r(e,"empty path"),n++;return`"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e])}" fill="rgb(${100*o.r}%,${100*o.g}%,${100*o.b}%)" fill-opacity="${l}" fill-rule="${"EVENODD"===t?"evenodd":"nonzero"}"/>`}).join("")}).join("")):""}let o=e.absoluteRenderBounds,l=e.absoluteTransform;(!o||![o.x,o.y,o.width,o.height].every(t)||o.width<=0||o.height<=0)&&r(e,"missing render bounds"),(!Array.isArray(l)||2!==l.length||!l.every(e=>Array.isArray(e)&&3===e.length&&e.every(t))||0!==l[0][1]||0!==l[1][0]||l[0][0]<=0||l[1][1]<=0)&&r(e,"root rotation, reflection or shear requires native export");let s=(o.x-l[0][2])/l[0][0],d=(o.y-l[1][2])/l[1][1],f=o.width/l[0][0],c=o.height/l[1][1],u=function e(n,o){if(++i>1e4&&r(n,"subtree exceeds 10000 nodes"),n.isMask&&r(n,"mask requires native export"),!1===n.visible||0===n.opacity)return"";n.clipsContent&&r(n,"clipping requires native export"),Array.isArray(n.effects)&&n.effects.some(e=>!1!==e.visible)&&r(n,"effect requires native export"),n.blendMode&&!["NORMAL","PASS_THROUGH"].includes(n.blendMode)&&r(n,"layer blending requires native export");let l=void 0===n.opacity?1:n.opacity;(!t(l)||l<0||l>1)&&r(n,"invalid layer opacity");let s="";if(!o){let e=n.relativeTransform;Array.isArray(e)&&2===e.length&&e.every(e=>Array.isArray(e)&&3===e.length&&e.every(t))||r(n,"missing relative transform"),s=` transform="matrix(${e[0][0]} ${e[1][0]} ${e[0][1]} ${e[1][1]} ${e[0][2]} ${e[1][2]})"`}let d="";if(["GROUP","FRAME","INSTANCE","COMPONENT"].includes(n.type))(Array.isArray(n.fills)&&n.fills.some(e=>!1!==e.visible)||Array.isArray(n.strokes)&&n.strokes.some(e=>!1!==e.visible))&&r(n,"container paint requires native export"),Array.isArray(n.children)||r(n,"missing children"),d=(!0===n.itemReverseZIndex?[...n.children].reverse():n.children).map(t=>e(t,!1)).join("");else{for(let e of(["VECTOR","BOOLEAN_OPERATION","RECTANGLE","ELLIPSE","POLYGON","STAR","LINE"].includes(n.type)||r(n,"node type requires native export"),Array.isArray(n.strokes)&&n.strokes.some(e=>!1!==e.visible)&&"CENTER"!==n.strokeAlign&&r(n,"non-center stroke alignment requires native export"),Array.isArray(n.dashPattern)&&n.dashPattern.length&&r(n,"dashed stroke requires native export"),[n.fillGeometry,n.strokeGeometry]))void 0!==e&&(!Array.isArray(e)||e.some(e=>!["NONZERO","EVENODD"].includes(e.windingRule)))&&r(n,"unknown winding rule");d=a(n,n.fillGeometry,n.fills)+a(n,n.strokeGeometry,n.strokes)}return`${d}`}(e,!0);return n||r(e,"no painted geometry"),`${u}`}(e);return n("geometry-fallback",Date.now()-r),t}catch(e){throw Error(`native export: ${i.message||i}; ${e.message||e}`)}}finally{clearTimeout(a)}}let a=Date.now(),o="lookup",l="unknown",s=Math.min(6e5,Math.max(1e3,Number(t.timeoutMs)||9e4)),d={method:"native",stages:[]};function f(e,t,r){d.stages.push({stage:e,elapsedMs:t}),r&&(d.nativeError=r),"geometry-fallback"===e&&(d.method="geometry")}async function c(e){let t;try{return await Promise.race([e(),new Promise((e,r)=>{t=setTimeout(()=>r(Error(`DEVUP_ASSET_TIMEOUT: ${o} exceeded ${s} ms asset budget`)),Math.max(0,s-(Date.now()-a)))})])}finally{clearTimeout(t)}}function u(e){let r=e&&"string"==typeof e.message?e.message:String(e),n="string"==typeof figma.editorType?` (editor: ${figma.editorType})`:"";return`${o} failed after ${Date.now()-a} ms${n} (node: ${t.nodeId}, type: ${l}): ${r}`.slice(0,500)}function p(e,r){return e&&d.renderInfo&&(d.renderInfo.attemptedRepresentation=d.renderInfo.representation,d.renderInfo.representation="not-exported",d.renderInfo.renderedAs="not-exported",d.renderInfo.effectsBakedIntoAsset=!1,d.renderInfo.fidelity="unverified"),{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:t.scale,renderMode:h,usageAspectRatio:t.usageAspectRatio??null,maxWidth:t.maxWidth??null,srcset:!0===t.srcset,visibleScopeNodeId:t.visibleScopeNodeId??null,status:"failed",byteLength:null,sha256:null,errorCode:e,exportDiagnostics:d,...r?{errorDetail:r}:{}}}let g="string"==typeof t.field&&t.field.startsWith("$original-image/fills/"),h=void 0===t.renderMode?g?"fill":["svg","pdf"].includes(t.format)?"node":"original-source":t.renderMode,y=["auto","original-source"].includes(h)?"auto"===h?"node":"fill":h;function m(e,r){let n=e=>"number"==typeof e&&Number.isFinite(e)&&e>0,i=Array.isArray(e.effects)?e.effects:null,a=[],o=e=>[...e].slice(0,128).join(""),l=["string"==typeof e.name?o(e.name):"layer"],s=new Set,d=e;for(let t=e.parent;t&&a.length<32&&!s.has(t);t=t.parent)s.add(t),l.length<4&&"string"==typeof t.name&&t.name.length&&!["PAGE","DOCUMENT"].includes(t.type)&&l.push(o(t.name)),a.push({type:t.type||"unknown",clipsContent:t.clipsContent??null,isMask:t.isMask??null,effects:Array.isArray(t.effects)?t.effects:null,opacity:t.opacity??null,blendMode:t.blendMode??null,visible:t.visible??null,siblingMaskPresent:Array.isArray(t.children)?t.children.some(e=>e!==d&&!0===e.isMask):null}),d=t;return{sourceKind:r?"image-fill":t.imageHash?"image-node":"vector-node",renderedAs:"fill"===y?"raw-fill":"node-render",representation:"fill"===y?"original-fill":"native-node",layerHierarchy:l.reverse(),scope:"fill"===y?"selected-paint-original-bytes":"exact-node-subtree",exportSettings:"node"===y?{contentsOnly:!0,useAbsoluteBounds:!1}:null,requestedScale:t.scale,scaleSemantics:"fill"===y?"intrinsic-original-bytes-no-resize":["png","jpg"].includes(t.format)?"native-node-SCALE-constraint":"not-applied-to-vector-document",nodeSize:n(e.width)&&n(e.height)?{width:e.width,height:e.height,aspectRatio:e.width/e.height}:null,outputSize:null,dimensionEvidence:"unknown-until-byte-inspection",aspectRatioComparison:"unknown",scaleMode:r?r.scaleMode??null:null,imageTransform:r?r.imageTransform??null:null,visibleSourceRatio:null,visibleSourceRatioEvidence:"not-computed",effects:i,extraFills:Array.isArray(e.fills)?Math.max(0,e.fills.filter(e=>!1!==e.visible).length-!!r):null,effectsBakedIntoAsset:"node"===y&&["png","jpg"].includes(t.format)&&null!==i,bakedEffects:"node"===y&&["png","jpg"].includes(t.format)&&i?i.filter(e=>!1!==e.visible):[],instanceOverride:"unknown",instanceOverrideEvidence:"no-component-paint-comparison",sourceProperties:{scaleMode:r?r.scaleMode??null:null,imageTransform:r?r.imageTransform??null:null,rotation:r?r.rotation??null:null,filters:r?r.filters??null:null,opacity:r?r.opacity??null:null,effects:i,blurEffects:i?i.filter(e=>!1!==e.visible&&String(e.type).includes("BLUR")):null,isMask:e.isMask??null,clipsContent:e.clipsContent??null,ancestors:a},unappliedProperties:"fill"===y?["scaleMode","imageTransform","rotation","filters","paintOpacity","nodeGeometry","nodeTransforms","nodeEffectsIncludingBlur","nodeAndAncestorMasks","ancestorClipsAndEffects"]:[],warnings:"fill"===y?["original-fill-properties-unapplied","aspect-ratio-unknown"]:["ancestor-canvas-appearance-unverified","aspect-ratio-unknown"],fidelity:"unverified"}}function b(e,t){let r=0;for(let n=e;n&&r++<32;n=n.parent){if("INSTANCE"===n.type&&Array.isArray(n.overrides)&&n.overrides.find(t=>t.id===e.id&&Array.isArray(t.overriddenFields)&&t.overriddenFields.includes("fills"))){t.instanceOverride=!0,t.instanceOverrideEvidence={kind:"figma-instance-overrides",instanceId:n.id,nodeId:e.id,overriddenFields:["fills"]};return}if(["PAGE","DOCUMENT"].includes(n.type))break}}try{let e;if(!["original-source","fill","node","visible","auto"].includes(h))return p("DEVUP_ASSET_RENDER_MODE_UNSUPPORTED");if(!g&&"fill"===h&&(!String(t.field).startsWith("fills/")||!["png","jpg","webp"].includes(t.format)||1!==t.scale))return p("DEVUP_ASSET_FILL_SETTINGS_UNSUPPORTED","Original fill requires fills/N, png or jpg matching its codec, and scale 1; no resizing or transcoding.");if((g||["fill","original-source"].includes(h))&&"bridge"!==t.transport)return p(g?"DEVUP_ORIGINAL_IMAGE_REQUIRES_BRIDGE":"DEVUP_ASSET_FILL_REQUIRES_BRIDGE");let x=await c(()=>n(t.nodeId));if(f("lookup",Date.now()-a),l=x&&x.type||"unknown",!x||["node","visible"].includes(h)&&"function"!=typeof x.exportAsync)return p("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM",u(Error(x?"node has no exportAsync":"node not found")));let E=null;if(g||"string"==typeof t.field&&t.field.startsWith("fills/")){let e=Number(t.field.slice(g?22:6)),r="fills"in x&&Array.isArray(x.fills)?x.fills:[],n=Number.isInteger(e)?r[e]:null,i=n&&"IMAGE"===n.type?n.imageHash||n.imageRef:null;if(!n||i!==t.imageHash)return p("DEVUP_ASSET_SOURCE_CHANGED");E=n}else if("node"!==t.field)return p("DEVUP_ASSET_FIELD_UNSUPPORTED");if("original-source"===h&&(!E||!t.imageHash))return p("DEVUP_ORIGINAL_SOURCE_UNAVAILABLE","No IMAGE fill source exists for this selection. Choose an IMAGE fill or explicitly renderMode node; vector assets should retain SVG.");let A=x;if("visible"===h){let e=t.visibleScopeNodeId;if(!e)return p("DEVUP_VISIBLE_CONTEXT_UNAVAILABLE","Recollect the parent frame and provide visibleScopeNodeId.");let r=new Set;for(;A&&A.id!==e&&!r.has(A)&&r.size<64;)r.add(A),A=A.parent;if(!A||A.id!==e||["PAGE","DOCUMENT"].includes(A.type)||"function"!=typeof A.exportAsync)return p("DEVUP_VISIBLE_SCOPE_UNSUPPORTED","The selected capture scope is not an exportable ancestor of the requested node.")}if(d.renderInfo=m(x,E),"visible"===h){let e=d.renderInfo;e.representation="native-visible",e.renderedAs="visible-scoped-capture",e.captureNodeId=A.id,e.captureBox={width:A.width,height:A.height,aspectRatio:A.width/A.height},e.contextScope="selected-ancestor-subtree",e.captureEffects=Array.isArray(A.effects)?A.effects:null,e.requestedNodeEffects=e.effects,e.effects=e.captureEffects,e.sourceProperties.captureEffects=e.captureEffects,e.visibleContext={requestedNodeId:x.id,captureNodeId:A.id,included:["selected-scope-children","selected-scope-clipping","selected-scope-masks","selected-scope-rounding"],outsideScope:"unknown; ancestors, page backdrop and external layers are not included",canvasReferenceCompared:!1},e.warnings.push("visible-scoped-capture","outside-capture-context-unverified")}b(x,d.renderInfo);let I=null;if(E)try{let r=figma.getImageByHash(t.imageHash),n=r&&await Promise.race([r.getSizeAsync(),new Promise(t=>{e=setTimeout(()=>t(null),250)})]);I=n&&n.width>0&&n.height>0&&Number.isFinite(n.width)&&Number.isFinite(n.height)?{...n,aspectRatio:n.width/n.height}:null}catch(e){}finally{clearTimeout(e)}let S=null;if("auto"===h){let e=function(e,r,n){let i,a=[],o=(e,t)=>{e||a.push(t)},l=e=>Array.isArray(e)&&2===e.length&&e.every(e=>Array.isArray(e)&&3===e.length&&e.every(Number.isFinite))&&1===e[0][0]&&1===e[1][1]&&0===e[0][1]&&0===e[1][0]&&Number.isInteger(e[0][2])&&Number.isInteger(e[1][2]);for(let a of(o("bridge"===t.transport,"raw-requires-bridge"),o(1===t.scale&&["png","jpg"].includes(t.format),"raw-requires-unscaled-raster"),o(!!r&&!0===r.visible&&1===r.opacity&&"NORMAL"===r.blendMode,"paint-compositing-or-unknown"),o(r&&"FILL"===r.scaleMode&&Array.isArray(i=r.imageTransform)&&2===i.length&&i.every((e,t)=>Array.isArray(e)&&3===e.length&&e.every((e,r)=>e===[[1,0,0],[0,1,0]][t][r]))&&0===r.rotation,"paint-transform-or-unknown"),o(r&&r.filters&&Object.values(r.filters).every(e=>0===e),"image-filters-or-unknown"),o(Array.isArray(e.fills)&&1===e.fills.length,"multiple-fills-or-unknown"),o("RECTANGLE"===e.type&&0===e.cornerRadius&&Array.isArray(e.strokes)&&0===e.strokes.length&&(!e.children||0===e.children.length),"node-geometry-or-unknown"),o(Array.isArray(e.effects)&&0===e.effects.length&&!1===e.isMask&&(void 0===e.clipsContent||!1===e.clipsContent)&&1===e.opacity&&!0===e.visible&&"NORMAL"===e.blendMode,"node-effects-mask-compositing-or-unknown"),o(l(e.absoluteTransform)&&l(e.relativeTransform),"node-transform-or-unknown"),o(n.sourceProperties.ancestors.length>0&&n.sourceProperties.ancestors.some(e=>["PAGE","DOCUMENT"].includes(e.type)),"ancestor-chain-incomplete"),o(n.sourceProperties.ancestors.every(e=>!1===e.siblingMaskPresent),"ancestor-sibling-mask-or-unknown"),n.sourceProperties.ancestors.filter(e=>!["PAGE","DOCUMENT"].includes(e.type))))o(!1===a.clipsContent&&!1===a.isMask&&!1===a.siblingMaskPresent&&Array.isArray(a.effects)&&0===a.effects.length&&1===a.opacity&&!0===a.visible&&["NORMAL","PASS_THROUGH"].includes(a.blendMode),"ancestor-dependency-or-unknown");return{eligible:0===a.length,reasons:[...new Set(a)],evidence:"explicit-plugin-properties; intrinsic-dimensions-and-codec-required"}}(x,E,d.renderInfo);if(e.eligible)try{let r=figma.getImageByHash(t.imageHash),n=I;n&&n.width===x.width&&n.height===x.height?(S=await c(()=>r.getBytesAsync()),(137===S[0]&&80===S[1]&&78===S[2]&&71===S[3]?"png":255===S[0]&&216===S[1]&&255===S[2]?"jpg":"unknown")!==t.format&&e.reasons.push("source-codec-mismatch-or-unknown")):e.reasons.push("intrinsic-dimensions-mismatch-or-unknown")}catch(t){e.reasons.push("source-inspection-unavailable")}e.eligible=0===e.reasons.length,y=e.eligible?"fill":"node",d.renderInfo=m(x,E),b(x,d.renderInfo),d.renderInfo.autoDecision={...e,selected:y}}if(d.renderInfo.sourceSize=I,d.renderInfo.sourceSizeEvidence=I?"Figma-image-getSizeAsync":"unknown",g||"fill"===y){d.method="original-fill",o="reading the original image";let e=figma.getImageByHash(t.imageHash);if(!e)return p("DEVUP_ORIGINAL_IMAGE_NOT_FOUND");let n=S||await c(()=>e.getBytesAsync());if(0===n.length||n.length>0x5000000)return p("DEVUP_ASSET_RESPONSE_TOO_LARGE",`${n.length} bytes; one answer carries 1 to 83886080`);let i=e=>e.every((e,t)=>n[t]===e),a=i([137,80,78,71,13,10,26,10])?"image/png":i([255,216,255])?"image/jpeg":i([71,73,70,56])?"image/gif":i([82,73,70,70])&&87===n[8]&&69===n[9]&&66===n[10]&&80===n[11]?"image/webp":null;if(!a)return p("DEVUP_ORIGINAL_IMAGE_CODEC_UNSUPPORTED");if(d.renderInfo.originalMimeType=a,!g&&"fill"===h&&"webp"!==t.format&&a!==({png:"image/png",jpg:"image/jpeg"})[t.format])return p("DEVUP_ASSET_FILL_CODEC_MISMATCH",`Original fill codec is ${a}, but ${t.format} was requested; choose the matching format or renderMode node. No transcoding was performed.`);if(!g)return{...p(null),status:"exported",mimeType:a,byteLength:n.length,sha256:r(n),data:figma.base64Encode(n)};let l=await c(()=>e.getSizeAsync());return{...p(null),status:"exported",kind:"devupOriginalImage",representation:"original-image-v1",format:null,scale:null,mimeType:a,width:l.width,height:l.height,byteLength:n.length,sha256:r(n),data:figma.base64Encode(n)}}let w="webp"===t.format?"PNG":String(t.format||"").toUpperCase();if(!["PNG","JPG","SVG","PDF"].includes(w))return p("DEVUP_ASSET_FORMAT_UNSUPPORTED");let v=Number(t.scale??2);if(!Number.isFinite(v)||v<=0||v>4)return p("DEVUP_ASSET_SCALE_INVALID");let N="SVG"===w,O={format:N?"SVG_STRING":w,contentsOnly:!0,useAbsoluteBounds:!1};("PNG"===w||"JPG"===w)&&(O.constraint={type:"SCALE",value:v}),o="export";let T=Math.max(1,s-(Date.now()-a)),_=N?Math.max(1,T-Math.min(5e3,Math.floor(T/5))):T,P=Date.now(),C=await c(()=>"visible"===h?A.exportAsync(O):E?x.exportAsync(O):i(x,O,_,f));if(E&&f("native",Date.now()-P),"geometry"===d.method&&(d.renderInfo.representation="geometry-fallback",d.renderInfo.renderedAs="geometry-fallback",d.renderInfo.effectsBakedIntoAsset=!1,d.renderInfo.warnings.push("geometry-fallback-not-native-render")),Date.now()-a>=s)throw Error(`DEVUP_ASSET_TIMEOUT: export exceeded ${s} ms asset budget`);o="encode";let k=Date.now(),M=N&&"string"==typeof C?C:null,R=null===M?C instanceof Uint8Array?C:new Uint8Array(C):function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(M);if(0===R.length||R.length>0x5000000)return p("DEVUP_ASSET_RESPONSE_TOO_LARGE",`${w} at scale ${v} is ${R.length} bytes; one answer carries 1 to 83886080`);let D=r(R);f("encode",Date.now()-k);let L=figma.io&&"function"==typeof figma.io.write,$="PNG"===w&&R.length>786432;if(L&&(null!==M&&R.length>12288||$))return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:v,renderMode:h,usageAspectRatio:t.usageAspectRatio??null,maxWidth:t.maxWidth??null,srcset:!0===t.srcset,visibleScopeNodeId:t.visibleScopeNodeId??null,status:"chunked",byteLength:R.length,sha256:D,cursor:{nextOffset:0,maxChunkBytes:12288},errorCode:null,exportDiagnostics:d};return L&&figma.io.write(`devup-asset-${t.assetId.replace(/[^A-Za-z0-9_-]/g,"_")}.${w.toLowerCase()}`,R),{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:v,renderMode:h,usageAspectRatio:t.usageAspectRatio??null,maxWidth:t.maxWidth??null,srcset:!0===t.srcset,visibleScopeNodeId:t.visibleScopeNodeId??null,status:"exported",byteLength:R.length,sha256:D,mimeType:L?null===M?null:"image/svg+xml":({PNG:"image/png",JPG:"image/jpeg",SVG:"image/svg+xml",PDF:"application/pdf"})[w],...!L&&null===M?{data:figma.base64Encode(R)}:{},text:M,errorCode:null,exportDiagnostics:d}}catch(e){return p(String(e.message||e).includes("DEVUP_ASSET_TIMEOUT:")?"DEVUP_ASSET_TIMEOUT":g||["fill","original-source"].includes(h)?"DEVUP_ORIGINAL_IMAGE_READ_FAILED":"DEVUP_ASSET_EXPORT_FAILED",u(e))}},bridgeContext:async function(e){let t=figma.currentPage,r=t.selection,n=e=>({id:e.id,name:String(e.name).slice(0,256),type:e.type});return{kind:"devup-bridge-context",currentPage:n(t),selection:r.slice(0,20).map(n),selectionCount:r.length,zoom:Number.isFinite(figma.viewport?.zoom)?figma.viewport.zoom:null,observedAt:Date.now(),lastKnownSelection:globalThis.__devupLastSelection??null,lastKnownPage:globalThis.__devupLastSelectionPage??null}},explore:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=e.explore,n=Math.max(20,Math.min(400,Number.isInteger(r.projectionLimit)?r.projectionLimit:200)),i=Math.max(0,Math.min(500,Number.isInteger(r.textPreviewLimit)?r.textPreviewLimit:160));function a(e){return"string"==typeof e?e.slice(0,240):""}let o="PAGE"===t.type,l=t,s="SECTION"===t.type?t:null;for(;!o&&l.parent&&"PAGE"!==l.parent.type;)l=l.parent,s||"SECTION"!==l.type||(s=l);let d="PAGE"===t.type?t:l.parent;if(!d||"PAGE"!==d.type)throw Error("DEVUP_PAGE_NOT_FOUND");function f(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function c(e){let t=f(e);return t&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&t.width/Math.max(1,t.height)>=.25&&t.width/Math.max(1,t.height)<=2.5}function u(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type&&t.length<12;){let e=a(r.name);e&&t.push(e),r=r.parent}return t.reverse()}await figma.setCurrentPageAsync(d);let p=o?d:!c(t)&&s?s:l,g=f(p)||f(t);if(!g)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let h="children"in d?d.children:[],y=h.map((e,t)=>({node:e,pageChildIndex:t,bounds:f(e)})).filter(e=>e.bounds).filter(e=>{var t;return!!o||e.node.id===p.id||e.bounds.y>=g.y-240&&e.bounds.y<=g.y+12e3&&(t=e.bounds,Math.min(t.x+t.width,g.x+g.width)-Math.max(t.x,g.x)>0)}).sort((e,t)=>e.bounds.y-t.bounds.y||e.bounds.x-t.bounds.x||e.node.id.localeCompare(t.node.id)),m="SECTION"===p.type?y.filter(e=>e.node.id===p.id):y.slice(0,n),b=new Map(m.map(e=>[e.node.id,e]));b.has(l.id)||b.set(l.id,{node:l,pageChildIndex:-1,bounds:f(l)}),b.has(p.id)||b.set(p.id,{node:p,pageChildIndex:-1,bounds:f(p)}),b.has(t.id)||b.set(t.id,{node:t,pageChildIndex:-1,bounds:f(t)});let x=o?null:t.parent;for(;x&&"PAGE"!==x.type&&(b.has(x.id)||b.set(x.id,{node:x,pageChildIndex:-1,bounds:f(x)}),x.id!==p.id);)x=x.parent;if("children"in p)for(let e of p.children.slice(0,n))b.has(e.id)||b.set(e.id,{node:e,pageChildIndex:-1,bounds:f(e)});let E="SECTION"===p.type||o,A=E&&"children"in p&&p.children.length>n;if(E&&"children"in p){let e=8*n,t=p.children.filter(e=>!c(e)).map(e=>({node:e,ancestors:[]})),r=0;for(;t.length&&r!b.has(e.id));if(b.size+t.length>n+2){A=!0;continue}for(let e of t)b.set(e.id,{node:e,pageChildIndex:-1,bounds:f(e)});continue}if("children"in e)for(let r of e.children)t.push({node:r,ancestors:[...i,e]})}A||=t.length>0}let I=[...b.values()].filter(e=>e.bounds).slice(0,n+2).map(({node:e,pageChildIndex:t,bounds:r})=>({id:e.id,type:e.type,fields:{name:a(e.name),parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:[],x:r.x,y:r.y,width:r.width,height:r.height,childCount:"children"in e?e.children.length:0,textPreview:"",pageChildIndex:t>=0?t:null,visible:!("visible"in e)||!1!==e.visible,breadcrumb:u(e)},extra:{},fieldErrors:{}})),S=I.reduce((e,t)=>{let r=t.fields;return e?{x:Math.min(e.x,r.x),y:Math.min(e.y,r.y),right:Math.max(e.right,r.x+r.width),bottom:Math.max(e.bottom,r.y+r.height)}:{x:r.x,y:r.y,right:r.x+r.width,bottom:r.y+r.height}},null)||{x:0,y:0,right:0,bottom:0},w="SECTION"!==p.type&&y.length>m.length||b.size>n+2||A,v={id:d.id,type:d.type,fields:{name:a(d.name),parentId:null,childrenIds:[],x:S.x,y:S.y,width:S.right-S.x,height:S.bottom-S.y,childCount:h.length,textPreview:"",projectionTruncated:w,visible:!0,breadcrumb:u(d),pageChildIndex:null},extra:{},fieldErrors:{}},N={fileKey:figma.fileKey||"",version:null,rootIds:[d.id],nodes:[v,...I.filter(e=>e.id!==d.id)],diagnostics:[]},O=new Set([d.id,l.id,p.id,t.id]);for(;JSON.stringify(N).length>14e3;){let e=N.nodes.length-1;for(;e>=0&&O.has(N.nodes[e].id);)e-=1;if(e<0)break;N.nodes.splice(e,1),v.fields.projectionTruncated=!0}if(JSON.stringify(N).length>14e3&&(N.nodes=N.nodes.filter(e=>O.has(e.id)).map(e=>({...e,fields:{...e.fields,name:a(e.fields.name).slice(0,80),textPreview:"",breadcrumb:e.fields.breadcrumb.slice(-4).map(e=>e.slice(0,80)),projectionTruncated:e.id===d.id||e.fields.projectionTruncated}}))),JSON.stringify(N).length>14e3)throw Error("DEVUP_EXPLORE_PROJECTION_TOO_LARGE");function T(e){let t=0;for(let r of e){let e=r.codePointAt(0);t+=e<128?1:e<2048?2:e<65536?3:4}return t}if(o){for(;T(JSON.stringify(N))>19456&&N.nodes.length>1;)N.nodes.pop(),v.fields.projectionTruncated=!0;if(T(JSON.stringify(N))>19456)throw Error("DEVUP_EXPLORE_PROJECTION_TOO_LARGE")}let _=14e3-JSON.stringify(N).length;if(o&&(_=Math.min(_,19456-T(JSON.stringify(N)))),i>0&&_>0){let e=N.nodes.filter(e=>b.get(e.id)&&e.id!==d.id),t=e.length;for(let r of e){let e=Math.floor(_/t);if(t-=1,e<=0)continue;let n="",a=0;for(let t of function(e){if(0===i)return"";let t=[],r=[e],n=0;for(;r.length&&n<80&&t.join(" ").lengthe)break;n+=t,a+=r}r.fields.textPreview=n,_-=a}}return N},fastSnapshot:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=e.rootIds;if(!Array.isArray(t)||0===t.length)throw Error("DEVUP_ROOTS_INVALID");let r=await Promise.all(t.map(e=>figma.getNodeByIdAsync(e)));if(r.some(e=>!e))throw Error("DEVUP_NODE_NOT_FOUND");if(1===r.length&&"SECTION"===r[0].type)throw Error("DEVUP_TARGET_IS_SECTION");let n=["mobile","tablet","desktop"],i=e=>{let t=String(e.name||"").trim().toLowerCase(),r=n.indexOf(t);if(r>=0)return r;let i=t.match(/^(\d+)\s*[~–]\s*(\d+)$/);if(!i)return -1;let a=Number(i[1]),o=Number(i[2]);return Number.isSafeInteger(a)&&Number.isSafeInteger(o)&&a>0&&o>=a&&o<=0xffffffff?1:-1};if(1===r.length&&i(r[0])>=0){let e=r[0].parent;if(e&&"SECTION"===e.type&&"children"in e){let t=e.children.filter(e=>e.id===r[0].id||i(e)>=0).filter(e=>!1!==e.visible);t.length>1&&(r.length=0,r.push(...t))}}if(1===t.length){let e=[],t=new Set(r.map(e=>e.id)),n=[],i=e=>{if("reactions"in e&&Array.isArray(e.reactions)){for(let r of e.reactions)if(r&&r.trigger&&"AFTER_TIMEOUT"===r.trigger.type)for(let e of r.actions||[])e&&"NODE"===e.type&&e.transition&&"SMART_ANIMATE"===e.transition.type&&"string"==typeof e.destinationId&&!t.has(e.destinationId)&&(t.add(e.destinationId),n.push(e.destinationId));if("children"in e)for(let t of e.children)i(t)}};for(let e of r)i(e);for(;n.length>0;){let t=n.shift(),r=await figma.getNodeByIdAsync(t);r&&"DOCUMENT"!==r.type&&"PAGE"!==r.type&&(e.push(r),i(r))}r.push(...e)}let a=e.nodeId,o=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],l=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],s=e.snapshot,d=Math.max(0,Math.floor(Number(s.offset)||0)),f=Math.max(8192,Math.floor(Number(s.maxEnvelopeBytes)||19456)),c=Math.min(s.maxEnvelopeBytes?f-1024:18e3,Math.max(4096,Math.floor(Number(s.maxPayloadBytes)||15e3))),u=new Set(["backgroundStyleId","effectStyleId","fillStyleId","gridStyleId","strokeStyleId","textStyleId"]),p=new Set(["maxWidth","maxHeight","absoluteRenderBounds"]),g=new Map([["rotation",0],["cornerRadius",0],["isAsset",!1],["isMask",!1],["clipsContent",!1],["blendMode","PASS_THROUGH"],["strokeAlign","INSIDE"],["textCase","ORIGINAL"],["textDecoration","NONE"],["textAlignHorizontal","LEFT"],["textAlignVertical","TOP"],["counterAxisAlignItems","MIN"],["primaryAxisAlignItems","MIN"],["gridColumnCount",0],["gridRowCount",0],["gridColumnGap",0],["gridRowGap",0],["gridColumnAnchorIndex",-1],["gridRowAnchorIndex",-1],["gridColumnSpan",1],["gridRowSpan",1]]),h=new Set(["start","end","characters","fontWeight","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]),y=new Set(["parent","children","consumers"]);function m(e,t=!1,r=new WeakSet,n=0){let i;if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>m(e,t,r,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(r.has(e))return{$circular:!0};if(r.add(e),t){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}i=[...t].sort().filter(e=>!e.startsWith("_")&&!y.has(e))}else i=Object.keys(e).sort();let a={};for(let o of i)try{let i=m(e[o],t,r,n+1);i&&"function"===i.$unsupported||(a[o]=i)}catch(e){a[o]=t?{$error:"unavailable"}:{$error:String(e&&e.message?e.message:e)}}return r.delete(e),a}let b=new Map;async function x(e,t,r){if("INSTANCE"!==e.type||"function"!=typeof e.getMainComponentAsync)return;b.has(e.id)||b.set(e.id,(async()=>{try{let t=await e.getMainComponentAsync();return{fields:{mainComponentId:t?t.id:null,mainComponentName:t?t.name:null,mainComponentSetId:t&&t.parent&&"COMPONENT_SET"===t.parent.type?t.parent.id:null}}}catch(e){return{error:String(e&&e.message?e.message:e)}}})());let n=await b.get(e.id);n.error?r.mainComponentId=n.error:Object.assign(t,n.fields)}async function E(e){let t={},r={};e.parent&&(t.parentId=e.parent.id),e.parent&&("PAGE"===e.parent.type||"SECTION"===e.parent.type||"COMPONENT_SET"===e.parent.type)&&(t.parentType=e.parent.type,"SECTION"===e.parent.type&&(t.parentName=e.parent.name));let n="children"in e?e.children.map(e=>e.id):[];for(let i of(n.length>0&&(t.childrenIds=n),o)){let n;if("componentPropertyDefinitions"!==i||"COMPONENT"!==e.type||!e.parent||"COMPONENT_SET"!==e.parent.type)try{if(!(i in e)){"overflowDirection"===i&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t[i]=null);continue}if(n=e[i],"function"==typeof n)continue;let r=m(n);if("overflowDirection"===i&&null==n){t[i]=null;continue}(null===r?!p.has(i):Array.isArray(r)?0===r.length:"object"==typeof r?0===Object.keys(r).length:!!(""===r&&u.has(i))||g.has(i)&&g.get(i)===r)||(t[i]=r)}catch(e){r[i]=String(e&&e.message?e.message:e)}}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{let r=m(e.getStyledTextSegments(l));if(1===r.length){let e=r[0];for(let t of Object.keys(e))h.has(t)||delete e[t]}r.length>0&&(t.styledTextSegments=r)}catch(e){r.styledTextSegments=String(e&&e.message?e.message:e)}await x(e,t,r);let i={id:e.id,type:e.type,fields:t};return Object.keys(r).length>0&&(i.fieldErrors=r),i}let A=[],I=[...r],S=new Set;for(let e=0;e=A.length&&A.length>0)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");function w(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1({id:e,styleType:t})).sort((e,t)=>e.id.localeCompare(t.id));async function a(e){try{let t=await figma.variables.getVariableByIdAsync(e);return t?{kind:"variable",value:m(t,!0),collectionId:t.variableCollectionId,aliasIds:Object.values(t.valuesByMode||{}).filter(e=>e&&"VARIABLE_ALIAS"===e.type&&"string"==typeof e.id&&e.id).map(e=>e.id)}:{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}}}}let o=await Promise.all([...n.map(a),...i.map(async({id:e,styleType:t})=>{try{let r=await figma.getStyleByIdAsync(e);if(!r)return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}};return{kind:"style",value:{...m(r,!0),styleType:t,value:m("PAINT"===t?r.paints:"EFFECT"===t?r.effects:"GRID"===t?r.layoutGrids:r,!0)}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}}}})]),l=new Set(n),s=o.flatMap(e=>e.aliasIds||[]);for(;s.length>0;){let e=[...new Set(s)].filter(e=>!l.has(e)).sort();for(let t of e)l.add(t);let t=await Promise.all(e.map(a));o.push(...t),s=t.flatMap(e=>e.aliasIds||[])}let d=[...new Set(o.filter(e=>"variable"===e.kind&&e.collectionId).map(e=>e.collectionId))].sort(),f=(await Promise.all(d.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?m(t,!0):null}catch(e){return null}}))).filter(e=>null!==e),c=o.filter(e=>"variable"===e.kind).map(e=>e.value),u=o.filter(e=>"style"===e.kind).map(e=>e.value),p=o.filter(e=>"unresolved"===e.kind).map(e=>e.value);return{collections:f,variables:c,styles:u,usedRemoteVariables:c.filter(e=>!0===e.remote),usedVariableIds:n,usedStyleIds:i.map(e=>e.id),localComplete:!1,usedRemoteComplete:0===p.length,unresolved:p,$variableRefCount:n.length,$styleRefCount:i.length}}async function N(e){let t=[],r=2;for(let n=d;ne)break;t.push(i),r+=a}return{pageNodes:t,packedBytes:r}}let O=c-1024,T=null;for(let e=0;e<5;e+=1){let{pageNodes:e,packedBytes:t}=await N(O);if(0===e.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let n=function(e,t){let n=Math.min(A.length,d+e.length),{$variableRefCount:i,$styleRefCount:o,...l}=t,s=[...e,{id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:d,nextOffset:n,complete:n>=A.length,totalNodes:A.length},extra:{},fieldErrors:{}}],f={kind:"devupFastSnapshotEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",rootId:a},snapshot:{fileKey:figma.fileKey||"",version:null,rootIds:r.map(e=>e.id),nodes:s,diagnostics:[]},resources:l,integrity:{nodeCount:s.length,variableRefCount:i,styleRefCount:o,utf8Bytes:0}},c=0;for(let e=0;e<8&&(c=w(JSON.stringify(f)),f.integrity.utf8Bytes!==c);e+=1)f.integrity.utf8Bytes=c;if(f.integrity.utf8Bytes!==w(JSON.stringify(f)))throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:f,bytes:c}}(e,await v(e));if(n.bytes<=f){T=n;break}if(1===e.length)throw Error("DEVUP_ENVELOPE_TOO_LARGE");O=Math.max(1,Math.min(O-1,t-(n.bytes-f)-256))}if(!T)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return T.envelope},fastTheme:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=Math.max(0,Math.floor(Number(e.theme.offset)||0));function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={};for(let a of function(e){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}return[...t].sort()}(e))if(!(a.startsWith("_")||["parent","children","consumers"].includes(a)))try{let o=r(e[a],t,n+1);o&&"function"===o.$unsupported||(i[a]=o)}catch(e){i[a]={$error:"unavailable"}}return t.delete(e),i}let n=new Set,i=new Map;for(let e of figma.root.children)await e.loadAsync();for(let e of[figma.root,...figma.root.findAll(()=>!0)])for(let t of["boundVariables","fills","strokes","effects","layoutGrids","textStyleId","fillStyleId","strokeStyleId","backgroundStyleId","effectStyleId","gridStyleId"])try{!function e(t,r="",a=new WeakSet,o=0){if(!(o>16)&&null!==t&&"object"==typeof t&&!a.has(t)){if(a.add(t),Array.isArray(t)){for(let n of t)e(n,r,a,o+1);return}for(let[l,s]of("VARIABLE_ALIAS"===t.type&&"string"==typeof t.id&&t.id&&"figma.mixed"!==t.id&&"MIXED"!==t.id&&n.add(t.id),Object.entries(t))){let t="textStyleId"===l?"TEXT":["fillStyleId","strokeStyleId","backgroundStyleId"].includes(l)?"PAINT":"effectStyleId"===l?"EFFECT":"gridStyleId"===l?"GRID":null;t&&"string"==typeof s&&s&&"figma.mixed"!==s&&"MIXED"!==s&&!i.has(s)&&i.set(s,t),e(s,l||r,a,o+1)}}}({[t]:e[t]})}catch(e){}let[a,o,l,s,d,f]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.variables.getLocalVariablesAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),c=new Set(o.map(e=>e.id)),u=new Set([...l,...s,...d,...f].map(e=>e.id)),p=[];async function g(e){try{return await figma.variables.getVariableByIdAsync(e)||null}catch(t){return p.push({id:e,kind:"variable",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}),null}}function h(e){return Object.values(e.valuesByMode||{}).filter(e=>e&&"VARIABLE_ALIAS"===e.type&&"string"==typeof e.id&&e.id).map(e=>e.id)}let y=[...i.entries()].filter(([e])=>!u.has(e)).sort(([e],[t])=>e.localeCompare(t)).map(async([e,t])=>{try{let r=await figma.getStyleByIdAsync(e);return r?{style:r,styleType:t}:null}catch(t){return p.push({id:e,kind:"style",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}),null}}),m=new Set,b=[],x=[...n,...o.flatMap(h)];for(;x.length>0;){let e=[...new Set(x)].filter(e=>!c.has(e)&&!m.has(e)).sort();for(let t of e)m.add(t);let t=(await Promise.all(e.map(g))).filter(Boolean);b.push(...t),x=t.flatMap(h)}let E=(await Promise.all(y)).filter(Boolean);for(let e of m)b.some(t=>t.id===e)||p.some(t=>t.id===e)||p.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"});for(let[e]of[...i.entries()].filter(([e])=>!u.has(e)))E.some(t=>t.style.id===e)||p.some(t=>t.id===e)||p.push({id:e,kind:"style",reason:"notFoundOrUnavailable"});let A=[...new Set(b.map(e=>e.variableCollectionId))].filter(e=>!a.some(t=>t.id===e)).sort(),I=(await Promise.all(A.map(async e=>{try{return await figma.variables.getVariableCollectionByIdAsync(e)}catch(e){return null}}))).filter(Boolean);function S(e,t){return{...r(e),styleType:t,value:r("PAINT"===t?e.paints:"EFFECT"===t?e.effects:"GRID"===t?e.layoutGrids:e)}}let w=[...l.map(e=>S(e,"PAINT")),...s.map(e=>S(e,"TEXT")),...d.map(e=>S(e,"EFFECT")),...f.map(e=>S(e,"GRID")),...E.map(({style:e,styleType:t})=>S(e,t))].sort((e,t)=>e.id.localeCompare(t.id)),v=[...o,...b].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id)),N=[...a,...I].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id));function O(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}p.sort((e,t)=>e.kind.localeCompare(t.kind)||e.id.localeCompare(t.id));let T=[...N.map(e=>({kind:"collection",value:e})),...v.map(e=>({kind:"variable",value:e})),...w.map(e=>({kind:"style",value:e})),...[...n].sort().map(e=>({kind:"usedVariableId",value:e})),...[...i.keys()].sort().map(e=>({kind:"usedStyleId",value:e})),...p.map(e=>({kind:"unresolved",value:e}))];if(t>T.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let _=T.map(e=>O(JSON.stringify(e.value)).length+1),P=18432,C=null;for(let e=0;e<6;e+=1){let{pageItems:e,packed:r}=function(e){let r=[],n=0;for(let i=t;i0)||!(n+_[i]>e));i+=1)r.push(T[i]),n+=_[i];return{pageItems:r,packed:n}}(P),n=function(e,r){let n=t=>e.filter(e=>e.kind===t).map(e=>e.value),i=n("collection"),a=n("variable"),o=n("style"),l=n("unresolved"),s={kind:"devupFastThemeEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",version:null},resources:{collections:i,variables:a,styles:o,usedRemoteVariables:[],usedVariableIds:n("usedVariableId"),usedStyleIds:n("usedStyleId"),localComplete:!0,usedRemoteComplete:0===p.length,unresolved:l},page:{offset:t,nextOffset:r,complete:r>=T.length,totalItems:T.length},integrity:{collectionCount:i.length,variableCount:a.length,styleCount:o.length,unresolvedCount:l.length,utf8Bytes:0}},d=new Uint8Array;for(let e=0;e<8&&(d=O(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length);e+=1)s.integrity.utf8Bytes=d.length;if(d=O(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length)throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:s,bytes:d.length}}(e,t+e.length);if(n.bytes<=19456){C=n;break}if(e.length<=1)break;P=Math.max(1,Math.min(P-1,r-(n.bytes-19456)-256))}if(null===C||C.bytes>8388608)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return C.envelope},largeValue:async function(e){let t;figma.skipInvisibleInstanceChildren=!1;let r=e.largeValue;async function n(e){try{let t=await figma.getNodeByIdAsync(e);if(t)return t}catch(r){if(!e.startsWith("I")||!e.includes(";"))throw r;try{return await t()}catch(e){throw Error(`${r.message||r}; nested lookup fallback: ${e.message||e}`)}}if(!e.startsWith("I")||!e.includes(";"))return null;return t();async function t(){let t=await figma.getNodeByIdAsync(e.slice(1).split(";")[0]),r=t?[t]:[],n=new Set;for(;r.length;){let t=r.pop();if(t.id===e)return t;if(!n.has(t.id)){if(n.add(t.id),n.size>1e4)throw Error("nested lookup exceeds 10000 nodes");Array.isArray(t.children)&&r.push(...t.children)}}throw Error(`nested node ${e} not found in its instance`)}}async function i(e,t,r=85e3,n=()=>{}){let a,o=Date.now();try{let i=await Promise.race([e.exportAsync(t),new Promise((e,t)=>{a=setTimeout(()=>t(Error(`DEVUP_ASSET_TIMEOUT: native export exceeded ${r} ms`)),r)})]);return n("native-export",Date.now()-o),i}catch(i){if(n("native-export",Date.now()-o,String(i.message||i).slice(0,500)),"SVG_STRING"!==t.format)throw i;let r=Date.now();try{let t=function(e){let t=e=>"number"==typeof e&&Number.isFinite(e),r=(e,t)=>{throw Error(`geometry fallback unsupported: ${e.type||"unknown"} ${e.id||""}: ${t}`)},n=0,i=0;function a(e,i,a){(void 0!==a&&!Array.isArray(a)||void 0===a&&Array.isArray(i)&&i.length)&&r(e,"unresolved paint requires native export");let o=Array.isArray(a)?a.filter(e=>!1!==e.visible):[];return o.length?(Array.isArray(i)&&i.length||r(e,"paint has no rendered geometry"),o.map(a=>{("SOLID"!==a.type||a.blendMode&&"NORMAL"!==a.blendMode)&&r(e,"paint type or blending requires native export");let o=a.color;o&&[o.r,o.g,o.b].every(e=>t(e)&&e>=0&&e<=1)||r(e,"invalid solid color");let l=void 0===a.opacity?1:a.opacity;return(!t(l)||l<0||l>1)&&r(e,"invalid paint opacity"),["NONZERO","EVENODD"].map(t=>{let a=i.filter(e=>e.windingRule===t);if(!a.length)return"";for(let t of a)"string"==typeof t.data&&t.data.trim()||r(e,"empty path"),n++;return`"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e])}" fill="rgb(${100*o.r}%,${100*o.g}%,${100*o.b}%)" fill-opacity="${l}" fill-rule="${"EVENODD"===t?"evenodd":"nonzero"}"/>`}).join("")}).join("")):""}let o=e.absoluteRenderBounds,l=e.absoluteTransform;(!o||![o.x,o.y,o.width,o.height].every(t)||o.width<=0||o.height<=0)&&r(e,"missing render bounds"),(!Array.isArray(l)||2!==l.length||!l.every(e=>Array.isArray(e)&&3===e.length&&e.every(t))||0!==l[0][1]||0!==l[1][0]||l[0][0]<=0||l[1][1]<=0)&&r(e,"root rotation, reflection or shear requires native export");let s=(o.x-l[0][2])/l[0][0],d=(o.y-l[1][2])/l[1][1],f=o.width/l[0][0],c=o.height/l[1][1],u=function e(n,o){if(++i>1e4&&r(n,"subtree exceeds 10000 nodes"),n.isMask&&r(n,"mask requires native export"),!1===n.visible||0===n.opacity)return"";n.clipsContent&&r(n,"clipping requires native export"),Array.isArray(n.effects)&&n.effects.some(e=>!1!==e.visible)&&r(n,"effect requires native export"),n.blendMode&&!["NORMAL","PASS_THROUGH"].includes(n.blendMode)&&r(n,"layer blending requires native export");let l=void 0===n.opacity?1:n.opacity;(!t(l)||l<0||l>1)&&r(n,"invalid layer opacity");let s="";if(!o){let e=n.relativeTransform;Array.isArray(e)&&2===e.length&&e.every(e=>Array.isArray(e)&&3===e.length&&e.every(t))||r(n,"missing relative transform"),s=` transform="matrix(${e[0][0]} ${e[1][0]} ${e[0][1]} ${e[1][1]} ${e[0][2]} ${e[1][2]})"`}let d="";if(["GROUP","FRAME","INSTANCE","COMPONENT"].includes(n.type))(Array.isArray(n.fills)&&n.fills.some(e=>!1!==e.visible)||Array.isArray(n.strokes)&&n.strokes.some(e=>!1!==e.visible))&&r(n,"container paint requires native export"),Array.isArray(n.children)||r(n,"missing children"),d=(!0===n.itemReverseZIndex?[...n.children].reverse():n.children).map(t=>e(t,!1)).join("");else{for(let e of(["VECTOR","BOOLEAN_OPERATION","RECTANGLE","ELLIPSE","POLYGON","STAR","LINE"].includes(n.type)||r(n,"node type requires native export"),Array.isArray(n.strokes)&&n.strokes.some(e=>!1!==e.visible)&&"CENTER"!==n.strokeAlign&&r(n,"non-center stroke alignment requires native export"),Array.isArray(n.dashPattern)&&n.dashPattern.length&&r(n,"dashed stroke requires native export"),[n.fillGeometry,n.strokeGeometry]))void 0!==e&&(!Array.isArray(e)||e.some(e=>!["NONZERO","EVENODD"].includes(e.windingRule)))&&r(n,"unknown winding rule");d=a(n,n.fillGeometry,n.fills)+a(n,n.strokeGeometry,n.strokes)}return`${d}`}(e,!0);return n||r(e,"no painted geometry"),`${u}`}(e);return n("geometry-fallback",Date.now()-r),t}catch(e){throw Error(`native export: ${i.message||i}; ${e.message||e}`)}}finally{clearTimeout(a)}}let a=await n(r.nodeId);if(!a)throw Error("DEVUP_NODE_NOT_FOUND");function o(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}let l=null;try{if("$export:svg"===r.field){if("function"!=typeof a.exportAsync)throw Error("unsupported");let e=await i(a,{format:"SVG_STRING"},Math.max(1,(Number(r.timeoutMs)||9e4)-5e3));if("string"!=typeof e)throw Error("unsupported");l=o(e)}else if("string"==typeof r.field&&r.field.startsWith("$export:png")){if("function"!=typeof a.exportAsync)throw Error("unsupported");let e=Number(r.field.split("@")[1]||2);if(!Number.isFinite(e)||e<=0||e>4)throw Error("invalid scale");let t=r.field.split("@"),n=a;if("visible"===t[2]){let e=new Set;for(;n&&n.id!==t[3]&&!e.has(n)&&e.size<64;)e.add(n),n=n.parent;if(!n||n.id!==t[3]||["PAGE","DOCUMENT"].includes(n.type))throw Error("invalid visible scope")}else if(t.length>2)throw Error("invalid export field");let i=await n.exportAsync({format:"PNG",contentsOnly:!0,useAbsoluteBounds:!1,constraint:{type:"SCALE",value:e}});l=i instanceof Uint8Array?i:new Uint8Array(i)}else if("styledTextSegments"===r.field&&"TEXT"===a.type&&"function"==typeof a.getStyledTextSegments)t=a.getStyledTextSegments(["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]);else if(r.field in a)t=a[r.field];else throw Error("unsupported")}catch(e){return{kind:"devupLargeValueUnsupported",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,byteLength:r.byteLength,sha256:r.sha256,errorCode:"DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM",errorDetail:`${r.field} failed: ${e.message||e}`.slice(0,500)}}let s=null===l?o(JSON.stringify(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let o=e(t[a],r,n+1);o&&"function"===o.$unsupported||(i[a]=o)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(t))):l,d=function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=e.length,n=64*Math.ceil((r+9)/64),i=new Uint8Array(n);i.set(e),i[r]=128;let a=8*r;for(let e=0;e<8;e+=1)i[n-1-e]=255&Math.floor(a/2**(8*e));let o=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[n,a,s,d,f,c,u,p]=o;for(let e=0;e<64;e+=1){let i=p+(l(f,6)^l(f,11)^l(f,25))+(f&c^~f&u)+t[e]+r[e]>>>0,o=(l(n,2)^l(n,13)^l(n,22))+(n&a^n&s^a&s)>>>0;p=u,u=c,c=f,f=d+i>>>0,d=s,s=a,a=n,n=i+o>>>0}for(let[e,t]of[n,a,s,d,f,c,u,p].entries())o[e]=o[e]+t>>>0}return o.map(e=>e.toString(16).padStart(8,"0")).join("")}(s);if(s.length!==r.byteLength||d!==r.sha256)throw Error("DEVUP_LARGE_VALUE_CHANGED");let f=Math.max(0,Math.floor(Number(r.offset)||0)),c=Math.min(65536,Math.max(1,Math.floor(Number(r.maxChunkBytes)||8192)));if(f>=s.length)throw Error("DEVUP_LARGE_VALUE_RANGE_INVALID");let u=Math.min(s.length,f+c);return{kind:"devupLargeValueFragment",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,offset:f,nextOffset:u,byteLength:s.length,sha256:d,dataBase64:function(e){let t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",r="";for(let n=0;n>2],r+=t[(3&i)<<4|a>>4],r+=n+1>6]:"=",r+=n+210||t.some(e=>"string"!=typeof e||e.length>128||!/^(?:[0-9]+:[0-9]+|I[0-9]+:[0-9]+(?:;[0-9]+:[0-9]+)+)$/.test(e))||new Set(t).size!==t.length)throw Error("DEVUP_INVALID_INPUT: nodeIds must be 1..10 distinct Figma IDs");let r=(e,t)=>{try{let r=e[t];return"number"==typeof r&&Number.isFinite(r)?r:null}catch{return null}},n=(e,t)=>{try{let r=e[t];return"string"==typeof r&&r.length<=32&&/^[A-Z_]+$/.test(r)?r:null}catch{return null}},i=[];for(let e of t)try{let t=await figma.getNodeByIdAsync(e);if(!t||"DOCUMENT"===t.type||"PAGE"===t.type){i.push({nodeId:e,status:"unavailable",reason:t?"not-scene-node":"not-found"});continue}let a={width:r(t,"width"),height:r(t,"height"),padding:{top:r(t,"paddingTop"),right:r(t,"paddingRight"),bottom:r(t,"paddingBottom"),left:r(t,"paddingLeft")},gap:r(t,"itemSpacing"),layoutMode:n(t,"layoutMode"),align:{primary:n(t,"primaryAxisAlignItems"),counter:n(t,"counterAxisAlignItems")}},o=null!==a.width&&null!==a.height;i.push({nodeId:e,status:o?"measured":"partial",...a})}catch{i.push({nodeId:e,status:"unavailable",reason:"read-failed"})}return{kind:"devup-measure",schemaVersion:1,coordinateSpace:"figma-logical",nodes:i,partial:i.some(e=>"measured"!==e.status),evidenceLimit:"Null means unavailable or not applicable; no screenshot dimensions, descendants, theme, assets or code were read."}},metadata:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=t;for(;r&&"PAGE"!==r.type;)r=r.parent;r&&"PAGE"===r.type&&await figma.setCurrentPageAsync(r);let n=[];return!function(e){let t=[{node:e,visited:!1}],r=new Map;for(;t.length>0;){let e=t[t.length-1],i=e.node,a="children"in i?i.children:[];if(!e.visited){e.visited=!0;for(let e=a.length-1;e>=0;e-=1)t.push({node:a[e],visited:!1});continue}t.pop();let o=0,l=[];for(let e of a)l.push(e.id),o+=1+(r.get(e.id)||0);r.set(i.id,o),n.push({id:i.id,type:i.type,name:i.name,childrenIds:l,descendantCount:o})}}(t),{fileKey:figma.fileKey||"",version:null,rootId:t.id,nodes:n}},pageCatalog:async function(e){let t=figma.root.children;return{fileKey:figma.fileKey||"",version:null,rootIds:t.map(e=>e.id),nodes:t.map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:null,childrenIds:[]},extra:{},fieldErrors:{}})),diagnostics:[]}},referencePng:async function(e){let t,r=e.nodeId;globalThis.__devupReferencePngAnswers||(globalThis.__devupReferencePngAnswers=new Map);let n=globalThis.__devupReferencePngAnswers,i=0;for(let[e,t]of n)Date.now()-t.at>12e4?n.delete(e):i+=t.text.length;if(i>=0xc000000)throw Error(`DEVUP_REFERENCE_PNG_BUSY ${i} characters are held for other reads`);let a=await figma.getNodeByIdAsync(r);if(!a||"function"!=typeof a.exportAsync)throw Error(`DEVUP_NODE_NOT_FOUND ${r}`);let o=a.absoluteRenderBounds||a.absoluteBoundingBox,l=Math.max(o.width,o.height),s=l>8192?8192/l:1,d=(e,t)=>({format:"PNG",constraint:t&&s<1?{type:o.width>=o.height?"WIDTH":"HEIGHT",value:8192}:{type:"SCALE",value:s},contentsOnly:e,useAbsoluteBounds:!1,colorProfile:"SRGB"}),f=e=>(Array.isArray(e)?e:[]).filter(e=>e&&!1!==e.visible),c=(e,t)=>!!e&&!!t&&e.x!!e&&e.x+t<=o.x&&e.y+t<=o.y&&e.x+e.width-t>=o.x+o.width&&e.y+e.height-t>=o.y+o.height,p=e=>`${e.type} ${JSON.stringify(e.name)}`,g=e=>JSON.parse(JSON.stringify(e===figma.mixed?null:e)),h=e=>(void 0===e.opacity||1===e.opacity)&&(!e.blendMode||["PASS_THROUGH","NORMAL"].includes(e.blendMode)),y=[],m=a.parent;for(;m&&"PAGE"!==m.type;)y.unshift(m),m=m.parent;let b=[255,255,255];for(let e of f(m&&m.backgrounds)){if("SOLID"!==e.type)continue;let t=void 0===e.opacity?1:e.opacity;["r","g","b"].forEach((r,n)=>{b[n]=t*e.color[r]*255+(1-t)*b[n]})}let x=0,E=e=>!1!==e.visible&&!e.isMask&&!!c(e.absoluteRenderBounds,o)&&((x+=1)>5e3||"TEXT"===e.type||!!f(e.fills).length||!!f(e.strokes).length||!!f(e.effects).length||"children"in e&&e.children.some(E)),A=e=>h(e)&&u(e.absoluteBoundingBox,0)&&1e-9>Math.abs(e.absoluteTransform[0][1])&&1e-9>Math.abs(e.absoluteTransform[1][0])&&0===Math.max(0,...[e.topLeftRadius,e.topRightRadius,e.bottomRightRadius,e.bottomLeftRadius].filter(e=>"number"==typeof e))&&f(e.fills).some(e=>"NORMAL"===(e.blendMode||"NORMAL")&&(void 0===e.opacity||1===e.opacity)&&("SOLID"===e.type||e.type.startsWith("GRADIENT_")&&e.gradientStops.every(e=>1===e.color.a))),I=[...y,a],S=[],w=[],v=new Map;for(let e=0;eA(e)&&i.slice(0,t).every(h));r.forEach((e,r)=>{e===t||(e.isMask&&!1!==e.visible&&r{let t=await a.exportAsync(d(!1,!0));return{kind:"devupReferencePng",nodeId:r,mimeType:"image/png",byteLength:t.length,data:figma.base64Encode(t),backdrop:N,...e}},T=(e,t)=>{let r=[...new Set(e)];return{approximate:r.join("; "),note:`Drawn as the canvas shows this area: devup-mcp cannot yet compose what get_screenshot draws here (${r.slice(0,3).join("; ")}). Layers reaching into it may show here: ${t}; get_screenshot leaves other layers out.`}},_=e=>{let t=JSON.stringify(e).replace(/[\u0080-\uffff]/g,e=>`\\u${e.charCodeAt(0).toString(16).padStart(4,"0")}`);if(t.length>0x5a00000){if(e.compose){let{compose:r,reaching:n,...i}=e;return _({...i,...T([`the answer would be ${t.length} characters`],n)})}throw Error(`DEVUP_REFERENCE_PNG_TOO_LARGE ${t.length} characters, over the 94371840 one answer is read back up to`)}if(t.length<=6291456)return e;let i=`${r}@${Date.now()}.${Math.random().toString(36).slice(2,10)}`;return n.set(i,{text:t,at:Date.now()}),{kind:"devupReferencePngChunked",nodeId:r,id:i,length:t.length}};if(0===S.length)return _(await O({}));let P=I.some(e=>e.isMask),C=[],k=new Set,M=(e,t)=>{let r=p(e);for(let t of f(e.fills))"IMAGE"===t.type?((t.rotation||f([t.filters]).some(e=>Object.values(e).some(e=>e)))&&C.push(`${r} has an image fill with a rotation or filters`),k.add(t.imageHash)):"SOLID"===t.type||t.type.startsWith("GRADIENT_")||C.push(`${r} is painted with ${t.type}`);for(let t of f(e.effects))["LAYER_BLUR","DROP_SHADOW","INNER_SHADOW","BACKGROUND_BLUR"].includes(t.type)||C.push(`${r} has a ${t.type}`);let n=f(e.strokes);if((e=>{let t=0===f(e.strokes).length?[]:[e.strokeWeight,e.strokeTopWeight,e.strokeRightWeight,e.strokeBottomWeight,e.strokeLeftWeight].filter(e=>"number"==typeof e&&e>0);if(0===t.length)return!1;let r=Math.max(...t),n="OUTSIDE"===e.strokeAlign?0:"CENTER"===e.strokeAlign?r/2:r,i="INSIDE"===e.strokeAlign?0:"CENTER"===e.strokeAlign?r/2:r,a=e.absoluteBoundingBox;return c(a&&{x:a.x-i,y:a.y-i,width:a.width+2*i,height:a.height+2*i},o)&&!u(a,n)})(e)){let t=["FRAME","RECTANGLE","COMPONENT","COMPONENT_SET","INSTANCE","SECTION"].includes(e.type),i=n.every(e=>"SOLID"===e.type||e.type.startsWith("GRADIENT_")),a=Array.isArray(e.dashPattern)&&e.dashPattern.length>0,o="INSIDE"===e.strokeAlign||!e.strokeJoin||"MITER"===e.strokeJoin;t&&i&&!a&&o||C.push(`${r}'s stroke reaches it`)}return{transform:g(e.absoluteTransform),width:e.width,height:e.height,radii:[e.topLeftRadius,e.topRightRadius,e.bottomRightRadius,e.bottomLeftRadius].map(e=>"number"==typeof e?e:0),fills:g(f(e.fills)),opacity:void 0===e.opacity?1:e.opacity,blendMode:e.blendMode||null,clips:!!e.clipsContent,effects:g(f(e.effects)),strokes:g(n),strokeAlign:e.strokeAlign||null,strokeWeights:["strokeTopWeight","strokeRightWeight","strokeBottomWeight","strokeLeftWeight"].map(t=>"number"==typeof e[t]?e[t]:"number"==typeof e.strokeWeight?e.strokeWeight:0)}},R=async e=>{let t=await e.exportAsync(d(!0,e===a));return{data:figma.base64Encode(t),area:e.absoluteRenderBounds||e.absoluteBoundingBox,blendMode:e.blendMode||null,size:[16,20].map(e=>(t[e]<<24|t[e+1]<<16|t[e+2]<<8|t[e+3])>>>0)}},D=async e=>{let t=v.get(e);if(!t)return null;let r=await t.exportAsync({...d(!0,!1),useAbsoluteBounds:!0});return{data:figma.base64Encode(r),area:t.absoluteBoundingBox,luminance:"LUMINANCE"===t.maskType}},L=[],$={},U={};try{for(let e of y){let t=M(e,!1),r=await D(e);r&&(t.mask=r),L.push(t)}let e=e=>f(e.effects).some(e=>"BACKGROUND_BLUR"===e.type);if(w.length>0&&(e(a)||"findOne"in a&&a.findOne(e))){let r="children"in a&&a.children.some(t=>e(t)||"findOne"in t&&!!t.findOne(e));if("children"in a&&!r&&!f(a.strokes).length){for(let e of($.own=M(a,!0),$.children=[],a.children)){if(!1===e.visible||e.isMask||!e.absoluteRenderBounds)continue;let t=await R(e);$.children.push({data:t.data,area:t.area,blendMode:t.blendMode})}t=[Math.ceil(o.width*s-1e-6),Math.ceil(o.height*s-1e-6)]}else C.push(`${p(a)} blurs what lies behind it`)}else if(P)t=[Math.ceil(o.width*s-1e-6),Math.ceil(o.height*s-1e-6)];else{let e=await R(a);$.picture={data:e.data,area:e.area,blendMode:e.blendMode},t=e.size}let r=await D(a);r&&($.mask=r),t&&t[0]*t[1]>8e6&&C.push(`the area is ${t[0]}x${t[1]} pixels`);let n=globalThis.__devupNormalizeImage,i=Date.now()+2e4,l=0;for(let e of k){let t=e&&figma.getImageByHash(e),r=t&&await t.getBytesAsync();if(!r){C.push(`the image ${e} could not be read`);continue}let a=null;if("function"==typeof n){let{width:e,height:o}=await t.getSizeAsync(),l=(...e)=>e.every((e,t)=>r[t]===e),s=l(137,80,78,71)||l(255,216)||l(71,73,70),d=Math.min(1,8192/Math.max(e,o,1)),f=Math.ceil(e*d)*Math.ceil(o*d),c=5*f+65536,u=5*r.length+4*e*o*(d<1?2:1)+4*f+4*c+1048576,p=i-Date.now();if((!s||Math.max(e,o)>8192||r.length>4*e*o+1048576)&&u<=0x20000000&&p>0){let t=await n(r,8192,p,e,o);t&&(r=t,a={width:e,height:o})}}if(l+r.length>0x4000000){C.push("its ancestors' images are over 67108864 bytes");break}l+=r.length,U[e]=a?{data:figma.base64Encode(r),...a}:figma.base64Encode(r)}}catch(e){C.push(`a part could not be exported: ${e instanceof Error?e.message:String(e)}`)}let B=`${S.slice(0,5).join(", ")}${S.length>5?` and ${S.length-5} more`:""}`;return C.length>0?_(await O(T(C,B))):_(await O({reaching:B,compose:{area:o,scale:s,width:t[0],height:t[1],page:b,layers:L,node:$,images:U}}))},referencePngChunk:async function(e){let t=e.largeValue,r=globalThis.__devupReferencePngAnswers,n=r&&r.get(t.id);if(!n||"string"!=typeof n.text)throw Error(`DEVUP_REFERENCE_PNG_ANSWER_GONE ${t.id}`);let i=n.text,a=Math.max(0,Math.floor(Number(t.offset)||0)),o=Math.min(i.length,a+4194304);return o>=i.length&&r.delete(t.id),{kind:"devupReferencePngChunk",id:t.id,offset:a,nextOffset:o,complete:o>=i.length,text:i.slice(a,o)}},search:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=t;for(;r&&"PAGE"!==r.type;)r=r.parent;if(!r||"PAGE"!==r.type)throw Error("DEVUP_PAGE_NOT_FOUND");await figma.setCurrentPageAsync(r);let n=e.search,i=new Set((n.nodeTypes.length?n.nodeTypes:["PAGE","SECTION","FRAME","COMPONENT_SET","COMPONENT"]).map(e=>e.toUpperCase()));function a(e){return e.normalize("NFC").toLocaleLowerCase().replace(/\s/gu,"")}let o=t===r?r.findAll(()=>!0):"findAll"in t?t.findAll(()=>!0):[],l=[t,...o].filter(e=>i.has(e.type)&&"string"==typeof e.name).map(e=>({node:e,score:function(e){if(e===n.query)return 400;if("exact"===n.matchKind)return null;let t=a(e),r=a(n.query);if(t===r)return 300;if(t.startsWith(r))return 200;if(t.includes(r))return 100;if("fuzzy"!==n.matchKind)return null;let i=function(e,t){let r=Array.from({length:t.length+1},(e,t)=>t);for(let n=0;nnull!==e.score).sort((e,t)=>t.score-e.score||e.node.name.localeCompare(t.node.name)||e.node.id.localeCompare(t.node.id)).slice(0,n.limit),s=new Map([[r.id,r]]);for(let e of[t,...l.map(e=>e.node)]){let t=e;for(;t&&"DOCUMENT"!==t.type;)s.set(t.id,t),t=t.parent}return{fileKey:figma.fileKey||"",version:null,rootIds:[r.id],nodes:[...s.values()].map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:"children"in e?e.children.map(e=>e.id):[]},extra:{},fieldErrors:{}})),diagnostics:[]}},sectionIndex:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");if("SECTION"!==t.type){let e=t.parent,r=new Set;for(;e&&"SECTION"!==e.type&&!r.has(e.id);)r.add(e.id),e=e.parent;let n=e&&"SECTION"===e.type?e.id:null,i=n&&figma.fileKey?`https://www.figma.com/design/${figma.fileKey}?node-id=${n.replace(/:/g,"-")}`:null;throw Error("DEVUP_SECTION_REQUIRED "+JSON.stringify({pluginCode:"DEVUP_SECTION_REQUIRED",stage:"section-index",nodeId:t.id,nodeType:t.type,sectionId:n,nextAction:{tool:"devup_figma_export",how:n?`The url must point to a SECTION. This capture's SECTION is ${n}; select frames with frameIds.`:"The url must point to a SECTION. This node has no ancestor SECTION; choose the intended SECTION in Figma and copy its link.",arguments:i?"FRAME"===t.type&&t.parent===e?{url:i,frameIds:[t.id]}:{url:i}:null,requiredArguments:i?[]:["url"]}}))}let r=2048;function n(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function i(e,t){if("FRAME"!==e.type||!1===e.visible||!t)return!1;let r=t.width/Math.max(1,t.height);return t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&r>=.25&&r<=2.5}function a(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1[e.id,t.id])),s=[],d=0;for(let e=0;ee.id));for(let r of t.children){if(e.has(r.id))continue;let t=n(r);t&&!1!==r.visible&&s.push({node:r,box:t})}}s.sort((e,t)=>e.box.y-t.box.y||e.box.x-t.box.x||e.node.id.localeCompare(t.node.id));let f=o.length>d||s.length>100,c=s.slice(0,100),u=new Set(c.map(({node:e})=>e.id)),p=new Set(e.rootIds),g={},h=new Map([[t.id,null]]);for(let e=0;e{let r=e.parent;for(;r&&r.id!==t.id&&!u.has(r.id);)r=r.parent;return[e.id,r&&u.has(r.id)?r.id:t.id]})),m=e=>c.filter(({node:t})=>y.get(t.id)===e).map(({node:e})=>e.id),b=n(t);if(!b)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let x={id:t.id,type:t.type,fields:{name:t.name,parentId:t.parent&&"DOCUMENT"!==t.parent.type?t.parent.id:null,childrenIds:m(t.id),absoluteBoundingBox:b,visible:!1!==t.visible,projectionTruncated:f,nodeScreenIds:g},extra:{},fieldErrors:{}},E=c.length,A=c.map(({node:e,box:t})=>{let n=function(e){let t=[e],r=0,n=0;for(let e=0;er}}(e),o=Math.floor(r/E);E-=1;let l=function(e,t){let r=[e],n="",i=0,o=0,l=!1;if(t<=0)return{text:"",state:"budget-exhausted",spent:0};for(let e=0;e=120||o+e>t)return{text:n.trimEnd(),state:o+e>t?"budget-exhausted":"truncated",spent:o};n+=r,i+=1,o+=e}}if("children"in s){let e=Math.max(0,64-r.length);s.children.length>e&&(l=!0),r.push(...s.children.slice(0,e))}}}return{text:n,state:l?"truncated":n?"available":"no-text",spent:o}}(e,o);return r-=l.spent,{id:e.id,type:e.type,fields:{name:"string"==typeof e.name?e.name:"",parentId:y.get(e.id),childrenIds:m(e.id),absoluteBoundingBox:t,visible:!1!==e.visible,breadcrumb:function(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type;)"string"==typeof r.name&&r.name&&t.push(r.name),r=r.parent;return t.reverse()}(e),directChildCount:"children"in e?e.children.length:0,textPreview:l.text,textPreviewState:l.state,subtreeNodeCount:n.subtreeNodeCount,estimatedSerializedBytes:n.estimatedSerializedBytes,selectionReasons:[i(e,t)?"screen-like":"explicit-selection-only","inside-section"],estimateTruncated:n.truncated},extra:{},fieldErrors:{}}}),I={fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:[x,...A],diagnostics:[]},S=a(JSON.stringify(I));for(let e=A.length-1;e>=0&&S>19456;e-=1)A[e].fields.textPreview="",A[e].fields.textPreviewState="budget-exhausted",S=a(JSON.stringify(I));let w=()=>I.nodes.length-1,v=A.length;for(;S>19456&&w()>1;){let e=I.nodes.pop();x.fields.projectionTruncated=!0,x.fields.childrenIds=x.fields.childrenIds.filter(t=>t!==e.id),S=a(JSON.stringify(I))}if(S>19456)throw Error("DEVUP_SECTION_INDEX_TOO_LARGE "+JSON.stringify({pluginCode:"DEVUP_SECTION_INDEX_TOO_LARGE",stage:"section-index",responseBytes:S,maxResponseBytes:19456,discoveredCandidates:v,retainedCandidates:w()}));return I},snapshot:async function(e){figma.skipInvisibleInstanceChildren=!1;let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],n=new Set(r),i=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],a=e.snapshot,o=Math.max(0,Math.floor(Number(a.offset)||0)),l=Math.min(a.maxEnvelopeBytes?Math.max(8192,Math.floor(Number(a.maxEnvelopeBytes))):16e3,Math.max(4096,Math.floor(Number(a.maxPayloadBytes)||15e3))),s=Math.min(l-1024,Math.max(512,Math.floor(Number(a.maxFieldBytes)||4096))),d=new Set(["id","type","parent","children","mainComponent","masterComponent","instances"]),f=new Set(["parentId","childrenIds","name","characters","styledTextSegments","boundVariables"]);function c(e,t,r){let n=function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(JSON.stringify(r));return n.length>0x1000000?{$truncated:"max-large-value-bytes",byteLength:n.length}:{$largeValue:{nodeId:e,field:t,byteLength:n.length,sha256:function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],o=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=o(r[e-2],17)^o(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,l,s,d,f,c,u,p]=a;for(let e=0;e<64;e+=1){let n=p+(o(f,6)^o(f,11)^o(f,25))+(f&c^~f&u)+t[e]+r[e]>>>0,a=(o(i,2)^o(i,13)^o(i,22))+(i&l^i&s^l&s)>>>0;p=u,u=c,c=f,f=d+n>>>0,d=s,s=l,l=i,i=n+a>>>0}for(let[e,t]of[i,l,s,d,f,c,u,p].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}(n),cursor:{nextOffset:0,maxChunkBytes:12288}}}}function u(e){return function(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+112)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let o=e(t[a],r,n+1);o&&"function"===o.$unsupported||(i[a]=o)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(r),a=u(i);if(a<=s)return i;let o=c(e,t,i);return o.$truncated&&(n[t]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${a}>16777216`),o}let g=new Map;async function h(e,t,r){if("INSTANCE"!==e.type||"function"!=typeof e.getMainComponentAsync)return;g.has(e.id)||g.set(e.id,(async()=>{try{let t=await e.getMainComponentAsync();return{fields:{mainComponentId:t?t.id:null,mainComponentName:t?t.name:null,mainComponentSetId:t&&t.parent&&"COMPONENT_SET"===t.parent.type?t.parent.id:null}}}catch(e){return{error:String(e&&e.message?e.message:e)}}})());let n=await g.get(e.id);n.error?r.mainComponentId=n.error:Object.assign(t,n.fields)}async function y(e){let t={},a={},o={};for(let i of(t.parentId=e.parent?e.parent.id:null,e.parent&&("PAGE"===e.parent.type||"SECTION"===e.parent.type||"COMPONENT_SET"===e.parent.type)&&(t.parentType=e.parent.type,"SECTION"===e.parent.type&&(t.parentName=e.parent.name)),t.childrenIds="children"in e?e.children.map(e=>e.id):[],!("overflowDirection"in e)&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t.overflowDirection=null),function(e){let t=new Set,n=e;for(;n&&n!==Object.prototype;){for(let e of Object.getOwnPropertyNames(n))t.add(e);n=Object.getPrototypeOf(n)}for(let n of r)try{n in e&&t.add(n)}catch(e){}return[...t].sort()}(e)))if(!(d.has(i)||i.startsWith("_")||"componentPropertyDefinitions"===i&&"COMPONENT"===e.type&&e.parent&&"COMPONENT_SET"===e.parent.type))try{let r=e[i];if("function"==typeof r)continue;let l=p(e.id,i,r,o);(n.has(i)?t:a)[i]=l}catch(e){o[i]=String(e&&e.message?e.message:e)}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{t.styledTextSegments=p(e.id,"styledTextSegments",e.getStyledTextSegments(i),o)}catch(e){o.styledTextSegments=String(e&&e.message?e.message:e)}return await h(e,t,o),{id:e.id,type:e.type,fields:t,extra:a,fieldErrors:o}}let m=[],b=[t];for(;b.length;){let e=b.shift();m.push(e),"children"in e&&b.push(...e.children)}let x=[],E=l-1024,A=2;for(let e=o;eNumber(e.protected)-Number(t.protected)||t.byteLength-e.byteLength),n)){if(r<=t)break;let n=c(e.id,i.name,e[i.sectionName][i.name]);e[i.sectionName][i.name]=n,n.$truncated&&(e.fieldErrors[i.name]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${i.byteLength}>16777216`),r=u(e)}return e}(await y(m[e]),E),r=u(t)+ +!!x.length;if(x.length&&A+r>E)break;x.push(t),A+=r}let I=Math.min(m.length,o+x.length);return x.push({id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:o,nextOffset:I,complete:I>=m.length,totalNodes:m.length},extra:{},fieldErrors:{}}),{fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:x,diagnostics:[]}},usedResources:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),o=e;for(;o&&o!==Object.prototype;){for(let e of Object.getOwnPropertyNames(o))a.add(e);o=Object.getPrototypeOf(o)}for(let o of[...a].sort())if(!(o.startsWith("_")||["parent","children","consumers"].includes(o)))try{let a=r(e[o],t,n+1);a&&"function"===a.$unsupported||(i[o]=a)}catch(e){i[o]={$error:"unavailable"}}return t.delete(e),i}let n=new Map,i="ok";try{for(let e of(await figma.variables.getLocalVariablesAsync()))n.set(e.id,e)}catch(e){i="unavailable"}function a(e){return Object.values(e.valuesByMode||{}).filter(e=>e&&"VARIABLE_ALIAS"===e.type&&"string"==typeof e.id&&e.id).map(e=>e.id)}async function o(e){let t=n.get(e);if(t)return{value:r(t),collectionId:t.variableCollectionId,aliasIds:a(t)};try{let t=await figma.variables.getVariableByIdAsync(e);return t?{value:r(t),collectionId:t.variableCollectionId,aliasIds:a(t)}:{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupEmpty"}}}catch(t){return{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupThrew",error:String(t&&t.message||t).slice(0,300)}}}}let l=await Promise.all(t.variableIds.map(o)),s=new Set(t.variableIds),d=l.flatMap(e=>e.aliasIds||[]);for(;d.length>0;){let e=[...new Set(d)].filter(e=>!s.has(e)).sort();for(let t of e)s.add(t);let t=await Promise.all(e.map(o));l.push(...t),d=t.flatMap(e=>e.aliasIds||[])}let f=[...new Set(l.flatMap(e=>e.collectionId?[e.collectionId]:[]))].sort(),c=await Promise.all(f.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?[r(t)]:[]}catch(e){return[]}})),u=await Promise.all(t.styles.map(async e=>{try{let t=await figma.getStyleByIdAsync(e.id);if(!t)return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}};return{value:{...r(t),styleType:e.styleType,value:r("PAINT"===e.styleType?t.paints:"EFFECT"===e.styleType?t.effects:"GRID"===e.styleType?t.layoutGrids:t)}}}catch(t){return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable",error:String(t&&t.message||t).slice(0,300)}}}}));return{collections:c.flat(),variables:l.flatMap(e=>e.value?[e.value]:[]),styles:u.flatMap(e=>e.value?[e.value]:[]),usedVariableIds:t.variableIds,usedStyleIds:t.styles.map(e=>e.id),localVariableListing:i,localVariableCount:n.size,unresolved:[...l,...u].flatMap(e=>e.unresolved?[e.unresolved]:[])}},variableCatalog:async function(e){let[t,r,n,i,a]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),o=["PAINT","TEXT","EFFECT","GRID"];return{collections:t.map(e=>(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(r.has(t))return{$circular:!0};r.add(t);let i={},a=new Set(Object.keys(t)),o=t;for(;o&&o!==Object.prototype;){for(let e of Object.getOwnPropertyNames(o))a.add(e);o=Object.getPrototypeOf(o)}for(let o of[...a].sort())if(!o.startsWith("_"))try{let a=e(t[o],r,n+1);a&&"function"===a.$unsupported||(i[o]=a)}catch(e){i[o]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i})(e)),variableIds:[...new Set(t.flatMap(e=>e.variableIds))].sort(),styles:[r,n,i,a].flatMap((e,t)=>e.map(e=>({id:e.id,styleType:o[t]}))).sort((e,t)=>e.id.localeCompare(t.id)),localComplete:!0,usedRemoteComplete:!1}},variables:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),o=e;for(;o&&o!==Object.prototype;){for(let e of Object.getOwnPropertyNames(o))a.add(e);o=Object.getPrototypeOf(o)}for(let o of[...a].sort())if(!(o.startsWith("_")||["parent","children","consumers"].includes(o)))try{let a=r(e[o],t,n+1);a&&"function"===a.$unsupported||(i[o]=a)}catch(e){i[o]={$error:String(e&&e.message?e.message:e)}}return t.delete(e),i}let[n,i]=await Promise.all([Promise.all(t.variableIds.map(e=>figma.variables.getVariableByIdAsync(e))),Promise.all(t.styles.map(e=>figma.getStyleByIdAsync(e.id)))]),a=new Map(t.styles.map(e=>[e.id,e])),o=await Promise.all(i.filter(Boolean).map(async e=>{let t=a.get(e.id),n=t.styleType;if(Number.isInteger(t.consumerStart)&&Number.isInteger(t.consumerEnd)){let i=await e.getStyleConsumersAsync();return{id:e.id,styleType:n,$consumerStart:t.consumerStart,$consumerEntries:i.slice(t.consumerStart,t.consumerEnd).map(e=>[e.node.id,e.node.type,r(e.fields)])}}let i=await e.getStyleConsumersAsync();return{...r(e),styleType:n,$consumerCount:i.length,value:r("PAINT"===n?e.paints:"EFFECT"===n?e.effects:"GRID"===n?e.layoutGrids:e)}}));return{variables:n.filter(Boolean).map(e=>r(e)),styles:o}}},t=new Map,r=0;globalThis.__devupNormalizeImage=(e,n,i,a,o)=>new Promise(l=>{r+=1;let s=`decode-${r}`,d=setTimeout(()=>{t.delete(s),l(null)},i);t.set(s,e=>{clearTimeout(d),t.delete(s),l(e)}),figma.ui.postMessage({kind:"devup-decode",id:s,bytes:e,maxEdge:n,width:a,height:o})});let n=globalThis;function i(){let e=figma.currentPage,t=e.selection;return t.length>0&&(n.__devupLastSelection=t.slice(0,20).map(e=>({id:e.id,name:e.name.slice(0,256),type:e.type})),n.__devupLastSelectionPage={id:e.id,name:e.name.slice(0,256)}),{zoom:Number.isFinite(figma.viewport?.zoom)?figma.viewport.zoom:null,observedAt:Date.now(),lastKnownSelection:n.__devupLastSelection??null,lastKnownPage:n.__devupLastSelectionPage??null,currentPage:{id:e.id,name:e.name.slice(0,256)},selection:t.slice(0,20).map(e=>({id:e.id,name:e.name.slice(0,256),type:e.type})),selectionCount:t.length}}function a(){let e={kind:"devup-context",...i()};figma.ui.postMessage(e)}async function o(t){let r=e[t.script];if(!r)return{kind:"devup-result",requestId:t.requestId,error:`DEVUP_BRIDGE_UNKNOWN_SCRIPT: ${t.script} (아는 스크립트: ${Object.keys(e).join(", ")})`};try{var n;"bridgeContext"===t.script&&i();let e=await r({nodeId:(n=t.params).nodeId??"",rootIds:n.rootIds??[],snapshot:n.snapshot??{},search:n.search??{},explore:n.explore??{},resources:n.resources??{variableIds:[],styles:[]},largeValue:n.largeValue??{},asset:n.asset??{},theme:n.theme??{offset:0}});return{kind:"devup-result",requestId:t.requestId,data:e}}catch(e){return{kind:"devup-result",requestId:t.requestId,error:e instanceof Error?e.message:String(e)}}}let l=[],s=!1,d=null,f=new Set;async function c(){if(!s){s=!0;try{for(;l.length>0;){let e=l.shift();d=e.requestId;let t=await o(e).catch(t=>({kind:"devup-result",requestId:e.requestId,error:t instanceof Error?t.message:String(t)}));d=null,f.delete(e.requestId)||figma.ui.postMessage(t)}}finally{s=!1}}}let u=null;function p(){u??(u=(async()=>{try{await figma.loadAllPagesAsync(),await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.variables.getLocalVariablesAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()])}catch{}finally{u=null}})())}figma.showUI(__html__,{width:320,height:220}),p(),figma.on("selectionchange",a),figma.on("currentpagechange",a),figma.ui.onmessage=e=>{if("object"==typeof e&&null!==e){if("devup-ready"===e.kind){let e={kind:"devup-status",fileKey:figma.fileKey??null,fileName:figma.root.name,port:1993,...i()};figma.ui.postMessage(e);return}if("devup-job"===e.kind){l.push(e),c();return}if("devup-cancel"===e.kind){var r;let t;return void(r=e.requestId,(t=l.findIndex(e=>e.requestId===r))>=0?l.splice(t,1):d===r&&f.add(r))}if("devup-decoded"===e.kind)return void t.get(e.id)?.(e.png??null);"devup-in-view"===e.kind&&p()}}})(); \ No newline at end of file diff --git a/plugin/dist/ui.html b/plugin/dist/ui.html index bdf84728..f25c4264 100644 --- a/plugin/dist/ui.html +++ b/plugin/dist/ui.html @@ -45,4 +45,4 @@ padding-top: 12px; border-top: 1px solid #e6e6e6; color: #8c8c8c; - }

Devup Bridge

이 창을 열어 두면 devup-mcp 가 이 파일을 직접 읽습니다.

시작하는 중…

읽기 전용입니다. 문서를 바꾸지 않고, 데이터는 같은 기기의 devup-mcp 로만 나갑니다.

\ No newline at end of file + }

Devup Bridge

이 창을 열어 두면 devup-mcp 가 이 파일을 직접 읽습니다.

시작하는 중…

읽기 전용입니다. 문서를 바꾸지 않고, 데이터는 같은 기기의 devup-mcp 로만 나갑니다.

\ No newline at end of file diff --git a/plugin/scripts/verify-generated.mjs b/plugin/scripts/verify-generated.mjs index 54073fe0..a0e5c0a0 100644 --- a/plugin/scripts/verify-generated.mjs +++ b/plugin/scripts/verify-generated.mjs @@ -16,6 +16,8 @@ const { SCRIPTS, pageCatalog, referencePng, referencePngChunk, usedResources } = /** Rust 의 `BuiltinScript::plugin_name` 이 내놓는 이름. 어긋나면 런타임에야 안다. */ const EXPECTED = [ 'assets', + 'bridgeContext', + 'measureNodes', 'explore', 'fastSnapshot', 'fastTheme', diff --git a/plugin/src/code.ts b/plugin/src/code.ts index fdd754e3..34cb58fe 100644 --- a/plugin/src/code.ts +++ b/plugin/src/code.ts @@ -48,6 +48,10 @@ interface NodeRef { * 선택이 바뀔 때마다 보내므로 늦게 도착해도 최신이다. */ interface Context { + zoom: number | null + observedAt: number + lastKnownSelection: NodeRef[] | null + lastKnownPage: NodeRef | null currentPage: NodeRef selection: NodeRef[] selectionCount: number @@ -116,14 +120,28 @@ let decodes = 0 */ const SELECTION_LIMIT = 20 +const selectionHistory = globalThis as unknown as { + __devupLastSelection?: NodeRef[] + __devupLastSelectionPage?: NodeRef +} + function context(): Context { const page = figma.currentPage const selection = page.selection + if (selection.length > 0) { + selectionHistory.__devupLastSelection = selection.slice(0, SELECTION_LIMIT) + .map((node) => ({ id: node.id, name: node.name.slice(0, 256), type: node.type })) + selectionHistory.__devupLastSelectionPage = { id: page.id, name: page.name.slice(0, 256) } + } return { - currentPage: { id: page.id, name: page.name }, + zoom: Number.isFinite(figma.viewport?.zoom) ? figma.viewport.zoom : null, + observedAt: Date.now(), + lastKnownSelection: selectionHistory.__devupLastSelection ?? null, + lastKnownPage: selectionHistory.__devupLastSelectionPage ?? null, + currentPage: { id: page.id, name: page.name.slice(0, 256) }, selection: selection .slice(0, SELECTION_LIMIT) - .map((node) => ({ id: node.id, name: node.name, type: node.type })), + .map((node) => ({ id: node.id, name: node.name.slice(0, 256), type: node.type })), selectionCount: selection.length, } } @@ -166,6 +184,8 @@ async function runJob(job: JobMessage): Promise { } } try { + // Refresh history even when the selection event has not been delivered. + if (job.script === 'bridgeContext') context() const data = await script(fillParams(job.params)) return { kind: 'devup-result', requestId: job.requestId, data } } catch (error) { diff --git a/plugin/src/ui.ts b/plugin/src/ui.ts index 1f2e53b8..d1a509ea 100644 --- a/plugin/src/ui.ts +++ b/plugin/src/ui.ts @@ -14,6 +14,10 @@ interface NodeRef { /** 보고 있는 페이지와 선택. 메인 스레드가 바뀔 때마다 보낸다. */ interface Context { + zoom: number | null + observedAt: number + lastKnownSelection: NodeRef[] | null + lastKnownPage: NodeRef | null currentPage: NodeRef selection: NodeRef[] selectionCount: number @@ -101,6 +105,11 @@ function connect() { currentPage: bridgeStatus?.currentPage ?? null, selection: bridgeStatus?.selection ?? [], selectionCount: bridgeStatus?.selectionCount ?? 0, + zoom: bridgeStatus?.zoom ?? null, + uiDevicePixelRatio: window.devicePixelRatio, + observedAt: bridgeStatus?.observedAt, + lastKnownSelection: bridgeStatus?.lastKnownSelection, + lastKnownPage: bridgeStatus?.lastKnownPage, inView: inView(), }), ) @@ -181,6 +190,11 @@ function sendContext() { currentPage: bridgeStatus.currentPage, selection: bridgeStatus.selection, selectionCount: bridgeStatus.selectionCount, + zoom: bridgeStatus.zoom, + uiDevicePixelRatio: window.devicePixelRatio, + observedAt: bridgeStatus.observedAt, + lastKnownSelection: bridgeStatus.lastKnownSelection, + lastKnownPage: bridgeStatus.lastKnownPage, inView: inView(), }), ) @@ -220,6 +234,10 @@ window.onmessage = (event: MessageEvent) => { currentPage: context.currentPage, selection: context.selection, selectionCount: context.selectionCount, + zoom: context.zoom, + observedAt: context.observedAt, + lastKnownSelection: context.lastKnownSelection, + lastKnownPage: context.lastKnownPage, } sendContext() return @@ -227,6 +245,8 @@ window.onmessage = (event: MessageEvent) => { if (msg.kind === 'devup-result') { const result = msg as ResultMessage + const data = result.data as { kind?: string; uiDevicePixelRatio?: number } | undefined + if (data?.kind === 'devup-bridge-context') data.uiDevicePixelRatio = window.devicePixelRatio if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(result)) served += 1 diff --git a/plugin/tests/withdraw.test.mjs b/plugin/tests/withdraw.test.mjs index fb09a16e..22a1a0b6 100644 --- a/plugin/tests/withdraw.test.mjs +++ b/plugin/tests/withdraw.test.mjs @@ -95,3 +95,63 @@ test('an image conversion the UI never answers is given up at its time', async ( const asked = posted.find((message) => message.kind === 'devup-decode') assert.deepEqual([asked.maxEdge, asked.width, asked.height], [8192, 2, 1]) }) + +// Fixed measurement source and committed plugin acceptance stay in this suite +// so the existing CI entry point exercises them before dependency installation. +const measureSource=await readFile(new URL('../../crates/devup-mcp-figma/src/scripts/measure_nodes.js',import.meta.url),'utf8') +const measureNodes=({rootIds})=>new (Object.getPrototypeOf(async function(){}).constructor)('figma','rootIds',measureSource.replace('"__DEVUP_ROOT_IDS__"','rootIds'))(globalThis.figma,rootIds) + +test('measurement reads only requested layout fields, supports instances and discloses unavailable nodes', async () => { + const looked=[] + const node=new Proxy({type:'FRAME',width:167,height:94,layoutMode:'HORIZONTAL',itemSpacing:8,paddingTop:1,paddingRight:2,paddingBottom:3,paddingLeft:4,primaryAxisAlignItems:'CENTER',counterAxisAlignItems:'MIN'}, { + get(target,key) {if (key === "then") return undefined; assert.ok(key in target, `unexpected heavy read ${String(key)}`);return target[key]} + }) + globalThis.figma={getNodeByIdAsync:async id=>{looked.push(id);return id==='2:2'?null:node}} + const result=await measureNodes({rootIds:['I1:2;3:4','2:2']}) + assert.deepEqual(looked,['I1:2;3:4','2:2']) + assert.equal(result.nodes[0].width,167) + assert.deepEqual(result.nodes[0].padding,{top:1,right:2,bottom:3,left:4}) + assert.equal(result.nodes[1].status,'unavailable') + assert.equal(result.partial,true) + assert.equal(result.coordinateSpace,'figma-logical') +}) + +test('fixed measurement script refuses execution strings, malformed IDs, duplicates and excess batch before reads', async () => { + globalThis.figma={getNodeByIdAsync:()=>assert.fail('invalid IDs must never reach Figma')} + for (const rootIds of [[],['1:2;3:4'],['1:2;alert(1)'],['1:2','1:2'],Array.from({length:11},(_,i)=>`1:${i}`)]) { + await assert.rejects(measureNodes({rootIds}),/DEVUP_INVALID_INPUT/) + } +}) + +test('the compiled plugin refreshes page and selection without an event, retaining history only as diagnostics', async () => { + const bundle=await readFile(new URL('../dist/code.js',import.meta.url),'utf8') + const posted=[] + const figma={showUI(){},on(){},ui:{postMessage:m=>posted.push(m)},root:{name:'Synthetic'},fileKey:null, + currentPage:{id:'0:1',name:'First',type:'PAGE',selection:[{id:'1:1',name:'Old',type:'FRAME'}]},viewport:{zoom:0.83}} + vm.runInNewContext(bundle,{figma,__html__:'',console,setTimeout,clearTimeout}) + figma.ui.onmessage({kind:'devup-ready'}) + figma.currentPage={id:'0:2',name:'Current',type:'PAGE',selection:[{id:'2:2',name:'New',type:'FRAME'}]} + figma.ui.onmessage({kind:'devup-job',requestId:'fresh',script:'bridgeContext',params:{}}) + await new Promise(resolve=>setImmediate(resolve)) + const result=posted.find(m=>m.requestId==='fresh').data + assert.equal(result.currentPage.id,'0:2') + assert.equal(result.selection[0].id,'2:2') + assert.equal(result.zoom,0.83) + assert.equal(result.uiDevicePixelRatio,undefined) + figma.currentPage.selection=[] + figma.ui.onmessage({kind:'devup-job',requestId:'empty',script:'bridgeContext',params:{}}) + await new Promise(resolve=>setImmediate(resolve)) + const empty=posted.find(m=>m.requestId==='empty').data + assert.equal(empty.selectionCount,0) + assert.equal(empty.lastKnownSelection[0].id,'2:2') + assert.equal(empty.lastKnownPage.id,'0:2') +}) + +test('maximal bounded measurement batch stays below the direct text ceiling', async () => { + const node={type:'FRAME',width:1e12,height:1e12,paddingTop:-1e12,paddingRight:1e12,paddingBottom:1e12,paddingLeft:1e12,itemSpacing:-1e12,layoutMode:'A'.repeat(32),primaryAxisAlignItems:'A'.repeat(32),counterAxisAlignItems:'A'.repeat(32)} + globalThis.figma={getNodeByIdAsync:async()=>node} + const rootIds=Array.from({length:10},(_,i)=>`I${'9'.repeat(50)}:1;${'8'.repeat(50)}:${i}`) + const result=await measureNodes({rootIds}) + assert.equal(result.nodes.length,10) + assert.ok(Buffer.byteLength(JSON.stringify(result),'utf8')<19000) +})