From 566edf2878d9e731fd029b1a6399134b067b9c7a Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 17:12:15 +0900 Subject: [PATCH 01/11] Require complete runtime evidence and catch responsive reading-order and clipping failures --- .../changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json | 7 + .../changepack_log_qCx9bQYoBJWWTta31W1XI.json | 7 + crates/devup-mcp-devup-ui/src/ui_layout.rs | 80 +++- .../src/ui_validate/types_and_helpers.rs | 12 + .../server/audit_plan/planning_and_capture.rs | 29 ++ .../src/server/audit_plan/static_params.rs | 55 ++- .../devup-mcp/src/server/audit_plan_tests.rs | 15 + .../projection/delivered_property_audit.rs | 10 + .../runtime_audit/capture_comparison.rs | 105 ++++- .../runtime_audit/inputs_and_capture.rs | 62 ++- .../src/server/runtime_audit/tests.rs | 34 ++ .../src/server/runtime_audit/write_plan.rs | 4 + .../src/server/screen_plan/status.rs | 54 ++- .../screen_plan_tests/fixtures_and_cases.rs | 56 ++- .../src/server/skills/devup/SKILL.md | 398 +++++++++--------- .../src/server/text/devup-runtime-audit.md | 5 +- .../src/server/text/devup-screen-plan.md | 4 +- .../src/server/text/guide-GUIDE-c682b0c5.md | 3 + .../text/screen_plan-DONE_RULE-95793a55.md | 2 +- harness/runtime-audit/checks.mjs | 183 ++++++++ harness/runtime-audit/package.json | 2 +- harness/runtime-audit/run.mjs | 203 ++++++++- harness/runtime-audit/test/browser.test.mjs | 31 +- harness/runtime-audit/test/checks.test.mjs | 56 ++- harness/runtime-audit/test/layout.test.mjs | 60 +++ 25 files changed, 1237 insertions(+), 240 deletions(-) create mode 100644 .changepacks/changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json create mode 100644 .changepacks/changepack_log_qCx9bQYoBJWWTta31W1XI.json create mode 100644 harness/runtime-audit/test/layout.test.mjs diff --git a/.changepacks/changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json b/.changepacks/changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json new file mode 100644 index 00000000..79d672b8 --- /dev/null +++ b/.changepacks/changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Require complete URL-by-width runtime evidence, reading-order and responsive clipping checks before completion", + "date": "2026-10-19T08:11:39.299827700Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_qCx9bQYoBJWWTta31W1XI.json b/.changepacks/changepack_log_qCx9bQYoBJWWTta31W1XI.json new file mode 100644 index 00000000..78ab5578 --- /dev/null +++ b/.changepacks/changepack_log_qCx9bQYoBJWWTta31W1XI.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Verify rendered completeness, effective text contrast, and loaded image resources in runtime audits", + "date": "2026-10-19T08:04:41.792494500Z" +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/ui_layout.rs b/crates/devup-mcp-devup-ui/src/ui_layout.rs index 6a91afd2..8b927b8e 100644 --- a/crates/devup-mcp-devup-ui/src/ui_layout.rs +++ b/crates/devup-mcp-devup-ui/src/ui_layout.rs @@ -99,7 +99,7 @@ struct Collector<'s> { impl<'a> Visit<'a> for Collector<'_> { fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { - let attrs = string_attributes(&element.opening_element); + let attrs = string_attributes(&element.opening_element, self.source); let follows_viewport = match self.ancestors.last() { None => fluid_width(&attrs), Some(parent) => parent.follows_viewport && stretches_across(parent, &attrs), @@ -133,7 +133,7 @@ impl<'a> Visit<'a> for Collector<'_> { } } -fn string_attributes(element: &JSXOpeningElement<'_>) -> BTreeMap { +fn string_attributes(element: &JSXOpeningElement<'_>, source: &str) -> BTreeMap { element .attributes .iter() @@ -150,7 +150,10 @@ fn string_attributes(element: &JSXOpeningElement<'_>) -> BTreeMap text.value.to_string(), Expression::NumericLiteral(number) => number.value.to_string(), - _ => return None, + _ => { + let span = container.expression.span(); + source[span.start as usize..span.end as usize].to_owned() + } } } _ => return None, @@ -221,6 +224,44 @@ fn center_offset(left: &str) -> Option { impl Collector<'_> { fn analyze(&mut self, parent: &Element, children: &[Element]) { + fn direction(element: &Element) -> Option<&String> { + element + .attrs + .get("flexDir") + .or_else(|| element.attrs.get("flexDirection")) + } + let column = |element: &Element| { + element.tag == "VStack" + || direction(element).is_some_and(|value| value.contains("column")) + }; + if children + .iter() + .filter(|child| column(child) && child.has_children) + .count() + >= 2 + { + self.risks.push(json!({"rule":"column-reading-order", "confidence":"medium", "elementIndex":parent.ordinal, + "message":"Column wrappers can collapse cards in column order (01,03,02,04). Use one Grid or flex-wrap in design reading order, or explicit breakpoint order; run sequence-order at every width."})); + } + let row = parent.tag == "Flex" + && direction(parent).is_none_or(|value| !value.contains("column")) + || parent.tag == "Grid" + && parent + .attrs + .get("gridTemplateColumns") + .is_some_and(|value| value.contains(" ") || value.contains("repeat")); + let wraps = parent + .attrs + .get("flexWrap") + .is_some_and(|value| value.contains("wrap")); + let fixed_children = children + .iter() + .filter(|child| pixels(child.attrs.get("w")).is_some()) + .count(); + if row && !wraps && fixed_children >= 2 { + self.risks.push(json!({"rule":"narrow-fixed-columns", "confidence":"medium", "elementIndex":parent.ordinal, + "message":"A multi-column row keeps fixed child widths without a narrow column or wrap rule. Supply narrow responsive layout props and verify section-layout, overflow and clipped text at 390/858px."})); + } let width = pixels(parent.attrs.get("w")) .or_else(|| pixels(parent.attrs.get("boxSize"))) .or(self.reference_width); @@ -587,3 +628,36 @@ mod tests { assert_eq!(report["groupsWithoutReferenceWidth"], 1); } } + +#[cfg(test)] +mod responsive_failure_tests { + use super::*; + #[test] + fn columns_and_wide_only_fixed_rows_are_flagged() { + let columns = r#"const A = () => "#; + assert!( + layout_risks(columns, None)["risks"] + .as_array() + .unwrap() + .iter() + .any(|risk| risk["rule"] == "column-reading-order") + ); + let fixed = + r#"const A = () => "#; + assert!( + crate::ui_validate::validate_devup_ui_tsx(fixed, None, true) + .violations + .iter() + .any(|risk| risk.rule == "narrow-fixed-columns") + ); + let wrapping = + r#"const A = () => "#; + assert!( + !layout_risks(wrapping, None)["risks"] + .as_array() + .unwrap() + .iter() + .any(|risk| risk["rule"] == "narrow-fixed-columns") + ); + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs index d7e192a2..37189f68 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs @@ -150,6 +150,18 @@ fn validate_source( let checked_tokens = visitor.checked_tokens; let attributes = crate::provenance::attributes::generated_attributes(tsx); violations.extend(blur_layers::check(tsx, &attributes)); + for risk in crate::ui_layout::layout_risks(tsx, None)["risks"].as_array().into_iter().flatten() { + let rule = match risk["rule"].as_str() { + Some("narrow-fixed-columns") => "narrow-fixed-columns", + Some("column-reading-order") => "column-reading-order", + _ => continue, + }; + violations.push(Violation { rule, severity: Severity::Warning, byte_range: [0, 0], + context: risk.as_object().cloned().unwrap_or_default(), + message: risk["message"].as_str().unwrap_or_default().to_owned(), + suggestion: Some("Use reading-order DOM and explicit narrow layout props, then run the runtime audit at every required width.".into()) }); + } + let elements = crate::provenance::attributes::opening_elements(tsx); for violation in &mut violations { let [start, end] = violation.byte_range; diff --git a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs index 66e72d47..9abbbb68 100644 --- a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs +++ b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs @@ -307,7 +307,12 @@ pub(super) fn url_plan( // Health and containment at every width. let mut checks = vec![ + check(json!({"kind": "no-clipped-content", "label": "navigation and card content not cut off"})), + check(json!({"kind": "sequence-order", "label": "numbered cards in reading order"})), + check(json!({"kind": "section-layout", "label": "responsive sections and text columns"})), check(json!({"kind": "page-health", "label": "page health"})), + check(json!({"kind": "no-low-contrast-text", "label": "visible text contrast"})), + check(json!({"kind": "image-loaded", "label": "images and backgrounds loaded"})), check(json!({"kind": "no-horizontal-overflow", "label": "no horizontal overflow"})), check(json!({"kind": "no-clipped-text", "label": "no clipped text"})), ]; @@ -342,6 +347,7 @@ pub(super) fn url_plan( continue; }; let sections = visible_children(payload, frame_id); + let mut images = Vec::new(); let mut renders_missing = Vec::new(); for section in §ions { let name = name_of(payload, section); @@ -366,6 +372,14 @@ pub(super) fn url_plan( "subject": node(section), "container": node(frame_id), "nodeId": section}), )); } + if !is_asset && bounds(payload, section).is_some_and(|section_box| section_box.width >= frame_box.width * 0.5) { + layout.push(check(json!({"kind": "section-layout", "label": format!("{name} remains a visible section"), "subject": node_at(*width, section), "container": node_at(*width, frame_id), + "widths": viewports.iter().filter(|viewport| { + target.frames.iter().filter(|(drawn, _, _)| *drawn <= viewport.width).max_by_key(|(drawn, _, _)| *drawn) + .or_else(|| target.frames.iter().min_by_key(|(drawn, _, _)| *drawn)) + .is_some_and(|(drawn, _, _)| drawn == width) + }).map(|viewport| viewport.width).collect::>(), "nodeId": section}))); + } if let Some(section_box) = bounds(payload, section) { layout.push(bounds_check( &name, @@ -390,6 +404,9 @@ pub(super) fn url_plan( } } } + if is_asset { + images.push(section.clone()); + } let scope = if is_asset { "asset" } else { "section" }; match sources.native_render(section) { Some((artifact, asset)) => layout.push(capture( @@ -419,9 +436,13 @@ pub(super) fn url_plan( let is_asset = assets_everywhere.contains(&child) || payload.snapshot.nodes[&child].typed_view().bool("isAsset") == Some(true); if !is_asset { + if let Some(alignment) = text_alignment_check(payload, &child, *width) { + parts.push(alignment); + } stack.push((child, section.clone())); continue; } + images.push(child.clone()); let name = name_of(payload, &child); if let (Some(section_box), Some(asset_box)) = (bounds(payload, §ion), bounds(payload, &child)) @@ -449,6 +470,14 @@ pub(super) fn url_plan( } } } + checks.push(completeness_check( + *width, + frame_id, + frame_box.height, + §ions, + images, + &mut notes, + )); for batch in renders_missing.chunks(6) { missing.push(json!({"kind": "nativeRender", "frame": frame_id, "width": width, "nodeIds": batch, "why": "No native-node PNG at scale 1 for these sections or assets, so their visual comparison cannot run.", diff --git a/crates/devup-mcp/src/server/audit_plan/static_params.rs b/crates/devup-mcp/src/server/audit_plan/static_params.rs index d851c7d4..9987fa42 100644 --- a/crates/devup-mcp/src/server/audit_plan/static_params.rs +++ b/crates/devup-mcp/src/server/audit_plan/static_params.rs @@ -3,6 +3,40 @@ static FAILURE_CLASSES: std::sync::LazyLock> = std::sync::Lazy .expect("vendored failure classes") }); +/// The rendered-completeness check for one drawn width, listing at most +/// `MAX_COMPLETENESS_NODES` sections and images and noting what it left out. +fn completeness_check( + width: u32, + frame_id: &str, + frame_height: f64, + sections: &[String], + images: Vec, + notes: &mut Vec, +) -> CheckInput { + let limit = runtime_audit::MAX_COMPLETENESS_NODES; + let mut listed = |ids: &[String], kind: &str| { + if ids.len() > limit { + notes.push(format!( + "{} {kind} past the first {limit} left out of the completeness check at {width}px.", + ids.len() - limit + )); + } + ids.iter().take(limit).cloned().collect::>() + }; + let mut unique_images = Vec::new(); + for id in images { + if !unique_images.contains(&id) { + unique_images.push(id); + } + } + let section_ids = listed(sections, "sections"); + let image_ids = listed(&unique_images, "images"); + check(json!({"kind": "rendered-completeness", + "label": format!("page renders its {width}px frame"), + "nodeId": frame_id, "widths": [width], + "completeness": {"frameHeight": frame_height, "sections": section_ids, "images": image_ids}})) +} + /// `generateStaticParams` in the route's page: literal params in its body, /// else the param's values in a local data file it reads. pub(super) fn static_params( @@ -496,7 +530,7 @@ pub(super) fn work_queue( "why": decision["question"], "meanwhile": "work the rest; it is taken with assume at the end"})); } } - if row["implemented"] != "yes" { + if row["implemented"] == "no" { items.push(json!({"do": "build", "file": row["file"]})); } if let Some(reason) = plan["audit"]["inconclusive"][url].as_str() { @@ -526,7 +560,11 @@ pub(super) fn work_queue( items.push(json!({"do": "compare", "count": comparisons, "how": "run each visualComparisons call and pass the results back as visualResults"})); } - let done = items.is_empty(); + let cells_pass = row["widths"].as_array().is_some_and(|widths| !widths.is_empty() && widths.iter().all(|width| width["result"] == "pass")); + if !cells_pass && items.is_empty() { + items.push(json!({"do":"fix and rerun the audit", "why":"every required URL x width cell must pass before done"})); + } + let done = items.is_empty() && blocked.is_empty() && cells_pass && row["implemented"] == "yes"; items.extend(blocked); let reported = plan["audit"]["converterBugs"] .as_object() @@ -562,7 +600,7 @@ pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> Stri other => other.to_string(), }; let mut out = String::from( - "| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|\n", + "| URL | width | result | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|---|\n", ); let records = plan["audit"]["findings"].as_object(); for row in rows { @@ -592,8 +630,9 @@ pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> Stri .count() }; out.push_str(&format!( - "| {url} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", + "| {url} | {} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", width["width"], + cell(&width["result"]), cell(&columns["health"]), cell(&columns["geometry"]), count("code-drift"), @@ -784,3 +823,11 @@ pub(super) fn classify_geometry( } } } + +fn text_alignment_check(payload: &CollectedPayload, child: &str, width: u32) -> Option { + let view = payload.snapshot.nodes[child].typed_view(); + let edge = view.string("textAlignHorizontal")?.to_lowercase(); + if !["left", "right", "center", "justify"].contains(&edge.as_str()) { return None; } + Some(check(json!({"kind":"text-align", "label":format!("{} text alignment", name_of(payload, child)), + "subject":node_at(width, child), "nodeId":child, "edge":edge, "widths":[width]}))) +} diff --git a/crates/devup-mcp/src/server/audit_plan_tests.rs b/crates/devup-mcp/src/server/audit_plan_tests.rs index 496fbc60..4e3cd8b7 100644 --- a/crates/devup-mcp/src/server/audit_plan_tests.rs +++ b/crates/devup-mcp/src/server/audit_plan_tests.rs @@ -175,6 +175,9 @@ fn a_two_url_audit_plan_checks_the_page_its_sections_assets_geometry_overflow_an for plan in [&home.plan, &about.plan] { for kind in [ "page-health", + "sequence-order", + "section-layout", + "rendered-completeness", "no-horizontal-overflow", "no-clipped-text", "bounds", @@ -185,6 +188,18 @@ fn a_two_url_audit_plan_checks_the_page_its_sections_assets_geometry_overflow_an } } let checks = &home.plan.screens[0].checks; + for kind in ["no-low-contrast-text", "image-loaded"] { + assert!(checks.iter().any(|check| check.kind == kind && check.widths.is_empty())); + } + let completeness = checks.iter().filter(|check| check.kind == "rendered-completeness").collect::>(); + assert_eq!(completeness.len(), 2); + for check in completeness { + assert_eq!(check.widths.len(), 1); + let frame = check.completeness.as_ref().unwrap(); + assert!(frame.frame_height > 0.0); + assert!(!frame.sections.is_empty()); + } + let captures = checks .iter() .filter_map(|check| { diff --git a/crates/devup-mcp/src/server/projection/delivered_property_audit.rs b/crates/devup-mcp/src/server/projection/delivered_property_audit.rs index 371cc318..df2fbcad 100644 --- a/crates/devup-mcp/src/server/projection/delivered_property_audit.rs +++ b/crates/devup-mcp/src/server/projection/delivered_property_audit.rs @@ -23,6 +23,16 @@ fn audit_delivered_properties(value: &mut Value) { let Some(code) = value[field].as_str() else { continue; }; + let risks = devup_mcp_devup_ui::ui_layout::layout_risks(code, None)["risks"].clone(); + if let Some(risks) = risks.as_array() { + if !value["layoutRisks"].is_array() { value["layoutRisks"] = json!([]); } + for risk in risks { + let mut risk = risk.clone(); + risk["output"] = json!(field); + value["layoutRisks"].as_array_mut().unwrap().push(risk); + } + } + let code = value[field].as_str().unwrap(); let mut missing = devup_mcp_devup_ui::provenance::attributes::uncovered_attributes(code, &contract); let layout = &value["outputResults"][field]["fidelity"]["layout"]; diff --git a/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs b/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs index 2aaeb0cd..2dbb16d7 100644 --- a/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs +++ b/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs @@ -163,4 +163,107 @@ fn visual_comparison( "blockers": blockers, "note": include_str!("../text/runtime_audit-visual_comparison-8418b2cf.md"), }) -} \ No newline at end of file +} +/// What a page must render to count as built from its frame: the height the +/// frame was drawn at, every section, every image, and text a person can +/// see. Code on disk that renders a blank, a cut-short page, a broken image +/// or white-on-white text is written, not implemented. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct CompletenessInput { + /// The frame's height at this width, in Figma logical px. + pub frame_height: f64, + /// The rendered page must be at least this share of frameHeight tall; + /// default 0.9. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_height_ratio: Option, + /// Figma node ids of the frame's sections; each must render visible with + /// a non-empty box. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sections: Vec, + /// Figma node ids of the frame's images and icons; each must render with + /// a non-empty box, an decoded. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, + /// Lowest contrast ratio text may have against the background painted + /// behind it; default 1.5, below which text cannot be seen. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_contrast: Option, +} + +/// Most node ids one completeness check lists per kind. +pub(crate) const MAX_COMPLETENESS_NODES: usize = 200; + +fn validate_completeness(completeness: &CompletenessInput) -> Result<(), DevupError> { + if !completeness.frame_height.is_finite() + || completeness.frame_height <= 0.0 + || completeness.frame_height > 100_000.0 + { + return Err(invalid( + "completeness.frameHeight must be a positive finite px value.", + )); + } + if completeness + .min_height_ratio + .is_some_and(|ratio| !(ratio > 0.0 && ratio <= 1.0)) + { + return Err(invalid("completeness.minHeightRatio must be in (0, 1].")); + } + if completeness + .min_contrast + .is_some_and(|ratio| !(1.0..=21.0).contains(&ratio)) + { + return Err(invalid("completeness.minContrast must be in [1, 21].")); + } + for (field, ids) in [ + ("completeness.sections", &completeness.sections), + ("completeness.images", &completeness.images), + ] { + if ids.len() > MAX_COMPLETENESS_NODES { + return Err(invalid(format!( + "{field} lists at most {MAX_COMPLETENESS_NODES} node ids." + ))); + } + let mut seen = std::collections::BTreeSet::new(); + for id in ids { + bounded_text(&format!("{field}[]"), id)?; + if id.contains(['"', '\\', ']']) { + return Err(invalid(format!( + "{field}[] must be a Figma node id, without quotes, backslashes or brackets." + ))); + } + if !seen.insert(id.as_str()) { + return Err(invalid(format!("{field} repeats {id}."))); + } + } + } + Ok(()) +} + +/// At a drawn width, checks that pass on code rendering a blank or half a +/// page prove nothing: without a rendered-completeness check the width stays +/// not-run. +fn require_completeness( + row: &mut Value, + screen: &ScreenInput, + viewport: &ViewportInput, + judged: &[(&str, &CheckInput)], +) { + let drawn_here = screen + .figma + .as_ref() + .is_some_and(|figma| figma.drawn_widths.contains(&viewport.width)); + if drawn_here + && row["status"] == "pass" + && !judged + .iter() + .any(|(_, check)| check.kind == "rendered-completeness") + { + row["status"] = json!("not-run"); + row["reasons"] = json!([format!( + "no rendered-completeness check at {}px, a width the frame was drawn at; passing checks do not show the page renders its frame", + viewport.width + )]); + } +} diff --git a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs index 14a1f3a0..906b14a2 100644 --- a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs +++ b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs @@ -206,6 +206,10 @@ pub struct CheckInput { /// next screen should start from its own routePath. The plan writer /// moves it after the screen's other checks and the executor returns to /// the screen's URL after it, so order in the plan cannot break a run. + /// text-align: subject computed text alignment matches edge from Figma. + /// sequence-order: numbered siblings increase in visual reading order. + /// section-layout: visible sections span at least half their container. + /// no-clipped-content: internal clipped rows require an explicit carousel with controls. /// page-health: no error page text ("This page couldn't load", /// "Application error", ...), page error, console error or failed /// navigation while the screen was open. @@ -221,7 +225,14 @@ pub struct CheckInput { /// something visible; without a subject, every visible link and button. /// capture: screenshot subject's element box for devup_visual_compare /// against `reference`; ingest turns it into a ready call. - #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden"]))] + /// no-low-contrast-text: text against effective computed background colours. + /// image-loaded: every img and CSS background URL decodes with naturalWidth > 0. + /// Both accept an optional subject scope. + /// rendered-completeness: at one drawn width, the page is at least + /// minHeightRatio of the frame's height, every listed section and image + /// renders (images decoded), and no text is invisible against what is + /// painted behind it. A drawn width without a passing one is not-run. + #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden", "rendered-completeness", "sequence-order", "section-layout", "text-align", "no-clipped-content", "no-low-contrast-text", "image-loaded"]))] pub kind: String, #[serde(default)] pub subject: Option, @@ -266,6 +277,10 @@ pub struct CheckInput { /// capture: what the element screenshot is compared with. #[serde(default, skip_serializing_if = "Option::is_none")] pub reference: Option, + /// rendered-completeness: what the frame drawn at this width says must be + /// on the page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completeness: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -604,11 +619,11 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { Ok(()) }; match check.kind.as_str() { - "no-horizontal-overflow" | "page-health" => needs(false, false)?, + "no-horizontal-overflow" | "page-health" | "no-clipped-content" => needs(false, false)?, "visible" | "view-transition" | "hidden" => needs(true, false)?, - "no-clipped-text" => { + "no-clipped-text" | "no-low-contrast-text" | "image-loaded" => { if check.container.is_some() { - return Err(invalid("A no-clipped-text check takes no container.")); + return Err(invalid(format!("A {} check takes no container.", check.kind))); } } "bounds" => { @@ -673,6 +688,30 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { )); } } + "text-align" => { + needs(true, false)?; + if !matches!(check.edge.as_deref(), Some("left" | "right" | "center" | "justify")) { + return Err(invalid("A text-align check needs edge left, right, center or justify.")); + } + } + "sequence-order" => needs(false, false)?, + "section-layout" => { + if check.subject.is_some() { needs(true, true)?; } else { needs(false, false)?; } + } + "rendered-completeness" => { + needs(false, false)?; + let Some(completeness) = &check.completeness else { + return Err(invalid( + "A rendered-completeness check needs completeness {frameHeight, sections, images}.", + )); + }; + validate_completeness(completeness)?; + if check.widths.len() != 1 { + return Err(invalid( + "A rendered-completeness check holds one frame, so it names exactly one width.", + )); + } + } "contained" | "spans" => needs(true, true)?, "aligned" => { needs(true, true)?; @@ -688,6 +727,11 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { ("expected", check.expected.is_some(), "bounds"), ("state", check.state.is_some(), "interaction"), ("reference", check.reference.is_some(), "capture"), + ( + "completeness", + check.completeness.is_some(), + "rendered-completeness", + ), ] { if present && check.kind != kind { return Err(invalid(format!("{field} is only valid for {kind} checks."))); @@ -870,7 +914,7 @@ fn columns( let health = if unreached { "fail" } else { - fold(&["page-health", "no-horizontal-overflow", "no-clipped-text"]) + fold(&["page-health", "no-horizontal-overflow", "no-clipped-content", "no-clipped-text", "no-low-contrast-text", "image-loaded"]) }; let captures = |scope: &str| { let count = judged @@ -903,9 +947,15 @@ fn columns( .figma .as_ref() .is_some_and(|figma| figma.drawn_widths.contains(&viewport.width)); + let completeness = match fold(&["rendered-completeness"]) { + "-" if drawn => "not-run", + "-" => "not drawn", + other => other, + }; json!({ "health": health, - "geometry": fold(&["bounds", "dimensions", "contained", "aligned", "spans", "visible"]), + "completeness": completeness, + "geometry": fold(&["text-align", "sequence-order", "section-layout", "text-align", "bounds", "dimensions", "contained", "aligned", "spans", "visible"]), "interaction": fold(&["interaction", "view-transition"]), "page": if drawn { json!(if row["screenshot"].is_string() { "1 to compare" } else { "not captured" }) } else { json!("not drawn") }, "section": captures("section"), diff --git a/crates/devup-mcp/src/server/runtime_audit/tests.rs b/crates/devup-mcp/src/server/runtime_audit/tests.rs index edb0d9b2..135750c9 100644 --- a/crates/devup-mcp/src/server/runtime_audit/tests.rs +++ b/crates/devup-mcp/src/server/runtime_audit/tests.rs @@ -353,6 +353,23 @@ mod tests { ); } + #[test] + fn drawn_widths_need_completeness_before_they_can_pass() { + let mut screen = plan_input().screens.remove(0); + screen.figma = Some(serde_json::from_value(json!({"nodeId": "1:1", "drawnWidths": [1440]})).unwrap()); + let viewport: ViewportInput = serde_json::from_value(json!({"width": 1440, "height": 900})).unwrap(); + let mut missing = json!({"status": "pass", "reasons": []}); + require_completeness(&mut missing, &screen, &viewport, &[]); + assert_eq!(missing["status"], "not-run"); + let check: CheckInput = serde_json::from_value(json!({"kind": "rendered-completeness", "widths": [1440], "completeness": {"frameHeight": 2000}})).unwrap(); + let mut complete = json!({"status": "pass"}); + require_completeness(&mut complete, &screen, &viewport, &[("pass", &check)]); + assert_eq!(complete["status"], "pass"); + let mut failed = json!({"status": "fail"}); + require_completeness(&mut failed, &screen, &viewport, &[]); + assert_eq!(failed["status"], "fail"); + } + #[test] fn the_new_check_kinds_take_what_they_measure() { let ok = |check: Value| { @@ -364,6 +381,23 @@ mod tests { }; let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); assert!(ok(json!({"kind": "page-health"})).is_ok()); + for kind in ["no-low-contrast-text", "image-loaded"] { + assert!(ok(json!({"kind": kind})).is_ok()); + assert!(ok(json!({"kind": kind, "subject": node("1:2")})).is_ok()); + assert!(ok(json!({"kind": kind, "container": node("1:2")})).is_err()); + } + assert!(ok(json!({"kind": "rendered-completeness", "widths": [1440], "completeness": {"frameHeight": 2000, "sections": ["1:2"], "images": ["1:3"]}})).is_ok()); + for completeness in [ + json!({"frameHeight": 0}), + json!({"frameHeight": 100, "minHeightRatio": 1.1}), + json!({"frameHeight": 100, "minContrast": 22}), + json!({"frameHeight": 100, "sections": ["1:2", "1:2"]}), + json!({"frameHeight": 100, "images": ["bad]id"]}), + ] { + assert!(ok(json!({"kind": "rendered-completeness", "widths": [1440], "completeness": completeness})).is_err()); + } + assert!(ok(json!({"kind": "rendered-completeness", "completeness": {"frameHeight": 100}})).is_err()); + assert!(ok(json!({"kind": "no-clipped-text"})).is_ok()); assert!(ok(json!({"kind": "interaction", "state": "hover"})).is_ok()); assert!( diff --git a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs index 3b27a486..d4567c54 100644 --- a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs +++ b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs @@ -443,6 +443,7 @@ fn ingest_references( &environment_errors, &outcomes, ); + require_completeness(&mut row, screen, viewport, &judged); row["columns"] = columns(screen, viewport, &judged, &row); // A screen that never rendered fails as a runtime error, not as // the checks that could not measure it. @@ -916,6 +917,9 @@ fn array_text(value: &Value) -> String { pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { match kind { "page-health" => "runtime-error", + "image-loaded" => "asset-mismatch", + "no-low-contrast-text" => "section-visual", + "rendered-completeness" => "page-visual", "no-horizontal-overflow" | "no-clipped-text" => "overflow", "bounds" if reason.starts_with("position") => "layout-position", "bounds" | "dimensions" | "visible" => "layout-size", diff --git a/crates/devup-mcp/src/server/screen_plan/status.rs b/crates/devup-mcp/src/server/screen_plan/status.rs index 1825ec64..2b814e92 100644 --- a/crates/devup-mcp/src/server/screen_plan/status.rs +++ b/crates/devup-mcp/src/server/screen_plan/status.rs @@ -1,5 +1,25 @@ +/// A page file on disk is only written. It is implemented once the browser +/// rendered it complete (rendered-completeness passed) at every width its +/// frame was drawn at. +fn implemented_state(page_exists: bool, drawn: &Value, per_width: &[Value]) -> &'static str { + if !page_exists { + return "no"; + } + let drawn = array(drawn) + .iter() + .filter_map(Value::as_u64) + .collect::>(); + let complete = !drawn.is_empty() + && drawn.iter().all(|width| { + per_width.iter().any(|row| { + row["width"] == *width && row["result"] == "pass" && row["columns"]["completeness"] == "pass" + }) + }); + if complete { "yes" } else { "written-unverified" } +} + /// The pure half of `status`, so the not-run rule is tested on its own. pub(super) fn status_report( plan: &Value, @@ -33,19 +53,10 @@ pub(super) fn status_report( .iter() .find(|(pattern, _)| pattern == url || super::navigation::matches_route(pattern, url)) .map(|(_, page)| page.clone()); - let mut row_widths = if audited { - widths.clone() - } else { - DEFAULT_WIDTHS - .iter() - .map(|width| u64::from(*width)) - .chain( - array(&route["drawnWidths"]) - .iter() - .filter_map(Value::as_u64), - ) - .collect::>() - }; + let mut row_widths = DEFAULT_WIDTHS.iter().map(|width| u64::from(*width)) + .chain(widths.iter().copied()) + .chain(array(&route["drawnWidths"]).iter().filter_map(Value::as_u64)) + .collect::>(); row_widths.sort_unstable_by(|a, b| b.cmp(a)); row_widths.dedup(); let per_width = row_widths @@ -83,7 +94,7 @@ pub(super) fn status_report( "figmaScreen": route["name"], "nodeIds": route["nodeIds"], "file": page.clone().map_or_else(|| route["file"].clone(), Value::from), - "implemented": if page.is_some() { "yes" } else { "no" }, + "implemented": implemented_state(page.is_some(), &route["drawnWidths"], &per_width), "widths": per_width, })); } @@ -137,10 +148,19 @@ pub(super) fn status_report( problems.push(format!("{} {kind} at {}", text(&row["url"]), at.join(", "))); } } + let written = rows + .iter() + .filter(|row| row["implemented"] == "written-unverified") + .count(); let mut summary = format!( "{} URLs: {implemented} implemented, {verified} verified at all widths", rows.len() ); + if written > 0 { + summary.push_str(&format!( + ", {written} written but not rendered complete at every drawn width" + )); + } for problem in &problems { summary.push_str(&format!(", {problem}")); } @@ -186,10 +206,12 @@ pub(super) fn status_report( .filter(|width| width["result"] == "fail") .map(|width| width["width"].to_string()) .collect::>(); - let state = if row["implemented"] != "yes" { + let state = if row["implemented"] == "no" { "not implemented".to_owned() } else if !failing.is_empty() { format!("fails at {}", failing.join(", ")) + } else if row["implemented"] == "written-unverified" { + "written, not rendered complete".to_owned() } else if passed.len() == row_widths.len() { "verified".to_owned() } else { @@ -295,7 +317,7 @@ pub(super) fn status_report( "designFixes": design_fixes, "excluded": excluded, "inventoryNote": evidence.inventory_note, - "rule": "A width without a runtime audit item is not-run, never pass. Implemented means an App Router page exists for the URL, not that it renders.", + "rule": "A width without a runtime audit item is not-run, never pass. Implemented requires rendered completeness at every drawn width; done requires every URL at every required width to pass.", }) } diff --git a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs index a08c111d..264e1f6d 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs @@ -634,18 +634,42 @@ fn status_marks_widths_without_an_audit_item_not_run() { let report = status_report(&confirmed_plan(), &evidence(), Some(&audit)); let rows = array(&report["rows"]); assert_eq!(rows.len(), 2); - assert_eq!(rows[0]["implemented"], "yes"); + assert_eq!( + rows[0]["implemented"], "written-unverified", + "a page file without a passing rendered-completeness check is only written" + ); assert_eq!(rows[1]["implemented"], "no"); assert_eq!(rows[0]["widths"][0]["result"], "pass"); assert_eq!(rows[0]["widths"][1]["result"], "not-run"); - assert_eq!(rows[1]["widths"][1]["result"], "fail"); + assert_eq!(rows[1]["widths"][3]["result"], "fail"); assert!( text(&report["summary"]).starts_with( - "2 URLs: 1 implemented, 0 verified at all widths, /about overflows at 390" + "2 URLs: 0 implemented, 0 verified at all widths, 1 written but not rendered complete at every drawn width, /about overflows at 390" ), "{}", report["summary"] ); + assert!(text(&report["markdownTable"]).contains("written, not rendered complete")); + + let complete = json!({ + "verdict": "pass", + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + {"screen": "main", "routePath": "/", "width": 390, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + ], + }); + let rendered = status_report(&confirmed_plan(), &evidence(), Some(&complete)); + assert_eq!(rendered["rows"][0]["implemented"], "yes"); + let half = json!({ + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + {"screen": "main", "routePath": "/", "width": 390, "status": "fail", "reasons": ["rendered-completeness: the page is 300px tall"], "columns": {"completeness": "fail"}}, + ], + }); + let blank = status_report(&confirmed_plan(), &evidence(), Some(&half)); + assert_eq!(blank["rows"][0]["implemented"], "written-unverified"); assert_eq!(report["states"][0]["hostUrl"], "/"); assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); @@ -867,4 +891,28 @@ async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { .insert("review:12:1".into(), AnswerInput::One("/home".into())); let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); assert_eq!(confirmed["routes"][0]["url"], "/home"); -} \ No newline at end of file +} +#[test] +fn a_partial_report_cannot_drop_required_widths_or_claim_done() { + let report = status_report(&confirmed_plan(), &evidence(), Some(&json!({ + "widthTable":[{"width":1440}], + "screenTable":[{"routePath":"/", "width":1440, "status":"pass", "columns":{"completeness":"pass"}}] + }))); + let widths = array(&report["rows"][0]["widths"]); + assert_eq!(widths.iter().map(|row| row["width"].as_u64().unwrap()).collect::>(), [1440,1100,858,390]); + assert!(widths[1..].iter().all(|row| row["result"] == "not-run")); + assert!(text(&report["finalReport"]).contains("not-run")); + assert_eq!(report["workQueue"][0]["done"], false); +} + +#[test] +fn site_structure_and_url_questions_are_all_in_the_initial_rounds() { + let mut project = evidence(); + project.config = PlanConfig::default(); + let plan = draft_plan(&main_widths(), &BTreeMap::new(), &project, json!({}), None).unwrap(); + let ids = question_ids(&plan); + assert!(ids.iter().any(|id| id.starts_with("site:"))); + assert!(ids.iter().any(|id| id.starts_with("group:"))); + assert!(ids.iter().any(|id| id.starts_with("split-route:"))); + assert_rounds_cover(&plan); +} diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index 401b3a63..0b8637ce 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -1,198 +1,200 @@ ---- -name: devup -description: | - Start any DevFive project, feature, bug fix, Figma page implementation or - refactor with evidence-based project intake, all questions up front, ERD - models, a confirmed plan and supervisor work units. The Figma branch retains - screen planning, latest-first updates, vinext, exact layout, interaction states, - metadata, runtime/visual audits and the final URL x width table. - - Invoke as /devup [sources] [goal]. Also use the Figma branch when asked to - build pages or a site from a design, even without an explicit /devup. ---- - -## Project intake comes first - -`/devup` is the entry point for new projects, existing features, bug fixes, -Figma page implementation and refactors. Figma screen planning is a branch -of the confirmed project plan; complete this section before the screen workflow below. - -Read sources before asking: `devup_project_context` for every supported scope, -Atlassian MCP `getJiraIssue` / `searchJiraIssuesUsingJql` for issues, epic children -and dependencies, referenced Confluence/spec documents, and Figma exploration -(screen names, field labels, forms, tables and detail screens). Pass each read as -`evidence: [{source, facts}]` to `devup_project_plan`. Facts use `goal`, `parts`, -`figma` (links), `screens` ([{name, fields}]), and `.screens`, `.states`, -`.permissions`, `.forms`, `.notifications`. Backend decisions use `erd`, `tenancy`, -`auth`, `lifecycle`, `indexes`, `storage`; delivery uses `jiraPolicy`, `branchPolicy`, -`prPolicy`. A sourced fact is a default with its provenance, not another question. -Sources are read by the runtime's connectors, never by treating a URL as its contents. - -1. `devup_project_plan intake` with projectRoot: always ask the four initial - questions with the runtime question tool (recommended option first): work type - (refactor may be a custom answer), optional Figma/Jira/spec sources, one-sentence - goal, and parts. Existing `apis/` recommends front + admin + API; - `apps/admin` recommends front + admin. Retain planId on every later call. -2. Read newly named sources. `draft` with all initial answers, source-labelled - evidence and `features: [{id,scope,screens,acceptance}]` or - `issues: [{key,scope,dependencies,acceptance}]` (epic children are issues too). - Each unresolved feature gets concrete screen/role, state, permission, - validation, notification/integration rounds. Put all remaining unknowns into - extraQuestions. No implementation starts while a question remains unanswered. -3. API in scope: inspect existing `vespertide.json` and `apis/*/models/*.json`. - Draft complete `entities` as vespertide model objects and `relations` with - from/to, cardinality oneToMany|manyToMany, column/joinTable and onDelete. - Signup/post Figma evidence seeds user/post entities and a user FK, but the - agent must expand every actual domain from the source evidence. Review the - ERD round, ownership/tenancy, auth with integer roles (0,10,50,100), status - enums, soft delete, audit timestamps, nullable fields, unique/composite keys, - filter/sort indexes, file/image storage and cascade/restrict/set_null actions. - Read https://github.com/dev-five-git/vespertide/blob/main/SKILL.md and schemas/. - Use snake_case, `$schema` on every model, inline PK/FK/unique/index constraints, - and table constraints only for CHECK. A manyToMany relation generates its join - table. modelsDir is relative to projectRoot; set it explicitly for multiple APIs. - Re-draft structured entities for requested corrections before confirming. -4. When Figma is present, run the existing screen-plan draft/rounds/confirm - workflow (routes, groups, exclusions, site facts) before project confirm. -5. Ask all delivery questions now: Jira none/comment/transition, branch, PR. - `devup_project_plan confirm` requires every applicable answer and writes source - models plus confirmed state in one transaction. Then in modelsWorkingDirectory - run the returned commands, in order: `vespertide diff`, `vespertide sql`, - `vespertide revision -m "confirmed project plan"`, `vespertide export --orm seaorm`. - The tool returns commands; the runtime executes and checks them. Never hand-write - migrations/ or src/models/. Build vespera CRUD and feature routes, openapi.json, - and the devup-api client; `devup_stack_diff` must show zero drift before done. -6. Latest-first updates, vinext, exact layout, all interaction/site defaults, - runtime/visual audits and the existing never-wait rules below still apply. - After confirm, use project-plan defer with id/reason/recommended/destructive; - continue independent work. At the end assume with no decisions resolves all - pending recommendations; destructive decisions remain unbuilt. Status includes - ERD Mermaid, decisions and the unit table; retain unresolved evidence in reports. - -## Supervisor execution - -`devup_project_plan split` splits work, not files. Jira input produces one unit -per issue/sub-task with its explicit dependencies (reject cycles/unknown IDs). -Without Jira, it produces shared layout/theme first, then each feature's -models -> API -> client -> front chain; front-only features depend on shared. -Review cross-feature dependencies before dispatch; the supervisor sequences -shared database work to avoid concurrent schema edits. Each unit has acceptance -criteria and build/lint/typecheck/tests/validate/runtime/visual/stack-diff checks. -`.devup/units/.md` contains scope, decisions, ERD and screen context, and checklist. - -Default `unitQuestions: "supervisor"` means workers never ask the user: questions -were settled across all units during intake, and supervisor ownership avoids -repeated questions and keeps decisions consistent. `unitQuestions: "worker"` -is an explicit alternative, still subject to the confirmed never-wait policy. -Load `orca-cli` and `orchestration` skills to create one worktree per unit, -dispatch the DAG in parallel where independent, collect worker_done, and rerun -each unit's checks yourself before merging. New decisions use orchestration -ask/reply to the supervisor: answer from recorded decisions, or defer with a -recommended option while the worker continues other work. Send failed checks -back to the same worker session. Update unit states with status unitUpdates -(pending -> running -> verifying -> merged, or unresolved); merged needs recorded -supervisor verification. Without Orca, execute the same units sequentially in one -session. Only under the recorded Jira/PR policy, comment results and PR link and -transition issues. Final report includes the unit table, DAG, ERD and unresolved -items; never claim completion from split or command generation alone. - - - -# /devup [sources] [goal] - -Arguments: $ARGUMENTS - optional Figma links, Jira keys, spec URLs/files and a -one-sentence goal. With one Devup Bridge plugin, its selection supplies the -Figma anchor when a Figma implementation has no link. - -This is the devup-implementation-plan workflow, started explicitly. Follow it -in this order and do not skip a step. The devup-implementation-plan skill has -the full rules; these are the steps. - -1. **Connection.** `devup_figma_auth` with `action: "status"`. With a Bridge - plugin attached nothing needs a login; do not ask for one. -2. **Skills.** `devup_skills` with `action: "status"` and this `projectRoot`; - if anything is missing or outdated, `action: "install"`, then load the - installed skills (devup-ui, devfive-frontend, devup-implementation-plan). -3. **Project context.** `devup_project_context` with `scope: "theme"` (the - tokens the code must use) and `scope: "routes"` (the pages that exist, and - `frameworks`: which app runs on vinext or next). No app yet: the plan - starts from `dev-five-git/service-template`. With several apps the plan - picks the one the frames are for by meaning - the public site unless the - frames clearly say admin. -4. **Screen plan.** `devup_screen_plan` with `action: "draft"`, `projectRoot`, - the link, the notes and `extraQuestions` - every question you would - otherwise have to guess at any point in the job (form target, contact - details, external links, legal pages, data source, language). It returns - the screens, the excluded areas, the defaults it will fill in, and every - open question - routes, sections and structure, the site questions - (favicon, share image, site name and URL, analytics, scroll reveal) and - yours. -5. **Ask everything now, with your question tool.** The flat `questions` list - in one call if your tool has no per-call cap; otherwise `questionRounds` - back to back. Never in prose, never one decision at a time, never later. -6. **Confirm.** `devup_screen_plan` with `action: "confirm"`, the `planId` and - every answer that applies. -7. **Latest first, before implementing, without asking.** - a. At the repository root, run `bunx @dependency-check-updates/cli -d -u - --rm`. It updates every manifest in the repository to the latest - versions - every bun workspace and every Cargo.toml - in one recoverable - batch (`--recover rollback` restores the originals), and `--rm` removes - each lockfile and installed directory (`bun.lock`, `node_modules/`, - `Cargo.lock`, `target/`) so the next install resolves everything fresh. - b. `bun install`, then build, lint and typecheck. If the update breaks - something, fix the code to the new APIs. Pin a version back only when - no fix exists, and record the pin with its reason. - c. Before using a library API, check its current docs (context7 or the - library's site), not memory: what you remember is assumed stale. - Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: - { bumped, pins }`); status shows it as the dependencies line. Skip only - when `.devup/screen-plan.config.json` says `updateDependencies: false`. -8. **Implement URL by URL without asking anything.** `devup_figma_export` with - `projectRoot` and each screen's `frameIds`; `pageScaffold` takes its route - from the plan. Add the plan's `defaults` as you go: each interactive - element's role recipe (hover, pressed, focus ring, current page, expanded, - disabled, loading - restrained: at most two properties per state, - 150-250ms, existing tokens only), one shared transition, `_motionReduce`, - view transitions by the plan's mechanism, root and per-page `metadata` with - openGraph, `lang`, alt text, image dimensions, a not-found page. Run - `devup_ui_validate` with `projectRoot` on what you wrote. Anything new: - take the recommended or most conservative option and record it with - `action: "assume"`. -9. **Audit plan.** `devup_screen_plan` with `action: "auditPlan"`, - `baseUrl` and `artifacts` (every export artifactId of the plan's frames). - One plan per URL: health, overflow and clipped text at every width; the - page, each section and each asset against its Figma render and the size - and position of each section, card and asset at the drawn widths - - exact, half a pixel of rounding and no tolerance; merged pages per width - (`data-figma-node-ids`); dynamic routes at concrete URLs; hover - and focus; a view-transition check, last. Export whatever - `missingReferences` names and run auditPlan again. -10. **Run, status, fix - URL by URL.** Run each plan with the runner it wrote, - then `action: "status"` with `auditPlanPath` and `auditReportPath`. Run - every `visualComparisons` call and pass the results back as - `visualResults`. Fix each finding (its class, node, file and fix hint) in - `workQueue` order and re-run. A geometry mismatch is `code-drift` (restore - the export's values) or `converter-bug` (repro in `.devup/converter-bugs/`, - reported, never retried). After `maxFixAttempts` (default 3) a finding - is unresolved with its evidence. Runner cannot install: - `toolingUnavailable`, and carry on. -11. **Never stop to wait.** A decision mid-run: `action: "defer"` with the - recommended option, keep working, take it with `assume` at the end; a - destructive one stays unbuilt. -12. **Done = zero fails.** Every check and comparison passes at the drawn - widths and health and overflow pass at every width, or each remaining - finding is a filed converter bug or unresolved with evidence. Also: every - URL implemented, the production build free of `data-figma-node-id` (the - strip plugin in vite.config; `grep -rl data-figma-node-id dist` finds - nothing), `bun run build`, `bun run lint`, `bunx oxlint --deny-warnings`, `bun tsc --noEmit` - per changed app, `bun test` (on Windows `bun test` and `cargo test`), - `devup_ui_validate` without errors, site completeness filled. -13. **Final report.** End with status's `finalReport` and `summary`: URL x - width (health, geometry, page, section, asset, interaction, screenshot, - diffs), unresolved findings with evidence, pending decisions taken, - assumptions, design fixes, exclusions, defaults and the dependencies line. - -Hard rules: visually verify every screen, section and asset at every drawn -width; never stop to wait, reorder the work; done = zero fails, or listed as -unresolved with evidence; layout is exact, a mismatch is drift or a -converter bug. +--- +name: devup +description: | + Start any DevFive project, feature, bug fix, Figma page implementation or + refactor with evidence-based project intake, all questions up front, ERD + models, a confirmed plan and supervisor work units. The Figma branch retains + screen planning, latest-first updates, vinext, exact layout, interaction states, + metadata, runtime/visual audits and the final URL x width table. + + Invoke as /devup [sources] [goal]. Also use the Figma branch when asked to + build pages or a site from a design, even without an explicit /devup. +--- + +## Project intake comes first + +`/devup` is the entry point for new projects, existing features, bug fixes, +Figma page implementation and refactors. Figma screen planning is a branch +of the confirmed project plan; complete this section before the screen workflow below. + +Read sources before asking: `devup_project_context` for every supported scope, +Atlassian MCP `getJiraIssue` / `searchJiraIssuesUsingJql` for issues, epic children +and dependencies, referenced Confluence/spec documents, and Figma exploration +(screen names, field labels, forms, tables and detail screens). Pass each read as +`evidence: [{source, facts}]` to `devup_project_plan`. Facts use `goal`, `parts`, +`figma` (links), `screens` ([{name, fields}]), and `.screens`, `.states`, +`.permissions`, `.forms`, `.notifications`. Backend decisions use `erd`, `tenancy`, +`auth`, `lifecycle`, `indexes`, `storage`; delivery uses `jiraPolicy`, `branchPolicy`, +`prPolicy`. A sourced fact is a default with its provenance, not another question. +Sources are read by the runtime's connectors, never by treating a URL as its contents. + +1. `devup_project_plan intake` with projectRoot: always ask the four initial + questions with the runtime question tool (recommended option first): work type + (refactor may be a custom answer), optional Figma/Jira/spec sources, one-sentence + goal, and parts. Existing `apis/` recommends front + admin + API; + `apps/admin` recommends front + admin. Retain planId on every later call. +2. Read newly named sources. `draft` with all initial answers, source-labelled + evidence and `features: [{id,scope,screens,acceptance}]` or + `issues: [{key,scope,dependencies,acceptance}]` (epic children are issues too). + Each unresolved feature gets concrete screen/role, state, permission, + validation, notification/integration rounds. Put all remaining unknowns into + extraQuestions. No implementation starts while a question remains unanswered. +3. API in scope: inspect existing `vespertide.json` and `apis/*/models/*.json`. + Draft complete `entities` as vespertide model objects and `relations` with + from/to, cardinality oneToMany|manyToMany, column/joinTable and onDelete. + Signup/post Figma evidence seeds user/post entities and a user FK, but the + agent must expand every actual domain from the source evidence. Review the + ERD round, ownership/tenancy, auth with integer roles (0,10,50,100), status + enums, soft delete, audit timestamps, nullable fields, unique/composite keys, + filter/sort indexes, file/image storage and cascade/restrict/set_null actions. + Read https://github.com/dev-five-git/vespertide/blob/main/SKILL.md and schemas/. + Use snake_case, `$schema` on every model, inline PK/FK/unique/index constraints, + and table constraints only for CHECK. A manyToMany relation generates its join + table. modelsDir is relative to projectRoot; set it explicitly for multiple APIs. + Re-draft structured entities for requested corrections before confirming. +4. When Figma is present, run the existing screen-plan draft/rounds/confirm + workflow (routes, groups, exclusions, site facts) before project confirm. +5. Ask all delivery questions now: Jira none/comment/transition, branch, PR. + `devup_project_plan confirm` requires every applicable answer and writes source + models plus confirmed state in one transaction. Then in modelsWorkingDirectory + run the returned commands, in order: `vespertide diff`, `vespertide sql`, + `vespertide revision -m "confirmed project plan"`, `vespertide export --orm seaorm`. + The tool returns commands; the runtime executes and checks them. Never hand-write + migrations/ or src/models/. Build vespera CRUD and feature routes, openapi.json, + and the devup-api client; `devup_stack_diff` must show zero drift before done. +6. Latest-first updates, vinext, exact layout, all interaction/site defaults, + runtime/visual audits and the existing never-wait rules below still apply. + After confirm, use project-plan defer with id/reason/recommended/destructive; + continue independent work. At the end assume with no decisions resolves all + pending recommendations; destructive decisions remain unbuilt. Status includes + ERD Mermaid, decisions and the unit table; retain unresolved evidence in reports. + +## Supervisor execution + +`devup_project_plan split` splits work, not files. Jira input produces one unit +per issue/sub-task with its explicit dependencies (reject cycles/unknown IDs). +Without Jira, it produces shared layout/theme first, then each feature's +models -> API -> client -> front chain; front-only features depend on shared. +Review cross-feature dependencies before dispatch; the supervisor sequences +shared database work to avoid concurrent schema edits. Each unit has acceptance +criteria and build/lint/typecheck/tests/validate/runtime/visual/stack-diff checks. +`.devup/units/.md` contains scope, decisions, ERD and screen context, and checklist. + +Default `unitQuestions: "supervisor"` means workers never ask the user: questions +were settled across all units during intake, and supervisor ownership avoids +repeated questions and keeps decisions consistent. `unitQuestions: "worker"` +is an explicit alternative, still subject to the confirmed never-wait policy. +Load `orca-cli` and `orchestration` skills to create one worktree per unit, +dispatch the DAG in parallel where independent, collect worker_done, and rerun +each unit's checks yourself before merging. New decisions use orchestration +ask/reply to the supervisor: answer from recorded decisions, or defer with a +recommended option while the worker continues other work. Send failed checks +back to the same worker session. Update unit states with status unitUpdates +(pending -> running -> verifying -> merged, or unresolved); merged needs recorded +supervisor verification. Without Orca, execute the same units sequentially in one +session. Only under the recorded Jira/PR policy, comment results and PR link and +transition issues. Final report includes the unit table, DAG, ERD and unresolved +items; never claim completion from split or command generation alone. + + + +# /devup [sources] [goal] + +Arguments: $ARGUMENTS - optional Figma links, Jira keys, spec URLs/files and a +one-sentence goal. With one Devup Bridge plugin, its selection supplies the +Figma anchor when a Figma implementation has no link. + +This is the devup-implementation-plan workflow, started explicitly. Follow it +in this order and do not skip a step. The devup-implementation-plan skill has +the full rules; these are the steps. + +1. **Connection.** `devup_figma_auth` with `action: "status"`. With a Bridge + plugin attached nothing needs a login; do not ask for one. +2. **Skills.** `devup_skills` with `action: "status"` and this `projectRoot`; + if anything is missing or outdated, `action: "install"`, then load the + installed skills (devup-ui, devfive-frontend, devup-implementation-plan). +3. **Project context.** `devup_project_context` with `scope: "theme"` (the + tokens the code must use) and `scope: "routes"` (the pages that exist, and + `frameworks`: which app runs on vinext or next). No app yet: the plan + starts from `dev-five-git/service-template`. With several apps the plan + picks the one the frames are for by meaning - the public site unless the + frames clearly say admin. +4. **Screen plan.** `devup_screen_plan` with `action: "draft"`, `projectRoot`, + the link, the notes and `extraQuestions` - every question you would + otherwise have to guess at any point in the job (form target, contact + details, external links, legal pages, data source, language). It returns + the screens, the excluded areas, the defaults it will fill in, and every + open question - routes, sections and structure, the site questions + (favicon, share image, site name and URL, analytics, scroll reveal) and + yours. +5. **Ask everything now, with your question tool.** The flat `questions` list + in one call if your tool has no per-call cap; otherwise `questionRounds` + back to back. Never in prose, never one decision at a time, never later. +6. **Confirm.** `devup_screen_plan` with `action: "confirm"`, the `planId` and + every answer that applies. +7. **Latest first, before implementing, without asking.** + a. At the repository root, run `bunx @dependency-check-updates/cli -d -u + --rm`. It updates every manifest in the repository to the latest + versions - every bun workspace and every Cargo.toml - in one recoverable + batch (`--recover rollback` restores the originals), and `--rm` removes + each lockfile and installed directory (`bun.lock`, `node_modules/`, + `Cargo.lock`, `target/`) so the next install resolves everything fresh. + b. `bun install`, then build, lint and typecheck. If the update breaks + something, fix the code to the new APIs. Pin a version back only when + no fix exists, and record the pin with its reason. + c. Before using a library API, check its current docs (context7 or the + library's site), not memory: what you remember is assumed stale. + Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: + { bumped, pins }`); status shows it as the dependencies line. Skip only + when `.devup/screen-plan.config.json` says `updateDependencies: false`. +8. **Implement URL by URL without asking anything.** `devup_figma_export` with + `projectRoot` and each screen's `frameIds`; `pageScaffold` takes its route + from the plan. Add the plan's `defaults` as you go: each interactive + element's role recipe (hover, pressed, focus ring, current page, expanded, + disabled, loading - restrained: at most two properties per state, + 150-250ms, existing tokens only), one shared transition, `_motionReduce`, + view transitions by the plan's mechanism, root and per-page `metadata` with + openGraph, `lang`, alt text, image dimensions, a not-found page. Run + `devup_ui_validate` with `projectRoot` on what you wrote. Anything new: + take the recommended or most conservative option and record it with + `action: "assume"`. +9. **Audit plan.** `devup_screen_plan` with `action: "auditPlan"`, + `baseUrl` and `artifacts` (every export artifactId of the plan's frames). + One plan per URL: health, overflow and clipped text at every width; the + page, each section and each asset against its Figma render and the size + and position of each section, card and asset at the drawn widths - + exact, half a pixel of rounding and no tolerance; merged pages per width + (`data-figma-node-ids`); dynamic routes at concrete URLs; hover + and focus; a view-transition check, last. Export whatever + `missingReferences` names and run auditPlan again. +10. **Run, status, fix - URL by URL.** Run each plan with the runner it wrote, + then `action: "status"` with `auditPlanPath` and `auditReportPath`. Run + every `visualComparisons` call and pass the results back as + `visualResults`. Fix each finding (its class, node, file and fix hint) in + `workQueue` order and re-run. A geometry mismatch is `code-drift` (restore + the export's values) or `converter-bug` (repro in `.devup/converter-bugs/`, + reported, never retried). After `maxFixAttempts` (default 3) a finding + is unresolved with its evidence. Runner cannot install: + `toolingUnavailable`, and carry on. +11. **Never stop to wait.** A decision mid-run: `action: "defer"` with the + recommended option, keep working, take it with `assume` at the end; a + destructive one stays unbuilt. +12. **Done = zero fails.** Every check and comparison passes at the drawn + widths and health and overflow pass at every width, or each remaining + finding is a filed converter bug or unresolved with evidence. Also: every + URL implemented, the production build free of `data-figma-node-id` (the + strip plugin in vite.config; `grep -rl data-figma-node-id dist` finds + nothing), `bun run build`, `bun run lint`, `bunx oxlint --deny-warnings`, `bun tsc --noEmit` + per changed app, `bun test` (on Windows `bun test` and `cargo test`), + `devup_ui_validate` without errors, site completeness filled. +13. **Final report.** End with status's `finalReport` and `summary`: URL x + width (health, geometry, page, section, asset, interaction, screenshot, + diffs), unresolved findings with evidence, pending decisions taken, + assumptions, design fixes, exclusions, defaults and the dependencies line. + +Hard rules: visually verify every screen, section and asset at every drawn +width; never stop to wait, reorder the work; done = zero fails, or listed as +unresolved with evidence; layout is exact, a mismatch is drift or a +converter bug. + +Run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read its report, fix and rerun until every cell passes. Missing cells are not-run and block done; require rendered completeness and page/section/asset comparisons at drawn widths. Ask all site, structure and URL questionRounds back to back up front before implementation; never ask a later round during the build. diff --git a/crates/devup-mcp/src/server/text/devup-runtime-audit.md b/crates/devup-mcp/src/server/text/devup-runtime-audit.md index 06e69460..7f4f8601 100644 --- a/crates/devup-mcp/src/server/text/devup-runtime-audit.md +++ b/crates/devup-mcp/src/server/text/devup-runtime-audit.md @@ -1 +1,4 @@ -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. 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, hidden, and bounds - size and position against the Figma frame, exact up to 0.5px of rounding) plus page-health, no-clipped-text, interaction, capture and view-transition (follow a link and confirm a view transition ran) - 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. \ No newline at end of file +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. 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, hidden, and bounds - size and position against the Figma frame, exact up to 0.5px of rounding) plus page-health, no-clipped-text, interaction, capture and view-transition (follow a link and confirm a view transition ran) - 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. +Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. + +Rendered completeness requires the frame height (default minimum 90%), listed sections and images, visible text contrast (default minimum 1.5:1), and decoded image resources. no-low-contrast-text checks computed text colours against composited ancestor backgrounds, including browser default control backgrounds. Image or unsupported colour backgrounds are inconclusive, never silently passed. image-loaded checks every img and CSS background URL (including pseudo-elements), requiring naturalWidth > 0; lazy images are loaded for the audit. Both checks accept an optional subject scope and run at every audit width in generated plans. diff --git a/crates/devup-mcp/src/server/text/devup-screen-plan.md b/crates/devup-mcp/src/server/text/devup-screen-plan.md index 549550c1..6739c977 100644 --- a/crates/devup-mcp/src/server/text/devup-screen-plan.md +++ b/crates/devup-mcp/src/server/text/devup-screen-plan.md @@ -1 +1,3 @@ -Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `questionRounds`: questions already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed), at most maxQuestionsPerRound (default 4) per round. Ask everything now: a question tool with no cap (`question` in OpenCode) takes the flat `questions` list in one call; one with a cap (`AskUserQuestion` in Claude Code) takes the rounds in consecutive calls with no other work in between, skipping a question whose `when` was not met; never ask in prose and never implement before confirm. confirm takes every applicable answer keyed by question id and refuses the call, naming the missing ids. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. \ No newline at end of file +Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `questionRounds`: questions already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed), at most maxQuestionsPerRound (default 4) per round. Ask everything now: a question tool with no cap (`question` in OpenCode) takes the flat `questions` list in one call; one with a cap (`AskUserQuestion` in Claude Code) takes the rounds in consecutive calls with no other work in between, skipping a question whose `when` was not met; never ask in prose and never implement before confirm. confirm takes every applicable answer keyed by question id and refuses the call, naming the missing ids. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. +Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. +Ask all site, structure and URL questions from the draft questions/questionRounds together up front, back to back before implementing. Never ask another round during the build; record conservative assumptions after confirmation. diff --git a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md index b509c631..19d1fef4 100644 --- a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md +++ b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md @@ -304,3 +304,6 @@ page-visual, interaction, view-transition) with its node, file and fix hint. Never stop to wait; reorder the work: a decision after confirm goes to `defer` and is taken with `assume` at the end. Done = zero fails, or listed as unresolved with evidence after `maxFixAttempts`. + +Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. +Ask all site, structure and URL questions from the draft questions/questionRounds together up front, back to back before implementing. Never ask another round during the build; record conservative assumptions after confirmation. diff --git a/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md b/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md index 501adb6a..c3089452 100644 --- a/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md +++ b/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md @@ -1 +1 @@ -A URL is done when every check and comparison passes at its drawn widths and health and overflow pass at every width, or each remaining finding is a converter bug with its repro filed, or unresolved with its evidence after maxFixAttempts fix-and-rerun rounds. Layout is exact: a geometry mismatch is code drift (restore the export) or a converter bug, never tuned by hand. \ No newline at end of file +A URL is done only after a runtime audit loads it at every required width (default 1440/1100/858/390), every cell passes, and every drawn-width completeness check and page/section/asset comparison passes. Missing cells are not-run and block done. Report converter bugs with repros and unresolved findings with evidence after the fix budget, but do not call failing or unverified cells done. Read the report, fix and rerun until every cell passes. Layout permits only half a pixel of rounding. diff --git a/harness/runtime-audit/checks.mjs b/harness/runtime-audit/checks.mjs index ebb246a1..0181d7ac 100644 --- a/harness/runtime-audit/checks.mjs +++ b/harness/runtime-audit/checks.mjs @@ -163,6 +163,9 @@ export function evaluatePageHealth(observed) { ...observed.errorTexts.map((text) => `the page shows "${text}"`), ...observed.errors.map((error) => `${error.kind}: ${String(error.message).split('\n')[0]}`), ]; + if (Number.isFinite(observed.documentWidth) && observed.documentWidth < observed.viewportWidth - 1) { + problems.push(`document is ${round(observed.documentWidth)}px wide in a ${observed.viewportWidth}px viewport`); + } return problems.length === 0 ? { status: 'pass', reason: 'no error page, page error, console error or failed navigation', measured: {} } : { status: 'fail', reason: problems.slice(0, 5).join('; '), measured: { problems } }; @@ -297,6 +300,154 @@ export function evaluateViewTransition(observed) { return { status: 'fail', reason: `navigating from ${observed.from} to ${observed.to} ran no view transition`, measured }; } +/** Parses a computed CSS colour (rgb/rgba, as getComputedStyle returns it). */ +export function parseColor(text) { + const match = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/.exec(String(text)); + if (!match) return null; + let alpha = match[4] === undefined ? 1 : parseFloat(match[4]); + if (String(match[4]).endsWith('%')) alpha /= 100; + return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: alpha }; +} + +function over(top, bottom) { + const a = top.a + bottom.a * (1 - top.a); + if (a === 0) return { r: 0, g: 0, b: 0, a: 0 }; + const mix = (key) => (top[key] * top.a + bottom[key] * bottom.a * (1 - top.a)) / a; + return { r: mix('r'), g: mix('g'), b: mix('b'), a }; +} + +function luminance({ r, g, b }) { + const channel = (value) => { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +/** WCAG contrast ratio between two opaque colours. */ +export function contrastRatio(first, second) { + const [light, dark] = [luminance(first), luminance(second)].sort((a, b) => b - a); + return (light + 0.05) / (dark + 0.05); +} + +/** + * The contrast of text against what is painted behind it: its own colour + * over the stack of ancestor background colours (nearest first), on the + * white canvas. Text over a background image cannot be judged from colours + * and returns null. + */ +export function textContrast(sample) { + if (sample.imageBehind) return null; + const color = parseColor(sample.color); + const canvas = parseColor(sample.canvasColor ?? 'rgb(255, 255, 255)'); + if (!color || !canvas) return null; + if (sample.layers) { + let ink = color; + let backdrop = { r: 0, g: 0, b: 0, a: 0 }; + for (const layer of sample.layers) { + const background = parseColor(layer.color); + if (!background && (ink.a < 1 || backdrop.a < 1)) return null; + if (background) { + ink = over(ink, background); + backdrop = over(backdrop, background); + } + ink.a *= layer.opacity; + backdrop.a *= layer.opacity; + } + return contrastRatio(over(ink, canvas), over(backdrop, canvas)); + } + let backdrop = canvas; + for (const layer of [...sample.backgrounds].reverse()) { + const parsed = parseColor(layer); + if (!parsed) return null; + backdrop = over(parsed, backdrop); + } + const ink = over({ ...color, a: color.a * (sample.opacity ?? 1) }, backdrop); + return contrastRatio(ink, backdrop); +} + +export const DEFAULT_MIN_HEIGHT_RATIO = 0.9; +export const DEFAULT_MIN_CONTRAST = 1.5; + +/** Text uses computed colours, including browser default button backgrounds. */ +export function evaluateNoLowContrastText(options, observed) { + const minContrast = options.minContrast ?? DEFAULT_MIN_CONTRAST; + const invisible = []; + let judged = 0; + let skipped = 0; + for (const sample of observed.texts) { + const contrast = textContrast(sample); + if (contrast === null) { skipped += 1; continue; } + judged += 1; + if (contrast < minContrast) invisible.push({ text: sample.text, tag: sample.tag, contrast: round(contrast) }); + } + return { + status: invisible.length ? 'fail' : skipped ? 'inconclusive' : 'pass', + reason: invisible.length + ? `${invisible.length} texts cannot be seen against their background: ${invisible.slice(0, 3).map((item) => `"${item.text}" (${item.contrast}:1)`).join(', ')}` + : skipped ? `${skipped} text samples have backgrounds or colours that cannot be judged` : `${judged} texts have visible contrast`, + measured: { judged, skipped, invisible: invisible.slice(0, 20), minContrast }, + }; +} + +/** Every img and CSS background URL must decode to a nonzero natural width. */ +export function evaluateImageLoaded(observed) { + const resources = observed.resources ?? []; + const broken = resources.filter((resource) => !resource.loaded); + return { + status: broken.length ? 'fail' : 'pass', + reason: broken.length ? `${broken.length} images did not load: ${broken.slice(0, 5).map((item) => item.source).join(', ')}` : `${resources.length} image resources loaded`, + measured: { listed: resources.length, broken }, + }; +} + +/** + * Whether the page the browser rendered is the frame it was built from, not + * merely code on disk: tall enough, every section and image present and + * shown, images decoded, and no text a person cannot see. + * + * @param {object} completeness the plan's { frameHeight, minHeightRatio, sections, images, minContrast } + * @param {object} observed { pageHeight, sections: [{ id, count, visible, width, height }], + * images: [{ id, count, visible, width, height, broken }], texts: [{ text, tag, color, backgrounds, imageBehind, opacity }] } + */ +export function evaluateRenderedCompleteness(completeness, observed) { + const ratio = completeness.minHeightRatio ?? DEFAULT_MIN_HEIGHT_RATIO; + const needed = completeness.frameHeight * ratio; + const problems = []; + if (!(observed.pageHeight >= needed)) { + problems.push(`the page is ${round(observed.pageHeight)}px tall, under ${Math.round(ratio * 100)}% of the ${round(completeness.frameHeight)}px frame`); + } + const absent = (items) => items.filter((item) => item.count !== 1 || !item.visible || !(item.width > 0) || !(item.height > 0)); + const missingSections = absent(observed.sections); + if (missingSections.length) { + problems.push(`${missingSections.length} of ${observed.sections.length} sections not rendered: ${missingSections.slice(0, 5).map((item) => item.id).join(', ')}`); + } + const missingImages = absent(observed.images); + if (missingImages.length) { + problems.push(`${missingImages.length} of ${observed.images.length} images not rendered: ${missingImages.slice(0, 5).map((item) => item.id).join(', ')}`); + } + const broken = observed.images.filter((item) => !missingImages.includes(item) && item.broken); + if (broken.length) { + problems.push(`${broken.length} images did not load: ${broken.slice(0, 5).map((item) => item.id).join(', ')}`); + } + const contrast = evaluateNoLowContrastText(completeness, observed); + const resources = evaluateImageLoaded(observed); + if (contrast.status === 'fail') problems.push(contrast.reason); + if (resources.status === 'fail') problems.push(resources.reason); + const measured = { + pageHeight: round(observed.pageHeight), + frameHeight: completeness.frameHeight, + minHeightRatio: ratio, + sections: { listed: observed.sections.length, missing: missingSections.map((item) => item.id) }, + images: { listed: observed.images.length, missing: missingImages.map((item) => item.id), broken: broken.map((item) => item.id) }, + texts: contrast.measured, + resources: resources.measured, + }; + return problems.length === 0 + ? { status: contrast.status, reason: contrast.status === 'inconclusive' ? contrast.reason : `rendered complete: ${observed.sections.length} sections, ${observed.images.length} images, ${contrast.measured.judged} texts visible, ${round(observed.pageHeight)}px tall`, measured } + : { status: 'fail', reason: problems.join('; '), measured }; +} + export function summarize(runs) { const summary = { pass: 0, fail: 0, inconclusive: 0, skipped: 0 }; for (const run of runs) { @@ -308,3 +459,35 @@ export function summarize(runs) { } return summary; } + +/** Read independent numbered groups by visual rows, then left to right. */ +export function evaluateSequenceOrder(observed) { + const failures = []; + for (const group of observed.groups) { + const remaining = [...group]; + const ordered = []; + while (remaining.length) { + const top = Math.min(...remaining.map((item) => item.y)); + const row = remaining.filter((item) => item.y <= top + 8).sort((a, b) => a.x - b.x); + ordered.push(...row); + for (const item of row) remaining.splice(remaining.indexOf(item), 1); + } + for (let index = 1; index < ordered.length; index += 1) { + if (ordered[index].number < ordered[index - 1].number) failures.push(`${ordered[index - 1].name} before ${ordered[index].name}`); + } + } + return { status: failures.length ? 'fail' : 'pass', reason: failures.length ? `sequence out of visual order: ${failures.join('; ')}` : 'numbered siblings follow increasing visual order', measured: observed }; +} + +export function evaluateSectionLayout(observed) { + const failures = observed.sections.filter((item) => !item.visible || item.width < item.containerWidth * 0.5 || item.height <= 0); + const narrow = observed.texts.filter((item) => item.width < 120); + const problems = [...failures.map((item) => `${item.name}: section ${round(item.width)}px wide in ${round(item.containerWidth)}px container or hidden`), ...narrow.map((item) => `${item.name}: text column only ${round(item.width)}px wide`)]; + return { status: problems.length ? 'fail' : 'pass', reason: problems.length ? problems.join('; ') : 'sections visible and text columns have room', measured: observed }; +} + +export function evaluateTextAlignment(check, observed) { + const actual = observed.align === 'start' ? (observed.direction === 'rtl' ? 'right' : 'left') : observed.align === 'end' ? (observed.direction === 'rtl' ? 'left' : 'right') : observed.align; + const pass = observed.count === 1 && observed.visible && actual === check.edge; + return { status: pass ? 'pass' : 'fail', reason: pass ? `text is ${actual} aligned as drawn` : `text alignment ${actual ?? 'missing'}, expected ${check.edge} (${observed.count} elements)`, measured: observed }; +} diff --git a/harness/runtime-audit/package.json b/harness/runtime-audit/package.json index 1f3455a1..982ead0f 100644 --- a/harness/runtime-audit/package.json +++ b/harness/runtime-audit/package.json @@ -7,7 +7,7 @@ "devup-runtime-audit": "run.mjs" }, "scripts": { - "test": "node --test test/checks.test.mjs test/browser.test.mjs", + "test": "node --test test/checks.test.mjs test/browser.test.mjs test/layout.test.mjs", "test:unit": "node --test test/checks.test.mjs" }, "dependencies": { diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index f78387d6..ddbcfb90 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -17,7 +17,7 @@ import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { ERROR_PAGE_TEXT, STATE_PROPERTIES, evaluateBounds, evaluateHidden, evaluateCheck, evaluateClippedText, evaluateDimensions, - evaluateInteraction, evaluatePageHealth, evaluateViewTransition, summarize, + evaluateInteraction, evaluatePageHealth, evaluateTextAlignment, evaluateSequenceOrder, evaluateSectionLayout, evaluateNoLowContrastText, evaluateImageLoaded, evaluateRenderedCompleteness, evaluateViewTransition, summarize, } from './checks.mjs'; const PLAN_KIND = 'devup-runtime-audit-plan'; @@ -195,6 +195,170 @@ async function clippedText(page, subject) { }); } +// Internal overflow can hide cards and navigation even when the page fits. +export function observeClippedContent(page) { + return page.evaluate(() => { + const clipped = []; + for (const element of document.querySelectorAll('body *')) { + if (!element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + if (!box.width || !box.height) continue; + const horizontal = style.overflowX !== 'visible' && element.scrollWidth > element.clientWidth + 1; + const vertical = ['hidden','clip'].includes(style.overflowY) && element.scrollHeight > element.clientHeight + 1; + if (!horizontal && !vertical) continue; + if (style.textOverflow === 'ellipsis' || (style.webkitLineClamp && style.webkitLineClamp !== 'none')) continue; + const scrollbar = element.offsetHeight - element.clientHeight - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth) > 2; + const controls = element.parentElement?.querySelector('button[aria-label], [role="scrollbar"]'); + if (horizontal && !vertical && element.dataset.auditCarousel === 'true' && (scrollbar || controls)) continue; + clipped.push({ name: element.id || `${element.tagName.toLowerCase()} "${element.textContent.trim().slice(0,60)}"`, horizontal, vertical }); + } + return { clipped }; + }); +} + +// What the rendered page holds of its frame. A node is found by the id it +// carries itself, or, on a page merged from one frame per width, under this +// width in its data-figma-node-ids map. +export function observeCompleteness(page, completeness, width, subject) { + const scope = subject ? locate(page, subject) : page.locator('body'); + return scope.evaluate(async (root, { sections, images, width: drawnWidth }) => { + const find = (id) => document.querySelectorAll( + `[data-figma-node-id="${id}"], [data-figma-node-ids~="${drawnWidth}:${id}"]`, + ); + const shown = (element) => !element.checkVisibility + || element.checkVisibility({ opacityProperty: true, visibilityProperty: true }); + const describe = (id) => { + const found = find(id); + if (found.length !== 1) return { id, count: found.length }; + const element = found[0]; + const rect = element.getBoundingClientRect(); + const img = element.tagName === 'IMG' ? element : null; + return { + id, + count: 1, + visible: shown(element), + width: rect.width, + height: rect.height, + broken: img ? !(img.complete && img.naturalWidth > 0) : false, + }; + }; + const canvasProbe = document.createElement('span'); + canvasProbe.style.backgroundColor = 'Canvas'; + document.body.appendChild(canvasProbe); + const canvasColor = getComputedStyle(canvasProbe).backgroundColor; + canvasProbe.remove(); + const texts = []; + const walker = [...(root === document.body ? [document.documentElement] : []), root, ...root.querySelectorAll('*')]; + for (let index = 0; index < walker.length; index += 1) { + const element = walker[index]; + const own = [...element.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent.trim()).join(' ').trim(); + if (!own || !shown(element)) continue; + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + const style = getComputedStyle(element); + const backgrounds = []; + const layers = []; + let imageBehind = false; + let backgroundExposed = true; + let opacity = 1; + for (let node = element; node && node.nodeType === 1; node = node.parentElement) { + const nodeStyle = getComputedStyle(node); + opacity *= Number(nodeStyle.opacity); + layers.push({ color: nodeStyle.backgroundColor, opacity: Number(nodeStyle.opacity) }); + if (backgroundExposed) { + if (nodeStyle.backgroundImage && nodeStyle.backgroundImage !== 'none') imageBehind = true; + backgrounds.push(nodeStyle.backgroundColor); + // An opaque colour covers images and colours on more distant ancestors. + if (/^rgb\(/.test(nodeStyle.backgroundColor) || /,\s*1\)$/.test(nodeStyle.backgroundColor)) backgroundExposed = false; + } + } + texts.push({ text: own.slice(0, 60), tag: element.tagName.toLowerCase(), color: style.color, canvasColor, backgrounds, layers, imageBehind, opacity }); + } + const resources = []; + const pending = []; + const probe = (source, element, kind) => { + const item = { source, tag: element.tagName.toLowerCase(), kind, loaded: false }; + resources.push(item); + pending.push(new Promise((resolve) => { + const image = new Image(); + const timer = setTimeout(() => resolve(), 5000); + const done = () => { item.loaded = image.naturalWidth > 0; clearTimeout(timer); resolve(); }; + image.onload = done; + image.onerror = done; + image.src = source; + if (image.complete) done(); + })); + }; + for (const element of walker) { + if (element.tagName === 'IMG') { + const item = { source: element.currentSrc || element.src, tag: 'img', kind: 'img', loaded: false }; + resources.push(item); + element.loading = 'eager'; + pending.push(new Promise((resolve) => { + const timer = setTimeout(resolve, 5000); + const done = () => { item.loaded = element.naturalWidth > 0; clearTimeout(timer); resolve(); }; + element.decode().then(done, done); + })); + } + for (const pseudo of [null, '::before', '::after']) { + const style = getComputedStyle(element, pseudo); + if (pseudo && (style.content === 'none' || style.content === 'normal')) continue; + for (const match of style.backgroundImage.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/g)) { + probe(match[1] ?? match[2] ?? match[3], element, `background${pseudo ?? ''}`); + } + } + } + await Promise.all(pending); + return { + resources, + pageHeight: document.documentElement.scrollHeight, + sections: sections.map(describe), + images: images.map(describe), + texts, + }; + }, { sections: completeness.sections ?? [], images: completeness.images ?? [], width }); +} + +export function observeSequence(page) { + return page.evaluate(() => { + const groups = new Map(); + for (const element of document.querySelectorAll('body *')) { + if (element.children.length || !element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; + const label = element.textContent.trim(); + const match = /^(?:STEP\s*(\d{1,3})|(0\d|[1-9]\d?))[.?:]?$/i.exec(label); + if (!match) continue; + const number = Number(match[1] ?? match[2]); + const scope = element.closest('section, [data-sequence-group], main') ?? document.body; + const box = element.getBoundingClientRect(); + if (!box.width || !box.height) continue; + const group = groups.get(scope) ?? []; + group.push({ number, name: `${label} <${element.tagName.toLowerCase()}>`, x: box.x, y: box.y }); + groups.set(scope, group); + } + return { groups: [...groups.values()].filter((group) => group.length > 1 && new Set(group.map((item) => item.number)).size === group.length) }; + }); +} + +export async function observeSectionLayout(page, subject, container) { + if (subject) { + const target = locate(page, subject); + const parent = locate(page, container); + const box = await target.count() === 1 ? await target.boundingBox() : null; + const containerBox = await parent.count() === 1 ? await parent.boundingBox() : null; + return { sections: [{ name: JSON.stringify(subject), visible: Boolean(box), width: box?.width ?? 0, height: box?.height ?? 0, containerWidth: containerBox?.width ?? page.viewportSize().width }], texts: [] }; + } + return page.evaluate(() => { + const shown = (element) => element.checkVisibility({ opacityProperty: true, visibilityProperty: true }); + const sections = [...document.querySelectorAll('main > section, body > section')].map((element) => { + const box = element.getBoundingClientRect(); + return { name: element.id || element.tagName, visible: shown(element), width: box.width, height: box.height, containerWidth: element.parentElement.getBoundingClientRect().width }; + }); + const texts = [...document.querySelectorAll('p')].filter((element) => shown(element) && element.textContent.trim().length > 60).map((element) => ({ name: element.textContent.trim().slice(0, 60), width: element.getBoundingClientRect().width })); + return { sections, texts }; + }); +} + async function stateStyles(locator) { return locator.evaluate((element, properties) => { const style = getComputedStyle(element); @@ -280,6 +444,7 @@ async function pageMetrics(page) { const bodyStyle = document.body ? getComputedStyle(document.body) : style; return { viewportWidth, + documentWidth: document.documentElement.getBoundingClientRect().width, scrollWidth: document.documentElement.scrollWidth, maxRight, widest, @@ -423,7 +588,27 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { const errors = environmentErrors .slice(errorsAtStart) .filter((error) => ['page-error', 'console-error', 'navigation-failed'].includes(error.kind)); - record.checks.push({ ...item, ...evaluatePageHealth({ errorTexts: ERROR_PAGE_TEXT.filter((needle) => text.includes(needle)), errors }) }); + record.checks.push({ ...item, ...evaluatePageHealth({ errorTexts: ERROR_PAGE_TEXT.filter((needle) => text.replaceAll("?", "'").includes(needle)), errors, documentWidth: metrics.documentWidth, viewportWidth: metrics.viewportWidth }) }); + continue; + } + if (check.kind === 'text-align') { + const target = locate(page, check.subject); + const count = await target.count(); + const observed = count === 1 ? await target.evaluate((element) => ({ count: 1, visible: element.checkVisibility({ opacityProperty: true, visibilityProperty: true }), align: getComputedStyle(element).textAlign, direction: getComputedStyle(element).direction })) : { count }; + record.checks.push({ ...item, ...evaluateTextAlignment(check, observed) }); + continue; + } + if (check.kind === 'no-clipped-content') { + const observed = await observeClippedContent(page); + record.checks.push({ ...item, status: observed.clipped.length ? 'fail' : 'pass', reason: observed.clipped.length ? `content cut off: ${observed.clipped.map((item) => item.name).join('; ')}` : 'no hidden internal content', measured: observed }); + continue; + } + if (check.kind === 'sequence-order') { + record.checks.push({ ...item, ...evaluateSequenceOrder(await observeSequence(page)) }); + continue; + } + if (check.kind === 'section-layout') { + record.checks.push({ ...item, ...evaluateSectionLayout(await observeSectionLayout(page, check.subject, check.container)) }); continue; } if (check.kind === 'hidden') { @@ -437,6 +622,20 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { record.checks.push({ ...item, ...evaluateHidden({ subjectCount, visible }) }); continue; } + if (check.kind === 'no-low-contrast-text' || check.kind === 'image-loaded') { + if (check.subject && await locate(page, check.subject).count() !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: 'subject must match exactly one element', measured: {} }); + continue; + } + const observed = await observeCompleteness(page, {}, viewport.width, check.subject); + const result = check.kind === 'image-loaded' ? evaluateImageLoaded(observed) : evaluateNoLowContrastText({}, observed); + record.checks.push({ ...item, ...result }); + continue; + } + if (check.kind === 'rendered-completeness') { + record.checks.push({ ...item, ...evaluateRenderedCompleteness(check.completeness, await observeCompleteness(page, check.completeness, viewport.width)) }); + continue; + } if (check.kind === 'no-clipped-text') { record.checks.push({ ...item, ...evaluateClippedText(await clippedText(page, check.subject)) }); continue; diff --git a/harness/runtime-audit/test/browser.test.mjs b/harness/runtime-audit/test/browser.test.mjs index d33140b4..40448541 100644 --- a/harness/runtime-audit/test/browser.test.mjs +++ b/harness/runtime-audit/test/browser.test.mjs @@ -8,7 +8,7 @@ import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { extname, join } from 'node:path'; import test from 'node:test'; -import { runPlan } from '../run.mjs'; +import { observeCompleteness, runPlan } from '../run.mjs'; const FIXTURES = new URL('../fixtures/', import.meta.url); const TYPES = { '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml' }; @@ -353,3 +353,32 @@ test('a page merged from two width frames is found by its per-width node ids', a await rm(out, { recursive: true, force: true }); } }); + + +test('computed button backgrounds and every image resource are observed in Chromium', async () => { + const { chromium } = await import('playwright'); + const { evaluateNoLowContrastText, evaluateImageLoaded, evaluateRenderedCompleteness } = await import('../checks.mjs'); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.route('**/missing.svg', (route) => route.fulfill({ status: 404, body: '' })); + const good = 'data:image/svg+xml,'; + await page.setContent(` +
+ + `); + const observed = await observeCompleteness(page, {}, 800); + assert.equal(evaluateNoLowContrastText({}, observed).status, 'fail'); + const buttonBackground = await page.locator('button').evaluate((element) => getComputedStyle(element).backgroundColor); + assert.equal(observed.texts[0].backgrounds[0], buttonBackground); + assert.notEqual(buttonBackground, 'rgba(0, 0, 0, 0)'); + assert.equal(observed.resources.length, 4); + assert.equal(observed.resources.filter((item) => !item.loaded).length, 3); + assert.equal(evaluateImageLoaded(observed).status, 'fail'); + assert.equal(evaluateRenderedCompleteness({ frameHeight: 100 }, observed).status, 'fail'); + await page.setContent(``); + const fixed = await observeCompleteness(page, {}, 800); + assert.equal(evaluateNoLowContrastText({}, fixed).status, 'pass'); + assert.equal(evaluateImageLoaded(fixed).status, 'pass'); + } finally { await browser.close(); } +}); diff --git a/harness/runtime-audit/test/checks.test.mjs b/harness/runtime-audit/test/checks.test.mjs index fcbf872d..280bbba1 100644 --- a/harness/runtime-audit/test/checks.test.mjs +++ b/harness/runtime-audit/test/checks.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { GEOMETRY_ROUNDING_PX, STATE_PROPERTIES, evaluateBounds, evaluateHidden, evaluateCheck, evaluateClippedText, evaluateDimensions, evaluateInteraction, - evaluatePageHealth, evaluateViewTransition, summarize, + evaluatePageHealth, evaluateNoLowContrastText, evaluateImageLoaded, evaluateRenderedCompleteness, evaluateViewTransition, summarize, textContrast, } from '../checks.mjs'; import { validateDimensionsPlan } from '../run.mjs'; @@ -160,3 +160,57 @@ test('production builds lose the audit attributes; dev and audit builds keep the assert.equal(production.transform(source, '/app/src/style.css'), null); assert.equal(keepsAttributes('build', {}), false); }); + +test('a rendered page is complete only when tall enough, with every section, image and visible text', () => { + const completeness = { frameHeight: 2000, sections: ['1:2', '1:3'], images: ['1:4'] }; + const shown = (id) => ({ id, count: 1, visible: true, width: 100, height: 50, broken: false }); + const dark = { text: 'Hello', tag: 'p', color: 'rgb(17, 17, 17)', backgrounds: ['rgba(0, 0, 0, 0)', 'rgb(255, 255, 255)'], imageBehind: false, opacity: 1 }; + const complete = { pageHeight: 1900, sections: [shown('1:2'), shown('1:3')], images: [shown('1:4')], texts: [dark] }; + assert.equal(evaluateRenderedCompleteness(completeness, complete).status, 'pass'); + + const blank = evaluateRenderedCompleteness(completeness, { pageHeight: 900, sections: [{ id: '1:2', count: 0 }, shown('1:3')], images: [{ ...shown('1:4'), broken: true }], texts: [] }); + assert.equal(blank.status, 'fail'); + assert.match(blank.reason, /900px tall, under 90% of the 2000px frame/); + assert.match(blank.reason, /1 of 2 sections not rendered: 1:2/); + assert.match(blank.reason, /1 images did not load: 1:4/); + + const whiteOnWhite = { ...dark, text: 'Ghost', color: 'rgb(255, 255, 255)' }; + const ghost = evaluateRenderedCompleteness(completeness, { ...complete, texts: [dark, whiteOnWhite] }); + assert.equal(ghost.status, 'fail'); + assert.match(ghost.reason, /"Ghost" \(1:1\)/); + assert.equal(textContrast({ ...whiteOnWhite, imageBehind: true }), null, 'text over an image is not judged'); + assert.ok(textContrast({ ...whiteOnWhite, backgrounds: ['rgb(0, 0, 0)'] }) > 20); + assert.ok(textContrast({ ...dark, opacity: 0 }) < 1.01, 'fully transparent text cannot be seen'); +}); + + +test('no-low-contrast-text composites ancestor backgrounds and translucent ink', () => { + const sample = { text: 'Submit', tag: 'button', color: 'rgb(255, 255, 255)', backgrounds: ['rgb(239, 239, 239)'] }; + const check = (text) => evaluateNoLowContrastText({}, { texts: [text] }); + assert.equal(check(sample).status, 'fail'); + assert.equal(check({ ...sample, backgrounds: ['rgba(0, 0, 0, 0)', 'rgb(0, 0, 0)'] }).status, 'pass'); + assert.equal(check({ ...sample, color: 'rgba(0, 0, 0, 0.01)' }).status, 'fail'); + assert.equal(check({ ...sample, imageBehind: true }).status, 'inconclusive'); + assert.equal(check({ ...sample, color: 'unsupported' }).status, 'inconclusive'); +}); + +test('image-loaded fails for any undecoded img or background resource', () => { + const resources = [{ source: '/ok.png', kind: 'img', loaded: true }, { source: '/hero.png', kind: 'background', loaded: false }]; + assert.equal(evaluateImageLoaded({ resources: resources.slice(0, 1) }).status, 'pass'); + const broken = evaluateImageLoaded({ resources }); + assert.equal(broken.status, 'fail'); + assert.match(broken.reason, /hero.png/); + assert.equal(evaluateImageLoaded({ resources: [{ ...resources[0], loaded: false }] }).status, 'fail'); +}); + + +test('contrast composites opacity of text and background together through ancestor groups', () => { + const sample = { color: 'rgb(255, 255, 255)', layers: [{ color: 'rgb(0, 0, 0)', opacity: 0.01 }] }; + assert.ok(textContrast(sample) < 1.5, 'a faded black button on white loses its white text'); + assert.ok(textContrast({ ...sample, layers: [{ color: 'rgb(0, 0, 0)', opacity: 1 }] }) > 20); + assert.ok(textContrast({ color: 'rgb(0, 0, 0)', layers: [{ color: 'rgba(0, 0, 0, 0)', opacity: 1 }, { color: 'rgb(255, 255, 255)', opacity: 1 }] }) > 20); +}); + +test('transparent backgrounds use the browser canvas colour', () => { + assert.ok(textContrast({ color: 'rgb(255, 255, 255)', canvasColor: 'rgb(0, 0, 0)', backgrounds: ['rgba(0, 0, 0, 0)'] }) > 20); +}); diff --git a/harness/runtime-audit/test/layout.test.mjs b/harness/runtime-audit/test/layout.test.mjs new file mode 100644 index 00000000..1e912214 --- /dev/null +++ b/harness/runtime-audit/test/layout.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { evaluatePageHealth, evaluateTextAlignment, evaluateSequenceOrder, evaluateSectionLayout } from '../checks.mjs'; +import { observeSequence, observeSectionLayout, observeClippedContent } from '../run.mjs'; + +test('health rejects narrow documents and records vinext error text', () => { + const healthy = { errorTexts: [], errors: [], viewportWidth: 858, documentWidth: 858 }; + assert.equal(evaluatePageHealth(healthy).status, 'pass'); + assert.match(evaluatePageHealth({ ...healthy, documentWidth: 700 }).reason, /700px wide in a 858px viewport/); + assert.match(evaluatePageHealth({ ...healthy, errorTexts: ["This page couldn't load"] }).reason, /couldn't load/); +}); + +test('sequence order follows rows and rejects collapsed column DOM order', () => { + const group = [1, 3, 2, 4].map((number, index) => ({ number, name: `card ${number}`, x: 0, y: index * 100 })); + assert.match(evaluateSequenceOrder({ groups: [group] }).reason, /card 3 before card 2/); + assert.equal(evaluateSequenceOrder({ groups: [group.map((item) => ({ ...item, x: item.number % 2 ? 0 : 200, y: item.number <= 2 ? 0 : 100 }))] }).status, 'pass'); +}); + +test('collapsed sections, hidden sections and squashed text fail', () => { + const section = { name: 'hero', visible: true, width: 390, height: 300, containerWidth: 390 }; + assert.equal(evaluateSectionLayout({ sections: [section], texts: [] }).status, 'pass'); + assert.match(evaluateSectionLayout({ sections: [{ ...section, width: 150 }], texts: [] }).reason, /hero/); + assert.equal(evaluateSectionLayout({ sections: [{ ...section, visible: false }], texts: [] }).status, 'fail'); + assert.match(evaluateSectionLayout({ sections: [], texts: [{ name: 'description', width: 30 }] }).reason, /description/); +}); + +test('browser observes numbered column collapse and squashed sections at each width', async () => { + const { chromium } = await import('playwright'); + const browser = await chromium.launch({ headless: true }); + try { + for (const width of [390,858,1100,1440]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + await page.setContent('
01
03
02
04
'); + assert.equal(evaluateSequenceOrder(await observeSequence(page)).status, 'fail'); + assert.equal(evaluateSectionLayout(await observeSectionLayout(page)).status, 'fail'); + await page.setContent('
STEP 1STEP 2STEP 3
'); + assert.equal(evaluateSequenceOrder(await observeSequence(page)).status, 'pass'); + await page.close(); + } + } finally { await browser.close(); } +}); + +test('internal nav and thumbnail clipping fails even without page overflow', async () => { + const { chromium } = await import('playwright'); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 858, height: 900 } }); + try { + await page.setContent('
Devup UI / Vespera
'); + const observed = await observeClippedContent(page); + assert.deepEqual(observed.clipped.map((item) => item.name), ['nav','tech']); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 858); + await page.setContent('
Thumbnails
'); + assert.equal((await observeClippedContent(page)).clipped.length, 0); + } finally { await browser.close(); } +}); + +test('text alignment follows Figma even when its text box stays full width', () => { + assert.equal(evaluateTextAlignment({ edge: 'center' }, { count: 1, visible: true, align: 'left', direction: 'ltr' }).status, 'fail'); + assert.equal(evaluateTextAlignment({ edge: 'center' }, { count: 1, visible: true, align: 'center', direction: 'ltr' }).status, 'pass'); +}); From 9b15cbf53646e964e8bbf43f7033882a091a8a5f Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 17:23:16 +0900 Subject: [PATCH 02/11] Handle unavailable Windows symlink privileges in audit validation --- .../changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json | 7 +++++++ crates/devup-mcp/src/server/visual_compare.rs | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changepacks/changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json diff --git a/.changepacks/changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json b/.changepacks/changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json new file mode 100644 index 00000000..9d345ace --- /dev/null +++ b/.changepacks/changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Show rendered completeness in per-width audit reports", + "date": "2026-10-19T08:14:14.391652Z" +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/visual_compare.rs b/crates/devup-mcp/src/server/visual_compare.rs index f07fa072..0c557480 100644 --- a/crates/devup-mcp/src/server/visual_compare.rs +++ b/crates/devup-mcp/src/server/visual_compare.rs @@ -837,7 +837,16 @@ mod tests { let f = fixture(); let outside = fixture(); #[cfg(windows)] - std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")).unwrap(); + // Like output_policy's directory-symlink test, this requires the host + // to allow symlink creation. Windows reports missing privilege as 1314 + // (ErrorKind::Uncategorized on some toolchains), not PermissionDenied. + if let Err(error) = std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")) { + if error.raw_os_error() == Some(1314) { + eprintln!("symlink escape test unavailable: Windows symlink privilege is missing"); + return; + } + panic!("could not create test symlink: {error}"); + } #[cfg(unix)] std::os::unix::fs::symlink(&outside.0, f.0.join("escape")).unwrap(); assert!( From c808eef4ed11d643692384e01da16a1dd13b48e3 Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 17:32:43 +0900 Subject: [PATCH 03/11] Guard screen-plan frame inventories and bind navigation labels --- .../changepack_log_Cplouv_3AT3N0Fj41CiIp.json | 7 + .../src/server/screen_plan/actions.rs | 67 ++++- .../devup-mcp/src/server/screen_plan/draft.rs | 2 + .../src/server/screen_plan/draft_plan_with.rs | 239 ++++++++++++++- .../server/screen_plan/plan_route_group.rs | 45 ++- .../src/server/screen_plan/status.rs | 2 + .../devup-mcp/src/server/screen_plan_tests.rs | 1 + .../frame_inventory_and_link_labels.rs | 281 ++++++++++++++++++ .../src/server/text/devup-screen-plan.md | 3 +- crates/devup-mcp/src/server/visual_compare.rs | 8 +- crates/devup-mcp/tests/output_policy.rs | 4 +- 11 files changed, 637 insertions(+), 22 deletions(-) create mode 100644 .changepacks/changepack_log_Cplouv_3AT3N0Fj41CiIp.json create mode 100644 crates/devup-mcp/src/server/screen_plan_tests/frame_inventory_and_link_labels.rs diff --git a/.changepacks/changepack_log_Cplouv_3AT3N0Fj41CiIp.json b/.changepacks/changepack_log_Cplouv_3AT3N0Fj41CiIp.json new file mode 100644 index 00000000..18c09ccc --- /dev/null +++ b/.changepacks/changepack_log_Cplouv_3AT3N0Fj41CiIp.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Preserve screen-plan frame inventories and bind navigation labels to confirmed routes", + "date": "2026-10-19T08:05:44.718031500Z" +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screen_plan/actions.rs b/crates/devup-mcp/src/server/screen_plan/actions.rs index 5a65548a..e9653417 100644 --- a/crates/devup-mcp/src/server/screen_plan/actions.rs +++ b/crates/devup-mcp/src/server/screen_plan/actions.rs @@ -1,5 +1,3 @@ - - /// `confirm`: every question that applies answered, in one call, or nothing /// changes. pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { @@ -277,6 +275,14 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< { row["hostUrl"] = json!(screen_routes.get(text(&row["hostScreen"]))); } + let node_routes = routes + .iter() + .flat_map(|route| { + array(&route["nodeIds"]) + .iter() + .map(move |node| (text(node).to_owned(), route["url"].clone())) + }) + .collect::>(); if let Some(rows) = plan["screens"].as_array_mut() { for screen in rows { let id = text(&screen["id"]).to_owned(); @@ -287,7 +293,30 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< link["resolved"] = json!({"url": action["link"], "evidence": "confirmed answer"}); } else if let Some(target) = link["resolved"]["screen"].as_str() { - link["resolved"]["url"] = json!(screen_routes.get(target)); + link["resolved"]["url"] = + if let Some(node) = link["resolved"]["nodeId"].as_str() { + node_routes.get(node).cloned().unwrap_or(Value::Null) + } else { + let urls = routes + .iter() + .filter(|route| { + screens + .iter() + .find(|screen| screen["id"] == target) + .is_some_and(|screen| { + array(&screen["frames"]).iter().any(|frame| { + array(&route["nodeIds"]).contains(&frame["nodeId"]) + }) + }) + }) + .filter_map(|route| route["url"].as_str()) + .collect::>(); + if urls.len() == 1 { + json!(urls.first()) + } else { + Value::Null + } + }; } resolved.push(link); } @@ -326,6 +355,7 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< plan["designFixes"] = json!(design_fixes); plan["status"] = json!("confirmed"); plan["confirmedAt"] = json!(now()); + refresh_inventory_checks(&mut plan); if !plan["assumptions"].is_array() { plan["assumptions"] = json!([]); } @@ -335,6 +365,8 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< "planId": plan["planId"], "routes": routes, "markdownTable": route_markdown(&routes), + "frameInventory": plan["frameInventory"], + "linkLabelCheck": plan["linkLabelCheck"], "excluded": excluded, "designFixes": design_fixes, "site": plan["site"], @@ -633,6 +665,33 @@ fn markdown(plan: &Value) -> String { cell(&screen["openDecisions"]), )); } + if plan["frameInventory"].is_object() { + out.push_str( + "\n## Frame inventory\n\n| Frame | nodeId | disposition | route |\n|---|---|---|---|\n", + ); + for frame in array(&plan["frameInventory"]["frames"]) { + out.push_str(&format!( + "| {} | {} | {} | {} |\n", + cell(&frame["name"]), + cell(&frame["nodeId"]), + cell(&frame["disposition"]), + cell(&frame["route"]) + )); + } + out.push_str(&format!("\n{}\n", text(&plan["frameInventory"]["rule"]))); + out.push_str( + "\n## Link labels\n\n| On frame | Label | state | resolved |\n|---|---|---|---|\n", + ); + for label in array(&plan["linkLabelCheck"]["labels"]) { + out.push_str(&format!( + "| {} | {} | {} | {} |\n", + cell(&label["on"]), + cell(&label["label"]), + cell(&label["state"]), + cell(&label["resolved"]) + )); + } + } let questions = array(&plan["questions"]); if !questions.is_empty() { out.push_str(&format!("\n## Open questions\n\n{ASK_INSTRUCTION}\n\n")); @@ -782,4 +841,4 @@ pub(super) async fn status( report["inconclusiveComparisons"] = round["inconclusiveComparisons"].clone(); } Ok(report) -} \ No newline at end of file +} diff --git a/crates/devup-mcp/src/server/screen_plan/draft.rs b/crates/devup-mcp/src/server/screen_plan/draft.rs index 5629a7c3..fde7fc8f 100644 --- a/crates/devup-mcp/src/server/screen_plan/draft.rs +++ b/crates/devup-mcp/src/server/screen_plan/draft.rs @@ -443,6 +443,8 @@ pub(super) async fn draft_with( "heuristics": plan["heuristics"], "excluded": plan["excluded"], "outOfScope": plan["outOfScope"], + "frameInventory": plan["frameInventory"], + "linkLabelCheck": plan["linkLabelCheck"], "config": plan["config"], "unverifiedRoutes": unverified, "inventoryNote": plan["inventoryNote"], diff --git a/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs b/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs index b61329d0..79cba36e 100644 --- a/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs +++ b/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs @@ -9,6 +9,23 @@ pub(super) fn draft_plan_with( source: Value, notes: Option<&str>, ) -> Result { + if ["projection", "candidates", "atCeiling"] + .iter() + .any(|field| source["truncation"][field] == true) + { + return Err(invalid( + "The frame inventory is incomplete: exploration was truncated or reached its ceiling. Explore a smaller Section or supply the complete frame list before drafting.", + )); + } + let mut ids = BTreeSet::new(); + if screens + .iter() + .any(|screen| screen.node_id.trim().is_empty() || !ids.insert(&screen.node_id)) + { + return Err(invalid( + "Frame inventory requires a non-empty, unique nodeId for every frame.", + )); + } if screens.is_empty() { return Err(invalid( "draft found no screens. Link a Section or frame, or pass screens from devup_figma_explore.", @@ -520,12 +537,15 @@ pub(super) fn draft_plan_with( { *asked = with_when(asked.clone(), &base_when); } - let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes}); + let inventory = frame_inventory(screens, &out_of_scope, &excluded_rows, &kept); + let labels = link_label_check(screens, &out_of_scope, &excluded_rows, &kept); + let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes, + "frameInventory": inventory, "linkLabelCheck": labels}); let plan_id = format!( "sp-{}", &sha256(&serde_json::to_vec(&body).unwrap_or_default())[..16] ); - Ok(json!({ + let mut plan = json!({ "kind": PLAN_KIND, "schemaVersion": SCHEMA_VERSION, "planId": plan_id, @@ -539,6 +559,8 @@ pub(super) fn draft_plan_with( "heuristics": heuristics, "excluded": excluded_rows, "outOfScope": out_of_scope, + "frameInventory": body["frameInventory"], + "linkLabelCheck": body["linkLabelCheck"], "site": site, "defaults": defaults, "apps": evidence.apps.iter().map(super::framework::App::json).collect::>(), @@ -548,5 +570,216 @@ pub(super) fn draft_plan_with( "answers": {}, "routes": [], "assumptions": [], - })) + }); + refresh_inventory_checks(&mut plan); + Ok(plan) +} + +/// What became of one listed frame: planned, excluded, outOfScope or +/// siteAsset (a logo or share image taken out of the page list). +fn disposition_of( + node_id: &str, + out_of_scope: &[Value], + excluded_rows: &[Value], + kept: &[PlanScreenInput], +) -> &'static str { + let listed = |rows: &[Value]| rows.iter().any(|row| text(&row["nodeId"]) == node_id); + if listed(out_of_scope) { + "outOfScope" + } else if listed(excluded_rows) { + "excluded" + } else if kept.iter().any(|screen| screen.node_id == node_id) { + "planned" + } else { + "siteAsset" + } +} + +/// The rule every inventory and label check carries, so an agent reading +/// either one alone still meets it. +const ABSENCE_RULE: &str = "Say a screen does not exist only after searching frameInventory and running devup_figma_search for its name. A name missing from this list was not seen, which is not the same as absent: report it as unverified. outOfScope frames exist in the file but sit outside the linked node."; + +/// Every frame the explore listed and what became of it, so "there is no +/// screen for X" is answered from a list that stays in the plan file and in +/// `status`, never from memory of an earlier call. +fn frame_inventory( + screens: &[PlanScreenInput], + out_of_scope: &[Value], + excluded_rows: &[Value], + kept: &[PlanScreenInput], +) -> Value { + let frames = screens + .iter() + .map(|screen| { + json!({"nodeId": screen.node_id, "name": screen.name, "width": screen.width, + "section": screen.section_name.clone().or_else(|| screen.section.clone()), + "disposition": disposition_of(&screen.node_id, out_of_scope, excluded_rows, kept)}) + }) + .collect::>(); + json!({"total": frames.len(), "frames": frames, "rule": ABSENCE_RULE}) +} + +/// Whether a link label names a frame: the same words with widths and +/// device words dropped, or the same letters once spacing is removed. +fn label_names_frame(label: &str, frame: &str) -> bool { + let label_compact = compact(label); + if label_compact.is_empty() { + return false; + } + let label_words = base_words(label); + let frame_words = base_words(frame); + (!label_words.is_empty() && label_words == frame_words) + || compact(&frame_words.join("")) == compact(&label_words.join("")) + || compact(frame) == label_compact +} + +/// Link and menu labels with no URL. A tab "Core technology" with no +/// destination hints that a frame for it exists somewhere in the file, so +/// each label is matched against every frame the explore listed, in scope or +/// not. An unresolved label is never pointed at `/`. +fn link_label_check( + screens: &[PlanScreenInput], + out_of_scope: &[Value], + excluded_rows: &[Value], + kept: &[PlanScreenInput], +) -> Value { + let mut rows = Vec::new(); + for screen in screens { + for link in screen.links.iter().filter(|link| link.url.is_none()) { + if compact(&link.label).is_empty() { + continue; + } + let matches = screens + .iter() + .filter(|other| { + match link.destination_node_id.as_deref() { + Some(destination) => destination == other.node_id, + None => label_names_frame(&link.label, &other.name), + } + }) + .map(|other| { + json!({"nodeId": other.node_id, "name": other.name, + "disposition": disposition_of(&other.node_id, out_of_scope, excluded_rows, kept)}) + }) + .collect::>(); + let state = if !matches.is_empty() { + "frame-found" + } else if link.destination_node_id.is_some() { + "destination-not-listed" + } else { + "unverified" + }; + let next = match state { + "frame-found" => { + "Plan the matched frame, or bind the label to its route with navigationTargets." + } + "destination-not-listed" => { + "The prototype names a destination the explore did not list: export or search that node before deciding anything about it." + } + _ => { + "Run devup_figma_search for this label before saying no screen exists; until then report it as unverified and leave the link unresolved." + } + }; + rows.push(json!({"on": screen.node_id, "onName": screen.name, + "label": link.label, "destinationNodeId": link.destination_node_id, + "state": state, "frames": matches, "next": next})); + } + } + let unresolved = rows + .iter() + .filter(|row| row["state"] != "route-bound") + .count(); + json!({"labels": rows, "unresolved": unresolved, + "rule": "A link or menu label with no URL remains unresolved until it is bound to a route or explicitly left unlinked. Search for a matching frame with devup_figma_search before claiming it is absent. Not listed means unverified, not absent. Never point an unresolved label at / or any other guessed route."}) +} + +/// Reconcile draft diagnostics with confirmed placements and actual link URLs. +fn refresh_inventory_checks(plan: &mut Value) { + if !plan["frameInventory"].is_object() { + return; + } + let confirmed = plan["status"] == "confirmed"; + let screens = array(&plan["screens"]).to_vec(); + let routes = array(&plan["routes"]).to_vec(); + let excluded = array(&plan["excluded"]).to_vec(); + let fixes = array(&plan["designFixes"]).to_vec(); + if confirmed && let Some(frames) = plan["frameInventory"]["frames"].as_array_mut() { + for frame in frames { + let node = frame["nodeId"].clone(); + if frame["disposition"] == "outOfScope" || frame["disposition"] == "siteAsset" { + continue; + } + let placement = routes + .iter() + .find(|route| array(&route["nodeIds"]).contains(&node)); + frame["disposition"] = json!(if let Some(route) = placement { + text(&route["implementation"]) + } else if excluded.iter().any(|row| row["nodeId"] == node) { + "excluded" + } else if fixes.iter().any(|fix| array(&fix["frames"]) + .iter() + .any(|row| row["nodeId"] == node)) + { + "designFix" + } else { + "notBuilt" + }); + frame["route"] = placement.map_or(Value::Null, |route| route["url"].clone()); + } + } + let inventory = array(&plan["frameInventory"]["frames"]).to_vec(); + if let Some(labels) = plan["linkLabelCheck"]["labels"].as_array_mut() { + for label in labels.iter_mut() { + let mut matches = array(&label["frames"]).to_vec(); + for frame in &mut matches { + if let Some(current) = inventory + .iter() + .find(|row| row["nodeId"] == frame["nodeId"]) + { + frame["disposition"] = current["disposition"].clone(); + frame["route"] = current["route"].clone(); + } + } + label["frames"] = json!(matches); + label["state"] = json!(if !array(&label["frames"]).is_empty() { + "frame-found" + } else if label["destinationNodeId"].is_string() { + "destination-not-listed" + } else { + "unverified" + }); + label["next"] = json!( + "Bind the label to a verified route or leave it unlinked. Run devup_figma_search before claiming that no destination frame exists." + ); + let link = screens + .iter() + .find(|screen| { + array(&screen["frames"]) + .iter() + .any(|frame| frame["nodeId"] == label["on"]) + }) + .and_then(|screen| { + array(&screen["links"]).iter().find(|link| { + link["label"] == label["label"] + && link["destinationNodeId"] == label["destinationNodeId"] + }) + }); + label["resolved"] = link.map_or(Value::Null, |link| link["resolved"].clone()); + if confirmed + && link.is_some_and(|link| link["resolved"]["evidence"] == "confirmed answer") + && (label["resolved"]["url"].is_null() || label["resolved"]["url"] == "remove") + { + label["state"] = json!("left-unlinked"); + label["next"] = json!("Keep this label unlinked as confirmed."); + } else if label["resolved"]["url"].is_string() { + label["state"] = json!("route-bound"); + label["next"] = json!("Use the resolved URL for this label."); + } + } + let unresolved = labels + .iter() + .filter(|label| label["state"] != "route-bound" && label["state"] != "left-unlinked") + .count(); + plan["linkLabelCheck"]["unresolved"] = json!(unresolved); + } } diff --git a/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs b/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs index 93838988..b6d26303 100644 --- a/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs +++ b/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs @@ -536,20 +536,41 @@ fn plan_route_group(context: RouteGroupPlanning<'_, '_>, index: usize, screen: & let mut links = Vec::new(); for (link_index, link) in screen.links.iter().enumerate() { let resolved = link - .url - .as_deref() - .map(|url| json!({"url": url, "evidence": "figma-url"})) - .or_else(|| { - let destination = link.destination_node_id.as_deref()?; - targets - .get(destination) - .map(|url| json!({"url": url, "evidence": format!("navigationTargets[{destination}]")})) - .or_else(|| { - frame_owner.get(destination).map(|owner| { - json!({"screen": drafts[*owner].id(), "evidence": "destination frame in this plan"}) + .url + .as_deref() + .map(|url| json!({"url": url, "evidence": "figma-url"})) + .or_else(|| { + let inferred = if link.destination_node_id.is_none() { + let owners = drafts + .iter() + .enumerate() + .filter(|(_, draft)| { + screen_apps.get(draft.id()).map(|app| &app.dir) + == screen_apps.get(screen.id()).map(|app| &app.dir) + && draft.frames.iter().any(|frame| { + label_names_frame(&link.label, &frame.name) + }) + }) + .map(|(index, _)| index) + .collect::>(); + (owners.len() == 1).then(|| owners[0]) + } else { + None + }; + let destination = link + .destination_node_id + .as_deref() + .or_else(|| inferred.map(|owner| drafts[owner].id()))?; + frame_owner.get(destination).map(|owner| { + json!({"screen": drafts[*owner].id(), + "nodeId": link.destination_node_id, + "evidence": if inferred.is_some() { "label matches a frame in this plan" } else { "destination frame in this plan" }}) + }).or_else(|| { + targets.get(destination).map(|url| { + json!({"url": url, "evidence": format!("navigationTargets[{destination}]")}) }) }) - }); + }); if resolved.is_none() { decisions.push("unresolved-link"); let mut choices = vec![choice( diff --git a/crates/devup-mcp/src/server/screen_plan/status.rs b/crates/devup-mcp/src/server/screen_plan/status.rs index 1825ec64..f8d0ed92 100644 --- a/crates/devup-mcp/src/server/screen_plan/status.rs +++ b/crates/devup-mcp/src/server/screen_plan/status.rs @@ -294,6 +294,8 @@ pub(super) fn status_report( "defaults": plan["defaults"], "designFixes": design_fixes, "excluded": excluded, + "frameInventory": plan["frameInventory"], + "linkLabelCheck": plan["linkLabelCheck"], "inventoryNote": evidence.inventory_note, "rule": "A width without a runtime audit item is not-run, never pass. Implemented means an App Router page exists for the URL, not that it renders.", }) diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index 0ed79910..d882b926 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -3,3 +3,4 @@ include!("screen_plan_tests/fixtures_and_cases.rs"); include!("screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs"); include!("screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs"); +include!("screen_plan_tests/frame_inventory_and_link_labels.rs"); diff --git a/crates/devup-mcp/src/server/screen_plan_tests/frame_inventory_and_link_labels.rs b/crates/devup-mcp/src/server/screen_plan_tests/frame_inventory_and_link_labels.rs new file mode 100644 index 00000000..22ab6ec8 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests/frame_inventory_and_link_labels.rs @@ -0,0 +1,281 @@ +/// Every listed frame stays in the plan with what became of it, and a menu +/// label with no URL is matched against those frames instead of being +/// pointed at `/`. +#[test] +fn frame_inventory_lists_every_frame_and_link_labels_are_matched() { + let mut home = screen("40:1", "Home", 1440.0); + home.links = vec![ + PlanLinkInput { + label: "Core technology".into(), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Pricing".into(), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Company".into(), + url: Some("/company".into()), + ..PlanLinkInput::default() + }, + ]; + let plan = draft_plan_with( + &[home, screen("40:2", "Core Technology - 1440", 1440.0)], + &[], + &[], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + + let inventory = &plan["frameInventory"]; + assert_eq!(inventory["total"], 2); + assert!( + array(&inventory["frames"]) + .iter() + .all(|frame| frame["disposition"] == "planned"), + "{inventory}" + ); + assert!(text(&inventory["rule"]).contains("unverified")); + + let check = &plan["linkLabelCheck"]; + let labels = array(&check["labels"]); + assert_eq!(labels.len(), 2, "a link with a URL is not checked: {check}"); + let core = labels + .iter() + .find(|row| row["label"] == "Core technology") + .unwrap(); + assert_eq!(core["state"], "frame-found"); + assert_eq!(core["frames"][0]["nodeId"], "40:2"); + let pricing = labels.iter().find(|row| row["label"] == "Pricing").unwrap(); + assert_eq!(pricing["state"], "unverified"); + assert!(text(&pricing["next"]).contains("devup_figma_search")); + assert_eq!( + check["unresolved"], 2, + "a found frame still needs a confirmed route" + ); + assert_eq!(plan["screens"][0]["links"][0]["resolved"]["screen"], "40:2"); +} + +#[test] +fn frame_inventory_retains_excluded_assets_and_out_of_scope_frames() { + let screens = vec![ + sectioned("41:1", "Home", 1440.0, "Site"), + PlanScreenInput { + visible: Some(false), + ..sectioned("41:2", "Hidden", 1440.0, "Site") + }, + sectioned("41:3", "Logo", 300.0, "Site"), + sectioned("41:4", "Other", 1440.0, "Elsewhere"), + ]; + let plan = draft_plan_with( + &screens, + &[], + &[], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "section-Site"}), + None, + ) + .unwrap(); + let frames = array(&plan["frameInventory"]["frames"]); + assert_eq!(frames.len(), 4); + assert_eq!( + frames + .iter() + .map(|frame| text(&frame["disposition"])) + .collect::>(), + ["planned", "excluded", "siteAsset", "outOfScope"] + ); + assert_eq!( + status_report(&plan, &evidence(), None)["frameInventory"], + plan["frameInventory"] + ); + let md = markdown(&plan); + for screen in screens { + assert!(md.contains(&screen.node_id)); + } +} + +#[test] +fn incomplete_or_duplicate_frame_inventories_are_refused() { + for field in ["projection", "candidates", "atCeiling"] { + let mut source = json!({"truncation": {}}); + source["truncation"][field] = json!(true); + assert!( + draft_plan( + &[screen("42:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence(), + source, + None + ) + .unwrap_err() + .message + .contains("incomplete") + ); + } + assert!( + draft_plan( + &[ + screen("42:1", "Home", 1440.0), + screen("42:1", "Other", 390.0) + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None + ) + .is_err() + ); +} + +#[test] +fn explicit_destinations_override_labels_and_ambiguous_labels_stay_unresolved() { + let mut home = screen("43:1", "Home", 1440.0); + home.links = vec![ + PlanLinkInput { + label: "Pricing".into(), + destination_node_id: Some("missing".into()), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Pricing".into(), + ..PlanLinkInput::default() + }, + ]; + let plan = draft_plan( + &[ + home, + sectioned("43:2", "Pricing", 1440.0, "A"), + sectioned("43:3", "Pricing", 390.0, "B"), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + assert_eq!( + plan["linkLabelCheck"]["labels"][0]["state"], + "destination-not-listed" + ); + assert!(array(&plan["linkLabelCheck"]["labels"][0]["frames"]).is_empty()); + assert!(plan["screens"][0]["links"][0]["resolved"].is_null()); + for link in array(&plan["screens"][0]["links"]) { + assert!(link["resolved"]["url"].is_null()); + } + assert_eq!(plan["linkLabelCheck"]["unresolved"], 2); +} + +#[tokio::test] +async fn link_labels_follow_confirmed_routes_and_inventory_survives_status() { + let fixture = Fixture::new(); + let mut home = screen("44:1", "Home", 1440.0); + home.links = vec![PlanLinkInput { + label: "Core technology".into(), + ..PlanLinkInput::default() + }]; + let mut draft_input = input(&fixture, "draft"); + draft_input.navigation_targets = BTreeMap::from([("44:1".into(), "/".into())]); + let result = draft_with( + &draft_input, + &[home, screen("44:2", "Core Technology - 1440", 1440.0)], + &[], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + assert_eq!(result["frameInventory"]["total"], 2); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = Some(text(&plan["planId"]).into()); + confirm_input.answers = array(&plan["questions"]) + .iter() + .map(|q| { + ( + text(&q["id"]).into(), + AnswerInput::One(first_label(q).into()), + ) + }) + .collect(); + confirm_input + .answers + .insert("route:44:2".into(), AnswerInput::One("/technology".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let check = &confirmed["linkLabelCheck"]; + assert_eq!(check["labels"][0]["state"], "route-bound"); + assert_eq!(check["labels"][0]["resolved"]["url"], "/technology"); + assert_eq!(check["unresolved"], 0); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(plan["frameInventory"]["frames"][1]["route"], "/technology"); + assert_eq!( + status_report(&plan, &evidence(), None)["linkLabelCheck"], + *check + ); +} + +#[tokio::test] +async fn prototype_links_follow_the_selected_frame_when_a_group_is_split() { + let fixture = Fixture::new(); + let mut home = screen("45:1", "Landing", 1440.0); + home.links = vec![ + PlanLinkInput { + label: "Pricing".into(), + destination_node_id: Some("45:3".into()), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Pricing".into(), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Unknown".into(), + ..PlanLinkInput::default() + }, + ]; + let draft_input = input(&fixture, "draft"); + draft_with( + &draft_input, + &[ + home, + screen("45:2", "Pricing", 1440.0), + screen("45:3", "Pricing", 390.0), + ], + &[], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = Some(text(&plan["planId"]).into()); + confirm_input.answers = array(&plan["questions"]) + .iter() + .map(|q| { + ( + text(&q["id"]).into(), + AnswerInput::One(first_label(q).into()), + ) + }) + .collect(); + confirm_input.answers.insert( + "group:45:2".into(), + AnswerInput::One("/desktop, /mobile".into()), + ); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!( + confirmed["linkLabelCheck"]["labels"][0]["resolved"]["url"], + "/mobile" + ); + assert_eq!( + confirmed["linkLabelCheck"]["labels"][2]["state"], + "left-unlinked" + ); + assert!(confirmed["linkLabelCheck"]["labels"][1]["resolved"]["url"].is_null()); + assert_eq!(confirmed["linkLabelCheck"]["unresolved"], 1); +} diff --git a/crates/devup-mcp/src/server/text/devup-screen-plan.md b/crates/devup-mcp/src/server/text/devup-screen-plan.md index 549550c1..a0c8aecf 100644 --- a/crates/devup-mcp/src/server/text/devup-screen-plan.md +++ b/crates/devup-mcp/src/server/text/devup-screen-plan.md @@ -1 +1,2 @@ -Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `questionRounds`: questions already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed), at most maxQuestionsPerRound (default 4) per round. Ask everything now: a question tool with no cap (`question` in OpenCode) takes the flat `questions` list in one call; one with a cap (`AskUserQuestion` in Claude Code) takes the rounds in consecutive calls with no other work in between, skipping a question whose `when` was not met; never ask in prose and never implement before confirm. confirm takes every applicable answer keyed by question id and refuses the call, naming the missing ids. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. \ No newline at end of file +Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `questionRounds`: questions already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed), at most maxQuestionsPerRound (default 4) per round. Ask everything now: a question tool with no cap (`question` in OpenCode) takes the flat `questions` list in one call; one with a cap (`AskUserQuestion` in Claude Code) takes the rounds in consecutive calls with no other work in between, skipping a question whose `when` was not met; never ask in prose and never implement before confirm. confirm takes every applicable answer keyed by question id and refuses the call, naming the missing ids. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. +The saved plan, draft, confirm and status expose frameInventory (every listed frame, including excluded, out-of-scope and site assets) and linkLabelCheck. Incomplete exploration is refused. A unique link-label match follows its frame's confirmed route; explicit prototype destinations take priority and ambiguous labels stay unresolved. A found frame without a bound route is still unresolved. Search frameInventory and run devup_figma_search before claiming a screen does not exist; an unlisted name is unverified. Never bind an unresolved label to a guessed route. diff --git a/crates/devup-mcp/src/server/visual_compare.rs b/crates/devup-mcp/src/server/visual_compare.rs index f07fa072..0d30522c 100644 --- a/crates/devup-mcp/src/server/visual_compare.rs +++ b/crates/devup-mcp/src/server/visual_compare.rs @@ -837,7 +837,13 @@ mod tests { let f = fixture(); let outside = fixture(); #[cfg(windows)] - std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")).unwrap(); + if let Err(error) = std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")) { + if error.raw_os_error() == Some(1314) { + eprintln!("Skipping symlink escape test: Windows symlink privilege is unavailable."); + return; + } + panic!("create escape symlink: {error}"); + } #[cfg(unix)] std::os::unix::fs::symlink(&outside.0, f.0.join("escape")).unwrap(); assert!( diff --git a/crates/devup-mcp/tests/output_policy.rs b/crates/devup-mcp/tests/output_policy.rs index 346bcd22..00780c25 100644 --- a/crates/devup-mcp/tests/output_policy.rs +++ b/crates/devup-mcp/tests/output_policy.rs @@ -245,7 +245,9 @@ fn rejects_a_directory_symlink_parent_that_escapes_the_root() -> anyhow::Result< let root = unique_temp_dir("symlink-root")?; let outside = unique_temp_dir("symlink-outside")?; if let Err(error) = symlink_dir(&outside, root.join("linked")) { - if error.kind() == std::io::ErrorKind::PermissionDenied { + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(1314) + { fs::remove_dir_all(root)?; fs::remove_dir_all(outside)?; return Ok(()); From 015334284c89e0b7107406e75c8d8ce43f9086c2 Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 17:34:45 +0900 Subject: [PATCH 04/11] fix: guard Figma section and asset coverage --- .../changepack_log_F-lJ1_TR61qiwFfH3-BxP.json | 7 + crates/devup-mcp/src/server/asset_guard.rs | 665 ++++++++++++++++++ .../src/server/handlers/devup_ui_validate.rs | 21 + crates/devup-mcp/src/server/mask_guard.rs | 4 +- .../mod_parts/exploration_and_responses.rs | 72 +- .../server/mod_parts/state_and_services.rs | 4 +- .../projection/finalize_export_delivery.rs | 1 + .../project_node_and_theme_outputs.rs | 34 +- .../server/projection/project_operation.rs | 4 + .../projection/project_section_frames.rs | 10 + .../src/server/text/devup-figma-export.md | 2 +- .../src/server/text/devup-ui-validate.md | 2 +- crates/devup-mcp/src/server/tools.rs | 14 + crates/devup-mcp/src/server/visual_compare.rs | 10 +- .../devup-mcp/tests/export_project_theme.rs | 5 + crates/devup-mcp/tests/output_policy.rs | 4 +- .../devup-mcp/tests/ui_validate_response.rs | 2 + .../ui_validate_response/section_coverage.rs | 42 ++ 18 files changed, 857 insertions(+), 46 deletions(-) create mode 100644 .changepacks/changepack_log_F-lJ1_TR61qiwFfH3-BxP.json create mode 100644 crates/devup-mcp/src/server/asset_guard.rs create mode 100644 crates/devup-mcp/tests/ui_validate_response/section_coverage.rs diff --git a/.changepacks/changepack_log_F-lJ1_TR61qiwFfH3-BxP.json b/.changepacks/changepack_log_F-lJ1_TR61qiwFfH3-BxP.json new file mode 100644 index 00000000..2770c239 --- /dev/null +++ b/.changepacks/changepack_log_F-lJ1_TR61qiwFfH3-BxP.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Report missing public assets and omitted Figma sections and asset references during export and UI validation", + "date": "2026-10-19T08:09:42.879273700Z" +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/asset_guard.rs b/crates/devup-mcp/src/server/asset_guard.rs new file mode 100644 index 00000000..5f0d9394 --- /dev/null +++ b/crates/devup-mcp/src/server/asset_guard.rs @@ -0,0 +1,665 @@ +//! `devup_ui_validate`'s `asset-missing` rule: generated code that points at +//! an asset file the project does not have. +//! +//! An export without `assetPublicRoot` keeps placeholder asset paths, and an +//! asset request that failed or was never made leaves the reference in the +//! TSX with no file behind it. Nothing in the source shows that: the page +//! compiles, and the browser draws an empty box or a broken-image icon where +//! the design had a picture. So when `projectRoot` is given, every +//! root-relative asset URL (`src`, and any `url(...)` in a style value) is +//! looked up under the project's public directories, and one that resolves +//! to no file is an error. +//! +//! Like the rest of the ground-truth tools this reads the disk on every call +//! and never caches. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use devup_mcp_devup_ui::ui_validate::{Severity, Violation}; +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; +use serde_json::{Map, json}; + +use super::mask_guard::{percent_decode, public_dirs_of}; + +pub(super) const RULE: &str = "asset-missing"; +pub(super) const COVERAGE_RULE: &str = "section-coverage"; + +/// Paths may be renamed/deduplicated or percent-encoded at delivery. Keep +/// the original node census, but freeze URLs only after those rewrites. +pub(super) fn refresh_export_asset_urls(result: &mut serde_json::Value) { + if let Some(object) = result.as_object_mut() { + for field in ["tsx", "componentTsx"] { + if let Some(source) = object.get(field).and_then(serde_json::Value::as_str) { + let urls = asset_urls(source) + .into_iter() + .map(|(url, _)| url) + .collect::>(); + if let Some(contract) = object + .get_mut("outputResults") + .and_then(|outputs| outputs.get_mut(field)) + .and_then(|output| output.get_mut("sectionCoverage")) + .and_then(serde_json::Value::as_object_mut) + { + contract.insert("assetUrls".into(), json!(urls)); + } + } + } + if let Some(frames) = object + .get_mut("frames") + .and_then(serde_json::Value::as_array_mut) + { + for frame in frames { + refresh_export_asset_urls(frame); + } + } + } +} + +pub(super) fn asset_coverage_violations( + sources: &[(&str, &str)], + expected: &[String], +) -> Vec { + let present = sources + .iter() + .flat_map(|(_, source)| asset_urls(source).into_iter().map(|(url, _)| url)) + .collect::>(); + expected.iter().collect::>().into_iter().filter(|url| !present.contains(url.as_str())).map(|url| Violation { + rule: COVERAGE_RULE, + severity: Severity::Error, + byte_range: [0, 0], + context: Map::from_iter([("assetUrl".into(), json!(url))]), + message: format!("Asset reference {url} from the original export is missing from the supplied code."), + suggestion: Some("Restore the generated image, mask or background reference from the original export.".into()), + }).collect() +} + +/// Compare against the original export, not the edited code's source map. +/// Parse JSX attributes so comments and string literals cannot satisfy coverage. +pub(super) fn coverage_violations(sources: &[(&str, &str)], expected: &[String]) -> Vec { + let mut present = BTreeSet::new(); + for (_, source) in sources { + for attr in devup_mcp_devup_ui::provenance::attributes::generated_attributes(source) { + if attr.name != "data-figma-node-id" { + continue; + } + let Some((_, value)) = attr.source.split_once('=') else { + continue; + }; + let value = value + .trim() + .trim_start_matches('{') + .trim_end_matches('}') + .trim(); + if let Some(quote @ ('\'' | '"')) = value.chars().next() + && value.ends_with(quote) + { + present.insert(value[1..value.len() - 1].to_owned()); + } + } + } + expected.iter().collect::>().into_iter() + .filter(|id| !present.contains(id.as_str())) + .map(|id| Violation { + rule: COVERAGE_RULE, + severity: Severity::Error, + byte_range: [0, 0], + context: Map::from_iter([("nodeId".into(), json!(id))]), + message: format!("Captured section or asset {id} is missing from the supplied JSX identity coverage."), + suggestion: Some("Restore the generated section/image and its data-figma-node-id from the original export. Static identity coverage does not verify runtime visibility.".into()), + }).collect() +} + +/// Expected visible sections and assets come from the captured tree, even if +/// generation omitted them. Asset boundaries deliberately hide their internals. +pub(super) fn export_coverage( + snapshot: &devup_mcp_figma::Snapshot, + root: &str, + output: &mut devup_mcp_devup_ui::codegen::CodegenOutput, + inline_instances: bool, + annotate: bool, +) -> serde_json::Value { + let mut scoped = snapshot.clone(); + scoped.roots = vec![root.into()]; + let manifest = devup_mcp_figma::discover_asset_manifest(&scoped); + let assets = manifest + .assets + .iter() + .filter(|a| a.error_code.as_deref() != Some("DEVUP_ASSET_NODE_HIDDEN")) + .map(|a| a.node_id.as_str()) + .collect::>(); + let mut wanted = BTreeSet::new(); + let mut seen = BTreeSet::new(); + let mut pending = vec![(root, 0)]; + while let Some((id, depth)) = pending.pop() { + if !seen.insert(id) { + continue; + } + let Some(node) = snapshot.nodes.get(id) else { + continue; + }; + let view = node.typed_view(); + if view.bool("visible") == Some(false) + || view.number("opacity").is_some_and(|opacity| opacity <= 0.0) + { + continue; + } + if manifest + .assets + .iter() + .any(|a| a.node_id == id && a.error_code.as_deref() == Some("DEVUP_ASSET_NODE_HIDDEN")) + { + continue; + } + if depth <= 1 || node.node_type == "SECTION" || assets.contains(id) { + wanted.insert(id.to_owned()); + } + // Leaf assets (including a flattened wrapper) represent all their children. + let flattened = manifest + .assets + .iter() + .any(|a| a.node_id == id && a.field == "node"); + if flattened || (!inline_instances && node.node_type == "INSTANCE") { + continue; + } + pending.extend(view.child_ids().map(|child| (child, depth + 1))); + } + if annotate { + devup_mcp_devup_ui::codegen::node_ids::annotate_node_ids(output, &wanted); + // Identity attributes are derived from captured IDs. Record that + // derivation so the final attribute audit can verify them too. + for attr in devup_mcp_devup_ui::provenance::attributes::generated_attributes(&output.tsx) { + if attr.name != "data-figma-node-id" { + continue; + } + let Some((_, value)) = attr.source.split_once('=') else { + continue; + }; + let id = value.trim().trim_matches('"'); + output + .source_map + .entries + .push(devup_mcp_devup_ui::provenance::ProvenanceEntry { + generated_range: Some(devup_mcp_devup_ui::provenance::GeneratedRange { + start: attr.start, + end: attr.end, + }), + calculation: None, + generated_property: Some(attr.source.clone()), + json_pointer: None, + node_id: Some(id.into()), + property: Some("id".into()), + variable_id: None, + style_id: None, + asset_id: None, + resolution: "exact".into(), + element_index: Some(attr.element), + }); + } + } + let node_ids = wanted.into_iter().collect::>(); + let mapped = output + .source_map + .entries + .iter() + .filter(|e| e.element_index.is_some()) + .filter_map(|e| e.node_id.as_ref()) + .collect::>(); + for id in node_ids.iter().filter(|id| !mapped.contains(id)) { + output.diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_SECTION_COVERAGE".into(), + node_id: Some(id.clone()), + message: format!("Captured section or asset {id} has no generated JSX element."), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), + details: Some(json!({"stage":"projection","classification":"omitted-content","originalValue":{"nodeId":id},"appliedValue":null,"appliedValueReason":"No generated JSX element maps to the captured node."})), + ..Default::default() + }); + } + for asset in manifest + .assets + .iter() + .filter(|asset| node_ids.contains(&asset.node_id)) + { + if output.source_map.entries.iter().any(|entry| { + entry.asset_id.as_deref() == Some(&asset.asset_id) && entry.element_index.is_some() + }) { + continue; + } + output.diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_SECTION_COVERAGE".into(), + node_id: Some(asset.node_id.clone()), + property: Some("assetReference".into()), + message: format!( + "Captured asset {} has no generated asset reference.", + asset.asset_id + ), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), + details: Some(json!({"stage":"projection","classification":"omitted-content","originalValue":{"assetId":asset.asset_id,"nodeId":asset.node_id},"appliedValue":null,"appliedValueReason":"No generated asset reference maps to the captured asset."})), + ..Default::default() + }); + } + if annotate { + let asset_urls = asset_urls(&output.tsx) + .into_iter() + .map(|(url, _)| url) + .collect::>(); + json!({"nodeIds":node_ids,"assetUrls":asset_urls}) + } else { + serde_json::Value::Null + } +} + +/// Extensions treated as files a page serves from `public`. +const ASSET_EXTENSIONS: &[&str] = &[ + "svg", "png", "jpg", "jpeg", "webp", "gif", "avif", "ico", "bmp", "woff", "woff2", "ttf", + "otf", "mp4", "webm", "mp3", "pdf", +]; + +/// One `asset-missing` error per distinct root-relative asset URL in each +/// source that no public directory of `project_root` holds. A project with +/// no public directory at all is still checked: every asset is then missing. +pub(super) fn violations(sources: &[(&str, &str)], project_root: Option<&str>) -> Vec { + let Some(root) = project_root.map(Path::new).filter(|root| root.is_dir()) else { + return Vec::new(); + }; + let mut public_dirs: Option> = None; + let mut found = Vec::new(); + for (name, source) in sources { + let mut reported = BTreeSet::new(); + for (url, byte_range) in asset_urls(source) { + if !reported.insert(url.clone()) { + continue; + } + let dirs = public_dirs.get_or_insert_with(|| public_dirs_of(root)); + if served(dirs, &url) { + continue; + } + let looked_in = dirs + .iter() + .map(|dir| { + dir.strip_prefix(root) + .unwrap_or(dir) + .to_string_lossy() + .replace('\\', "/") + }) + .collect::>(); + let mut context = Map::new(); + context.insert("assetUrl".into(), json!(url)); + context.insert("publicDirs".into(), json!(looked_in)); + if !name.is_empty() { + context.insert("sourceName".into(), json!(name)); + } + found.push(Violation { + rule: RULE, + severity: Severity::Error, + byte_range, + context, + message: if looked_in.is_empty() { + format!("{url} is referenced, but the project has no public directory, so no file serves it; the browser renders an empty or broken image.") + } else { + format!("{url} is referenced, but no file at that path exists under {}; the browser renders an empty or broken image.", looked_in.join(", ")) + }, + suggestion: Some( + "Acquire the asset with devup_figma_export assetRequests (outputPath under public/, or assetPublicRoot so references are rewritten), or point the reference at the file that exists. Never leave a placeholder path in shipped code.".into(), + ), + }); + } + } + found +} + +/// Whether a root-relative URL is a file under one of `dirs`. A URL with a +/// segment that could leave the directory is never served. +fn served(dirs: &[PathBuf], url: &str) -> bool { + let segments = url.trim_start_matches('/').split('/').collect::>(); + if segments.iter().any(|segment| { + segment.is_empty() || matches!(*segment, "." | "..") || segment.contains(['\\', ':']) + }) { + return false; + } + dirs.iter().any(|dir| { + segments + .iter() + .fold(dir.clone(), |path, segment| path.join(segment)) + .is_file() + }) +} + +/// Root-relative asset URLs with their byte ranges: the quoted value of a +/// `src` prop or property, and the inside of every `url(...)`. +fn asset_urls(source: &str) -> Vec<(String, [usize; 2])> { + #[derive(Default)] + struct Literals { + src: Vec<[usize; 2]>, + strings: Vec<[usize; 2]>, + } + impl<'a> Visit<'a> for Literals { + fn visit_jsx_attribute(&mut self, attr: &JSXAttribute<'a>) { + if attr + .name + .as_identifier() + .is_some_and(|name| name.name == "src") + { + let span = match &attr.value { + Some(JSXAttributeValue::StringLiteral(s)) => Some(s.span), + Some(JSXAttributeValue::ExpressionContainer(c)) => match &c.expression { + JSXExpression::StringLiteral(s) => Some(s.span), + JSXExpression::TemplateLiteral(t) if t.expressions.is_empty() => { + Some(t.span) + } + _ => None, + }, + _ => None, + }; + if let Some(span) = span { + self.src.push([span.start as usize, span.end as usize]); + } + } + walk::walk_jsx_attribute(self, attr); + } + fn visit_object_property(&mut self, prop: &ObjectProperty<'a>) { + if !prop.computed + && prop.key.static_name().as_deref() == Some("src") + && matches!( + &prop.value, + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) + ) + { + let span = prop.value.span(); + self.src.push([span.start as usize, span.end as usize]); + } + walk::walk_object_property(self, prop); + } + fn visit_string_literal(&mut self, literal: &StringLiteral<'a>) { + self.strings + .push([literal.span.start as usize, literal.span.end as usize]); + } + fn visit_template_literal(&mut self, literal: &TemplateLiteral<'a>) { + if literal.expressions.is_empty() { + self.strings + .push([literal.span.start as usize, literal.span.end as usize]); + } + walk::walk_template_literal(self, literal); + } + } + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let mut literals = Literals::default(); + literals.visit_program(&parsed.program); + let mut urls = Vec::new(); + for span in literals.src { + if let Some(raw) = source.get(span[0] + 1..span[1].saturating_sub(1)) + && let Some(url) = asset_path(raw) + { + urls.push((url, span)); + } + } + let mut cursor = 0; + while let Some(offset) = source[cursor..].find("url(") { + let open = cursor + offset + "url(".len(); + let Some(close) = source[open..].find(')') else { + break; + }; + let raw = source[open..open + close] + .trim() + .trim_matches(|c| matches!(c, '\'' | '"')); + if let Some(url) = asset_path(raw) + && literals + .strings + .iter() + .any(|span| span[0] <= open && open + close < span[1]) + { + urls.push((url, [open - "url(".len(), open + close + 1])); + } + cursor = open + close + 1; + } + urls.sort_by_key(|(_, range)| range[0]); + urls +} + +/// The decoded path of a root-relative asset URL, without query or fragment. +/// Template interpolation, protocol-relative and absolute URLs are not files +/// of this project and are left alone. +fn asset_path(raw: &str) -> Option { + if raw.contains("${") { + return None; + } + let path = raw.split(['?', '#']).next().unwrap_or(raw); + if !path.starts_with('/') || path.starts_with("//") { + return None; + } + let extension = path.rsplit_once('.')?.1.to_ascii_lowercase(); + ASSET_EXTENSIONS + .contains(&extension.as_str()) + .then(|| percent_decode(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coverage_uses_jsx_identity_across_files_not_comments_or_strings() { + let expected = vec![ + "section".into(), + "picture".into(), + "absent".into(), + "absent".into(), + ]; + let found = coverage_violations( + &[ + ("page.tsx", r#""#), + ( + "image.tsx", + r#"; /* */ const example = '';"#, + ), + ], + &expected, + ); + assert_eq!(found.len(), 1); + assert_eq!(found[0].context["nodeId"], "absent"); + assert_eq!(found[0].severity, Severity::Error); + } + + #[test] + fn export_reports_omitted_sections_and_images_without_requiring_diagnostics() { + use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; + let snapshot = serde_json::from_value(json!({ + "fileKey":"coverage", "roots":["root"], "diagnostics":[], "nodes": { + "root":{"id":"root","type":"FRAME","fields":{"layoutMode":"VERTICAL","childrenIds":["section","hidden"]}}, + "section":{"id":"section","type":"FRAME","fields":{"layoutMode":"VERTICAL","childrenIds":["picture"]}}, + "picture":{"id":"picture","type":"RECTANGLE","fields":{"width":100,"height":100,"fills":[{"type":"IMAGE","imageHash":"photo"}]}}, + "hidden":{"id":"hidden","type":"FRAME","fields":{"visible":false,"childrenIds":["hiddenPicture"]}}, + "hiddenPicture":{"id":"hiddenPicture","type":"RECTANGLE","fields":{"fills":[{"type":"IMAGE","imageHash":"hidden"}]}} + } + })).unwrap(); + let mut output = generate_component(&snapshot, "root", &CodegenOptions::default()).unwrap(); + let contract = export_coverage(&snapshot, "root", &mut output, true, true); + let attributes = devup_mcp_devup_ui::provenance::attributes::attribute_contract(&output); + let unmapped = devup_mcp_devup_ui::provenance::attributes::uncovered_attributes( + &output.tsx, + &attributes, + ); + assert!( + !unmapped + .iter() + .any(|attr| attr["property"] == "data-figma-node-id"), + "{unmapped:?}" + ); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_SECTION_COVERAGE"), + "{:?}", + output.diagnostics + ); + let ids: Vec = serde_json::from_value(contract["nodeIds"].clone()).unwrap(); + assert!(ids.contains(&"section".into()), "{contract}"); + assert!(ids.contains(&"picture".into()), "{contract}"); + assert!(!ids.contains(&"hiddenPicture".into())); + assert!( + coverage_violations(&[("", &output.tsx)], &ids).is_empty(), + "{}", + output.tsx + ); + // Simulate a generator silently dropping the two nodes from its map. + output + .source_map + .entries + .retain(|e| !matches!(e.node_id.as_deref(), Some("section" | "picture"))); + export_coverage(&snapshot, "root", &mut output, true, false); + let missing = output + .diagnostics + .iter() + .filter(|d| d.code == "DEVUP_CODEGEN_SECTION_COVERAGE") + .collect::>(); + assert_eq!( + missing.iter().filter(|d| d.property.is_none()).count(), + 2, + "{missing:?}" + ); + assert!( + missing + .iter() + .any(|d| d.property.as_deref() == Some("assetReference")), + "{missing:?}" + ); + assert!( + missing + .iter() + .all(|d| d.fidelity_impact == Some(devup_mcp_figma::FidelityImpact::Lossy)) + ); + } + + #[test] + fn src_props_and_style_urls_are_read() { + let source = r#"const page = <> + + + + +About; +const s = { src: '/fonts/a.woff2' }"#; + let urls = asset_urls(source) + .into_iter() + .map(|(url, _)| url) + .collect::>(); + assert_eq!( + urls, + [ + "/images/hero.webp", + "/images/bg.png", + "/icons/a b.svg", + "/icons/c.svg", + "/fonts/a.woff2" + ] + ); + } + + #[test] + fn comments_examples_and_dynamic_sources_are_not_asset_references() { + let source = r#"// +const example = ''; +/* const style = { bg: "url(/comment.svg)" }; */ +; +;"#; + assert!(asset_urls(source).is_empty(), "{:?}", asset_urls(source)); + } + + #[test] + fn an_image_removed_from_an_existing_node_fails_reference_coverage() { + let expected = vec!["/picture.png".into(), "/mask.svg".into()]; + let source = + r#"<>"#; + let found = asset_coverage_violations(&[("", source)], &expected); + assert_eq!(found.len(), 1); + assert_eq!(found[0].context["assetUrl"], "/picture.png"); + } + + #[test] + fn delivery_refreshes_renamed_asset_urls_for_each_output_and_frame() { + let mut result = json!({"frames":[{ + "tsx":"", + "componentTsx":"", + "outputResults":{ + "tsx":{"sectionCoverage":{"nodeIds":["photo"],"assetUrls":["/images/placeholder.png"]}}, + "componentTsx":{"sectionCoverage":{"nodeIds":["icon"],"assetUrls":[]}} + } + }]}); + refresh_export_asset_urls(&mut result); + let output = &result["frames"][0]["outputResults"]; + assert_eq!( + output["tsx"]["sectionCoverage"]["assetUrls"], + json!(["/photos/final name.webp"]) + ); + assert_eq!( + output["componentTsx"]["sectionCoverage"]["assetUrls"], + json!(["/icons/final.svg"]) + ); + assert_eq!( + output["tsx"]["sectionCoverage"]["nodeIds"], + json!(["photo"]) + ); + } + + #[test] + fn traversal_is_never_served() { + let dirs = vec![PathBuf::from("public")]; + assert!(!served(&dirs, "/../Cargo.toml")); + assert!(!served(&dirs, "/a//b.png")); + assert!(!served(&dirs, "/C:/x.png")); + } + + #[test] + fn a_missing_file_is_an_error_and_a_present_one_is_not() -> std::io::Result<()> { + let root = std::env::temp_dir().join(format!( + "asset-guard-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(root.join("apps/web/public/images"))?; + std::fs::write(root.join("apps/web/public/images/hero.webp"), b"RIFF")?; + let tsx = r#"<>"#; + let root_text = root.to_string_lossy().into_owned(); + + let found = violations(&[("Page.tsx", tsx)], Some(&root_text)); + assert_eq!(found.len(), 1, "{found:?}"); + let violation = &found[0]; + assert_eq!(violation.rule, RULE); + assert_eq!(violation.severity, Severity::Error); + assert_eq!(violation.context["assetUrl"], "/images/missing.png"); + assert_eq!(violation.context["sourceName"], "Page.tsx"); + assert_eq!(violation.context["publicDirs"][0], "apps/web/public"); + assert_eq!( + &tsx[violation.byte_range[0]..violation.byte_range[1]], + "\"/images/missing.png\"" + ); + + assert!(violations(&[("", tsx)], None).is_empty()); + std::fs::remove_dir_all(&root)?; + Ok(()) + } + + #[test] + fn a_project_without_public_reports_every_asset() -> std::io::Result<()> { + let root = std::env::temp_dir().join(format!( + "asset-guard-empty-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&root)?; + let found = violations( + &[("", r#""#)], + Some(&root.to_string_lossy()), + ); + assert_eq!(found.len(), 1); + assert!(found[0].message.contains("no public directory")); + std::fs::remove_dir_all(&root)?; + Ok(()) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs index 2d897df5..e962bb9d 100644 --- a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs +++ b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs @@ -105,6 +105,27 @@ impl DevupServer { report.ok = false; } report.violations.extend(masks); + // A reference with no file behind it renders an empty box, which only + // the disk shows. + let missing = asset_guard::violations(&sources, input.project_root.as_deref()); + if !missing.is_empty() { + report.ok = false; + } + report.violations.extend(missing); + if let Some(coverage) = &input.section_coverage { + if coverage.node_ids.len() + coverage.asset_urls.len() > 4096 { + return Err(invalid("sectionCoverage exceeds 4096 entries.".into())); + } + let mut missing = asset_guard::coverage_violations(&sources, &coverage.node_ids); + missing.extend(asset_guard::asset_coverage_violations( + &sources, + &coverage.asset_urls, + )); + if !missing.is_empty() { + report.ok = false; + } + report.violations.extend(missing); + } // These keys are assembled here rather than serialized from // `UiValidation`, so anything the struct's own documentation // explains reaches nobody unless it is answered here too. Three diff --git a/crates/devup-mcp/src/server/mask_guard.rs b/crates/devup-mcp/src/server/mask_guard.rs index c15e6790..72287c30 100644 --- a/crates/devup-mcp/src/server/mask_guard.rs +++ b/crates/devup-mcp/src/server/mask_guard.rs @@ -168,7 +168,7 @@ fn value_end(bytes: &[u8], start: usize) -> Option { } } -fn percent_decode(path: &str) -> String { +pub(super) fn percent_decode(path: &str) -> String { let bytes = path.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; @@ -190,7 +190,7 @@ fn percent_decode(path: &str) -> String { /// `/public` first, then any other `public` directory the project's /// packages have, as the authoritative scan finds them. -fn public_dirs_of(root: &Path) -> Vec { +pub(super) fn public_dirs_of(root: &Path) -> Vec { let mut dirs = Vec::new(); let top = root.join("public"); if top.is_dir() { diff --git a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs index 010bc870..7a574e0d 100644 --- a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs +++ b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs @@ -1,5 +1,3 @@ - - /// Cuts the ranked candidate list to `limit` and reports the two truncations /// apart. /// @@ -64,51 +62,51 @@ fn apply_explore_limit(mut response: Value, limit: usize) -> Value { response } -#[path = "../handlers/devup_figma_measure.rs"] -mod devup_figma_measure_handler; -#[path = "../handlers/devup_skills.rs"] -mod devup_skills_handler; +#[path = "../handlers/devup_feature_trace.rs"] +mod devup_feature_trace_handler; #[path = "../handlers/devup_figma_auth.rs"] mod devup_figma_auth_handler; -#[path = "../handlers/devup_figma_search.rs"] -mod devup_figma_search_handler; #[path = "../handlers/devup_figma_explore.rs"] mod devup_figma_explore_handler; #[path = "../handlers/devup_figma_export.rs"] mod devup_figma_export_handler; +#[path = "../handlers/devup_figma_measure.rs"] +mod devup_figma_measure_handler; +#[path = "../handlers/devup_figma_search.rs"] +mod devup_figma_search_handler; #[path = "../handlers/devup_project_context.rs"] mod devup_project_context_handler; -#[path = "../handlers/devup_ui_validate.rs"] -mod devup_ui_validate_handler; -#[path = "../handlers/devup_stack_diff.rs"] -mod devup_stack_diff_handler; -#[path = "../handlers/devup_visual_compare.rs"] -mod devup_visual_compare_handler; -#[path = "../handlers/devup_feature_trace.rs"] -mod devup_feature_trace_handler; -#[path = "../handlers/devup_runtime_audit.rs"] -mod devup_runtime_audit_handler; #[path = "../handlers/devup_project_plan.rs"] mod devup_project_plan_handler; +#[path = "../handlers/devup_runtime_audit.rs"] +mod devup_runtime_audit_handler; #[path = "../handlers/devup_screen_plan.rs"] mod devup_screen_plan_handler; +#[path = "../handlers/devup_skills.rs"] +mod devup_skills_handler; +#[path = "../handlers/devup_stack_diff.rs"] +mod devup_stack_diff_handler; +#[path = "../handlers/devup_ui_validate.rs"] +mod devup_ui_validate_handler; +#[path = "../handlers/devup_visual_compare.rs"] +mod devup_visual_compare_handler; impl DevupServer { fn tool_router() -> ToolRouter { - Self::devup_figma_measure_router() + - Self::devup_skills_router() + - Self::devup_figma_auth_router() + - Self::devup_figma_search_router() + - Self::devup_figma_explore_router() + - Self::devup_figma_export_router() + - Self::devup_project_context_router() + - Self::devup_ui_validate_router() + - Self::devup_stack_diff_router() + - Self::devup_visual_compare_router() + - Self::devup_feature_trace_router() + - Self::devup_runtime_audit_router() + - Self::devup_project_plan_router() + - Self::devup_screen_plan_router() + Self::devup_figma_measure_router() + + Self::devup_skills_router() + + Self::devup_figma_auth_router() + + Self::devup_figma_search_router() + + Self::devup_figma_explore_router() + + Self::devup_figma_export_router() + + Self::devup_project_context_router() + + Self::devup_ui_validate_router() + + Self::devup_stack_diff_router() + + Self::devup_visual_compare_router() + + Self::devup_feature_trace_router() + + Self::devup_runtime_audit_router() + + Self::devup_project_plan_router() + + Self::devup_screen_plan_router() } } @@ -149,6 +147,10 @@ fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { .collect::>(); if input.project_root.is_some() { rules.push(mask_guard::RULE); + rules.push(asset_guard::RULE); + } + if input.section_coverage.is_some() { + rules.push(asset_guard::COVERAGE_RULE); } if input.files.is_some() { rules.extend([ @@ -162,6 +164,10 @@ fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { result["verificationScope"] = json!({ "static": { "rules": rules, + "sectionCoverage": { + "state": if input.section_coverage.is_some() { "checked" } else { "unverified" }, + "scope": "Supplied JSX identities and asset references; runtime use and visibility remain unverified." + }, "unknownTokenCheckRan": result["tokens"]["unknownTokenCheckRan"], "layout": layout["checked"], "referenceWidth": input.reference_width, @@ -958,4 +964,4 @@ mod r8_recovery_tests { } } } -} \ No newline at end of file +} diff --git a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs index 47a466f4..4675934a 100644 --- a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs +++ b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs @@ -1,5 +1,7 @@ #[path = "../artifacts.rs"] pub mod artifacts; +#[path = "../asset_guard.rs"] +mod asset_guard; #[path = "../asset_jobs.rs"] mod asset_jobs; #[path = "../audit_plan.rs"] @@ -481,4 +483,4 @@ pub enum Bridge { /// `DEVUP_FIGMA_BRIDGE_PORT` decides, as in a serving process. FromEnvironment, Off, -} \ No newline at end of file +} diff --git a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs index 91d75764..f1c83719 100644 --- a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs +++ b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs @@ -160,6 +160,7 @@ pub(super) async fn finalize_export_delivery( } let mut value = Value::Object(std::mem::take(&mut result)); rewrite_result_asset_references(&mut value, &replacements); + super::super::asset_guard::refresh_export_asset_urls(&mut value); audit_delivered_properties(&mut value); super::super::interactions::attach(&mut value, &payload.snapshot); attach_diagnostic_screens(&mut value, &owners); diff --git a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs index 19f0f655..136c20be 100644 --- a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs +++ b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs @@ -102,8 +102,25 @@ pub(super) fn project_node_and_theme_outputs( } .with_payload_tokens(payload), )?; + if let Some(root) = theme_options.project_root.as_deref() + && super::super::screen_plan::node_ids_enabled(root) + { + let annotated = annotate_node_ids(&mut output, &payload.snapshot, node_id); + result.insert("figmaNodeIds".to_owned(), annotated); + } + let coverage = super::super::asset_guard::export_coverage( + &payload.snapshot, + node_id, + &mut output, + true, + theme_options + .project_root + .as_deref() + .is_some_and(super::super::screen_plan::node_ids_enabled), + ); scope_output(&mut output, "tsx"); result.entry("outputResults").or_insert_with(|| json!({}))["tsx"] = output_result(&output); + result["outputResults"]["tsx"]["sectionCoverage"] = coverage; result.insert( "widthBehavior".to_owned(), devup_mcp_devup_ui::codegen::width::width_behavior(&payload.snapshot, node_id, &output), @@ -111,12 +128,6 @@ pub(super) fn project_node_and_theme_outputs( projection_diagnostics.extend(output.diagnostics.iter().cloned()); fidelity_reports.push(output.fidelity_report.clone()); generated_output_target = "tsx"; - if let Some(root) = theme_options.project_root.as_deref() - && super::super::screen_plan::node_ids_enabled(root) - { - let annotated = annotate_node_ids(&mut output, &payload.snapshot, node_id); - result.insert("figmaNodeIds".to_owned(), annotated); - } tsx_source_map = Some(output.source_map.clone()); if keep_tsx && output_paths.contains_key("tsx") { pending_text_outputs.insert("tsx".to_owned(), output.tsx.clone()); @@ -161,9 +172,20 @@ pub(super) fn project_node_and_theme_outputs( ); match output { Ok(mut output) => { + let coverage = super::super::asset_guard::export_coverage( + &payload.snapshot, + node_id, + &mut output, + false, + theme_options + .project_root + .as_deref() + .is_some_and(super::super::screen_plan::node_ids_enabled), + ); scope_output(&mut output, "componentTsx"); result.entry("outputResults").or_insert_with(|| json!({}))["componentTsx"] = output_result(&output); + result["outputResults"]["componentTsx"]["sectionCoverage"] = coverage; projection_diagnostics.extend(output.diagnostics.iter().cloned()); fidelity_reports.push(output.fidelity_report.clone()); if tsx_source_map.is_none() { diff --git a/crates/devup-mcp/src/server/projection/project_operation.rs b/crates/devup-mcp/src/server/projection/project_operation.rs index e07cfbc8..336a468d 100644 --- a/crates/devup-mcp/src/server/projection/project_operation.rs +++ b/crates/devup-mcp/src/server/projection/project_operation.rs @@ -517,6 +517,10 @@ async fn project_operation( section_tsx_projected, ) = project_section_frames::project_section_frames( project_section_frames::SectionFrames { + annotate_node_ids: theme_options + .project_root + .as_deref() + .is_some_and(super::screen_plan::node_ids_enabled), failures, design_fingerprints: &design_fingerprints, payload, diff --git a/crates/devup-mcp/src/server/projection/project_section_frames.rs b/crates/devup-mcp/src/server/projection/project_section_frames.rs index f13be26b..3054da40 100644 --- a/crates/devup-mcp/src/server/projection/project_section_frames.rs +++ b/crates/devup-mcp/src/server/projection/project_section_frames.rs @@ -1,6 +1,7 @@ use super::*; pub(super) struct SectionFrames<'a> { + pub(super) annotate_node_ids: bool, pub(super) design_fingerprints: &'a Option>, pub(super) payload: &'a CollectedPayload, pub(super) artifact: &'a ArtifactLookup, @@ -42,6 +43,7 @@ type SectionFrameProjection = Result< pub(super) fn project_section_frames(input: SectionFrames<'_>) -> SectionFrameProjection { let SectionFrames { + annotate_node_ids, design_fingerprints, payload, artifact, @@ -197,8 +199,16 @@ pub(super) fn project_section_frames(input: SectionFrames<'_>) -> SectionFramePr continue; } }; + let coverage = super::super::asset_guard::export_coverage( + &payload.snapshot, + &candidate.node.node_id, + &mut output, + field == "tsx", + annotate_node_ids, + ); scope_output(&mut output, field); frame["outputResults"][field] = output_result(&output); + frame["outputResults"][field]["sectionCoverage"] = coverage; if frame.get("widthBehavior").is_none() { frame["widthBehavior"] = devup_mcp_devup_ui::codegen::width::width_behavior( &payload.snapshot, diff --git a/crates/devup-mcp/src/server/text/devup-figma-export.md b/crates/devup-mcp/src/server/text/devup-figma-export.md index d82f87eb..528709c4 100644 --- a/crates/devup-mcp/src/server/text/devup-figma-export.md +++ b/crates/devup-mcp/src/server/text/devup-figma-export.md @@ -1 +1 @@ -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. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. 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. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). 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. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the annotated output. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} \ No newline at end of file +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. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. 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. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). 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. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the annotated output. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} Exports report captured sections/assets with no generated element as DEVUP_CODEGEN_SECTION_COVERAGE projection issues, including without includeDiagnostics. With projectRoot and Figma node identities enabled, each TSX outputResults entry also returns sectionCoverage: pass this original contract to devup_ui_validate to detect deleted sections and asset references. Hidden subtrees, flattened asset internals and component-reference internals are excluded. sectionCoverage is null when identity attributes are disabled or no projectRoot is supplied; static coverage never proves runtime visibility. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-ui-validate.md b/crates/devup-mcp/src/server/text/devup-ui-validate.md index 27715675..d777e4d2 100644 --- a/crates/devup-mcp/src/server/text/devup-ui-validate.md +++ b/crates/devup-mcp/src/server/text/devup-ui-validate.md @@ -1 +1 @@ -Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. With projectRoot, a maskImage whose public SVG paints more than one colour warns `multicolor-mask` (use ). `themeNotes` summarizes each encountered empty token category once. Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), one with no focus style is a warning (focus-style), too much motion - over 400ms, scale above 1.05, infinite - is a warning (too-much-motion), an icon-only button without aria-label is a warning (icon-button-label); a button with no _active, a _hover with no transition, motion with no _motionReduce and a nav link with no aria-current are info (button-active, hover-transition, motion-reduce, nav-current); a page or layout with no metadata export is a warning (page-metadata, files mode), and with projectRoot an app with no icon or favicon is a warning (app-icon). Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio. \ No newline at end of file +Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. With projectRoot, a maskImage whose public SVG paints more than one colour warns `multicolor-mask` (use ), and a root-relative asset URL (src or url(...)) with no file under any public directory is an error (`asset-missing`), always failing `ok`. `themeNotes` summarizes each encountered empty token category once. Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), one with no focus style is a warning (focus-style), too much motion - over 400ms, scale above 1.05, infinite - is a warning (too-much-motion), an icon-only button without aria-label is a warning (icon-button-label); a button with no _active, a _hover with no transition, motion with no _motionReduce and a nav link with no aria-current are info (button-active, hover-transition, motion-reduce, nav-current); a page or layout with no metadata export is a warning (page-metadata, files mode), and with projectRoot an app with no icon or favicon is a warning (app-icon). Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio. Supply sectionCoverage from outputResults.tsx.sectionCoverage (or frames[].outputResults.tsx.sectionCoverage) to report omitted sections, images, masks and backgrounds as section-coverage errors. This compares literal JSX node identity and asset references across the supplied sources; it does not prove runtime visibility or component use. Omitted sectionCoverage leaves design coverage unverified. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index 6b94cb80..73bac285 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -351,6 +351,11 @@ pub struct ProjectContextInput { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct UiValidateInput { + /// Copy outputResults.tsx.sectionCoverage (or a Section frame's equivalent) + /// from devup_figma_export to check that captured sections/assets remain. + /// This is static JSX identity coverage, not proof of runtime visibility. + #[serde(default)] + pub section_coverage: Option, /// Actual acquisition policies keyed by assetId, including public assetPath /// and effectsBaked. Missing baking evidence remains unverified. #[serde(default)] @@ -405,6 +410,15 @@ pub struct UiValidateInput { pub reference_width: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SectionCoverageInput { + pub node_ids: Vec, + /// Asset references in the original generated output, including masks and backgrounds. + #[serde(default)] + pub asset_urls: Vec, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct SourceMapInput { diff --git a/crates/devup-mcp/src/server/visual_compare.rs b/crates/devup-mcp/src/server/visual_compare.rs index f07fa072..efcc9b85 100644 --- a/crates/devup-mcp/src/server/visual_compare.rs +++ b/crates/devup-mcp/src/server/visual_compare.rs @@ -837,7 +837,15 @@ mod tests { let f = fixture(); let outside = fixture(); #[cfg(windows)] - std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")).unwrap(); + if let Err(error) = std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")) { + // Non-admin Windows sessions without Developer Mode cannot + // create symlinks (ERROR_PRIVILEGE_NOT_HELD). + if error.raw_os_error() == Some(1314) { + eprintln!("symlink test unavailable: {error}"); + return; + } + panic!("cannot create test symlink: {error}"); + } #[cfg(unix)] std::os::unix::fs::symlink(&outside.0, f.0.join("escape")).unwrap(); assert!( diff --git a/crates/devup-mcp/tests/export_project_theme.rs b/crates/devup-mcp/tests/export_project_theme.rs index cafc50a3..2529a603 100644 --- a/crates/devup-mcp/tests/export_project_theme.rs +++ b/crates/devup-mcp/tests/export_project_theme.rs @@ -246,6 +246,10 @@ async fn an_export_into_a_project_marks_its_elements_with_the_figma_node() -> an "{}", result["figmaNodeIds"] ); + assert_eq!( + result["outputResults"]["tsx"]["sectionCoverage"]["nodeIds"], + json!(["3879:35481"]) + ); // What the export wrote for the node, kept to tell code drift from a // converter bug later. let record: Value = @@ -278,6 +282,7 @@ async fn an_export_into_a_project_marks_its_elements_with_the_figma_node() -> an .contains("data-figma-node-id") ); assert!(result.get("figmaNodeIds").is_none()); + assert!(result["outputResults"]["tsx"]["sectionCoverage"].is_null()); let result = export(json!({"url": URL, "outputs": ["tsx"]})).await?; assert!( diff --git a/crates/devup-mcp/tests/output_policy.rs b/crates/devup-mcp/tests/output_policy.rs index 346bcd22..00780c25 100644 --- a/crates/devup-mcp/tests/output_policy.rs +++ b/crates/devup-mcp/tests/output_policy.rs @@ -245,7 +245,9 @@ fn rejects_a_directory_symlink_parent_that_escapes_the_root() -> anyhow::Result< let root = unique_temp_dir("symlink-root")?; let outside = unique_temp_dir("symlink-outside")?; if let Err(error) = symlink_dir(&outside, root.join("linked")) { - if error.kind() == std::io::ErrorKind::PermissionDenied { + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(1314) + { fs::remove_dir_all(root)?; fs::remove_dir_all(outside)?; return Ok(()); diff --git a/crates/devup-mcp/tests/ui_validate_response.rs b/crates/devup-mcp/tests/ui_validate_response.rs index da1e1dde..cc11f7b8 100644 --- a/crates/devup-mcp/tests/ui_validate_response.rs +++ b/crates/devup-mcp/tests/ui_validate_response.rs @@ -11,3 +11,5 @@ // Source sections share this module to preserve privacy and test names. include!("ui_validate_response/fixtures_and_cases.rs"); include!("ui_validate_response/a_page_named_module_outside_the_router_is_not_judged_as_a_route.rs"); + +include!("ui_validate_response/section_coverage.rs"); diff --git a/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs b/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs new file mode 100644 index 00000000..a6d799d1 --- /dev/null +++ b/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs @@ -0,0 +1,42 @@ +#[tokio::test] +async fn missing_sections_and_asset_files_fail_even_without_strict() -> anyhow::Result<()> { + let result = validate(json!({ + "tsx": "", + "projectRoot": fixture_project_root(), + "sectionCoverage": {"nodeIds":["section","picture"]} + })) + .await?; + assert_eq!(result["ok"], false); + assert_eq!(result["okReason"], "error-violations"); + let violations = result["violations"].as_array().unwrap(); + for rule in ["asset-missing", "section-coverage"] { + assert!( + violations + .iter() + .any(|v| v["rule"] == rule && v["severity"] == "error"), + "{result}" + ); + assert!( + result["verificationScope"]["static"]["rules"] + .as_array() + .unwrap() + .contains(&json!(rule)) + ); + } + let result = validate(json!({ + "files":[ + {"path":"Section.tsx","content":"export function Section() { return ; }"}, + {"path":"Picture.tsx","content":"export function Picture() { return ; }"} + ], + "sectionCoverage":{"nodeIds":["section","picture"]} + })).await?; + assert!( + !result["violations"] + .as_array() + .unwrap() + .iter() + .any(|v| v["rule"] == "section-coverage"), + "{result}" + ); + Ok(()) +} From d9cb9dec18a5c291c700844d9d055154ef5afab5 Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 17:40:59 +0900 Subject: [PATCH 05/11] Publish generated Figma scripts atomically during concurrent validation --- plugin/scripts/gen-scripts.mjs | 34 ++++++++++++++++++++++++++--- plugin/scripts/gen-scripts.test.mjs | 32 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 plugin/scripts/gen-scripts.test.mjs diff --git a/plugin/scripts/gen-scripts.mjs b/plugin/scripts/gen-scripts.mjs index 24a56db0..d6049649 100644 --- a/plugin/scripts/gen-scripts.mjs +++ b/plugin/scripts/gen-scripts.mjs @@ -9,7 +9,9 @@ // 그래서 감싸기만 하면 그대로 동작한다. eval 은 쓰지 않는다 — Figma 플러그인 // 샌드박스에서 동적 코드 실행은 막혀 있고, 번들러가 정적으로 볼 수 있어야 한다. -import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -145,6 +147,32 @@ export declare const SCRIPTS: Record< ` mkdirSync(dirname(outFile), { recursive: true }) -writeFileSync(`${outFile}.js`, js, 'utf8') -writeFileSync(`${outFile}.d.ts`, dts, 'utf8') +// Several cargo/Node tests generate these wrappers concurrently. Readers +// must see a complete module, never the empty file between truncate/write. +async function publish(path, content) { + try { + if (readFileSync(path, 'utf8') === content) return + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp` + try { + writeFileSync(temporary, content, { encoding: 'utf8', flag: 'wx' }) + for (let attempt = 0; ; attempt += 1) { + try { + renameSync(temporary, path) + break + } catch (error) { + // Windows readers and concurrent replacements can briefly hold the + // destination open. Retry the rename without truncating that file. + if (attempt >= 40 || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error + await delay(25) + } + } + } finally { + rmSync(temporary, { force: true }) + } +} +await publish(`${outFile}.js`, js) +await publish(`${outFile}.d.ts`, dts) console.log(`generated ${files.length} scripts -> ${outFile}.{js,d.ts}`) diff --git a/plugin/scripts/gen-scripts.test.mjs b/plugin/scripts/gen-scripts.test.mjs new file mode 100644 index 00000000..4c113ef2 --- /dev/null +++ b/plugin/scripts/gen-scripts.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { appendFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const generator = fileURLToPath(new URL('./gen-scripts.mjs', import.meta.url)); +const moduleUrl = new URL('../src/generated/scripts.js', import.meta.url); +const generate = async () => { + const child = spawn(process.execPath, [generator], { stdio: ['ignore', 'ignore', 'pipe'] }); + let error = ''; + child.stderr.on('data', (data) => { error += data; }); + const [code] = await once(child, 'exit'); + assert.equal(code, 0, error); +}; + +test('concurrent generation always publishes a complete importable module', async () => { + await generate(); + appendFileSync(moduleUrl, '\n// Force concurrent republishing of an otherwise complete module.\n'); + const outcomes = await Promise.allSettled([ + ...Array.from({ length: 6 }, generate), + (async () => { + for (let index = 0; index < 40; index += 1) { + const { SCRIPTS } = await import(`${moduleUrl.href}?read=${index}`); + assert.equal(typeof SCRIPTS?.assets, 'function'); + await new Promise((resolve) => setTimeout(resolve, 2)); + } + })(), + ]); + for (const result of outcomes) if (result.status === 'rejected') throw result.reason; +}); From 1ce02840ffc596c639617d15c7454a5447a06e0e Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 17:40:59 +0900 Subject: [PATCH 06/11] Block unfinished visual comparisons and complete runtime audit reporting --- crates/devup-mcp-devup-ui/src/ui_layout.rs | 9 ++++- .../server/audit_plan/planning_and_capture.rs | 22 ------------- .../src/server/audit_plan/static_params.rs | 33 +++++++++++++++++-- .../devup-mcp/src/server/audit_plan_tests.rs | 16 +++++++-- .../src/server/runtime_audit/tests.rs | 5 +++ .../src/server/screen_plan/actions.rs | 2 +- .../src/server/screen_plan/status.rs | 14 ++++++-- ...is_vinext_with_view_transitions_enabled.rs | 2 +- .../screen_plan_tests/fixtures_and_cases.rs | 10 ++++++ .../src/server/skills/devup/SKILL.md | 8 ++--- .../devup-mcp/src/server/skills/manifest.json | 4 +-- .../src/server/text/guide-GUIDE-c682b0c5.md | 4 +-- crates/devup-mcp/tests/output_policy.rs | 4 ++- crates/devup-mcp/tests/screen_plan_tool.rs | 2 +- harness/runtime-audit/FAILURES-AUDIT.txt | 30 +++++++++++++++++ harness/runtime-audit/run.mjs | 4 +-- harness/runtime-audit/test/layout.test.mjs | 32 +++++++++++++++++- 17 files changed, 157 insertions(+), 44 deletions(-) create mode 100644 harness/runtime-audit/FAILURES-AUDIT.txt diff --git a/crates/devup-mcp-devup-ui/src/ui_layout.rs b/crates/devup-mcp-devup-ui/src/ui_layout.rs index 8b927b8e..612053e7 100644 --- a/crates/devup-mcp-devup-ui/src/ui_layout.rs +++ b/crates/devup-mcp-devup-ui/src/ui_layout.rs @@ -258,7 +258,14 @@ impl Collector<'_> { .iter() .filter(|child| pixels(child.attrs.get("w")).is_some()) .count(); - if row && !wraps && fixed_children >= 2 { + let wide_only = std::iter::once(parent).chain(children).any(|element| { + element.attrs.values().any(|value| { + (value.trim_start().starts_with("[null") + || value.trim_start().starts_with("[undefined")) + && value.contains("px") + }) + }); + if row && !wraps && (fixed_children >= 2 || wide_only) { self.risks.push(json!({"rule":"narrow-fixed-columns", "confidence":"medium", "elementIndex":parent.ordinal, "message":"A multi-column row keeps fixed child widths without a narrow column or wrap rule. Supply narrow responsive layout props and verify section-layout, overflow and clipped text at 390/858px."})); } diff --git a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs index 9abbbb68..5f261729 100644 --- a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs +++ b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs @@ -982,25 +982,3 @@ fn local_imports(source: &str) -> Vec<(String, String)> { } found } - -fn read_module(base: &Path, module: &str) -> Option { - for suffix in [ - "", - ".ts", - ".tsx", - ".js", - ".mjs", - ".json", - "/index.ts", - "/index.js", - ] { - let path = base.join(format!("{module}{suffix}")); - if path.is_file() - && std::fs::metadata(&path).is_ok_and(|meta| meta.len() <= 512 * 1024) - && let Ok(text) = std::fs::read_to_string(&path) - { - return Some(text); - } - } - None -} diff --git a/crates/devup-mcp/src/server/audit_plan/static_params.rs b/crates/devup-mcp/src/server/audit_plan/static_params.rs index 9987fa42..446bf8f7 100644 --- a/crates/devup-mcp/src/server/audit_plan/static_params.rs +++ b/crates/devup-mcp/src/server/audit_plan/static_params.rs @@ -472,6 +472,12 @@ pub(super) fn record_round( record["status"] = json!("resolved"); } } + let mut visual_pending = plan["audit"]["visualPending"].as_array().cloned().unwrap_or_default(); + visual_pending.retain(|comparison| !comparison["routePath"].as_str().zip(comparison["viewport"]["width"].as_u64()) + .is_some_and(|(route, width)| audited.contains(&(route.to_owned(), width)))); + visual_pending.extend(comparisons.iter().filter(|comparison| !visual.iter().any(|result| + comparison["id"] == result.id && matches!(result.verdict.as_str(), "pass" | "fail"))).cloned()); + plan["audit"]["visualPending"] = json!(visual_pending); json!({"pendingComparisons": pending, "inconclusiveComparisons": inconclusive}) } @@ -600,7 +606,7 @@ pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> Stri other => other.to_string(), }; let mut out = String::from( - "| URL | width | result | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|---|\n", + "| URL | width | result | completeness | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|\n", ); let records = plan["audit"]["findings"].as_object(); for row in rows { @@ -630,9 +636,10 @@ pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> Stri .count() }; out.push_str(&format!( - "| {url} | {} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", + "| {url} | {} | {} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", width["width"], cell(&width["result"]), + cell(&columns["completeness"]), cell(&columns["health"]), cell(&columns["geometry"]), count("code-drift"), @@ -831,3 +838,25 @@ fn text_alignment_check(payload: &CollectedPayload, child: &str, width: u32) -> Some(check(json!({"kind":"text-align", "label":format!("{} text alignment", name_of(payload, child)), "subject":node_at(width, child), "nodeId":child, "edge":edge, "widths":[width]}))) } + +fn read_module(base: &Path, module: &str) -> Option { + for suffix in [ + "", + ".ts", + ".tsx", + ".js", + ".mjs", + ".json", + "/index.ts", + "/index.js", + ] { + let path = base.join(format!("{module}{suffix}")); + if path.is_file() + && std::fs::metadata(&path).is_ok_and(|meta| meta.len() <= 512 * 1024) + && let Ok(text) = std::fs::read_to_string(&path) + { + return Some(text); + } + } + None +} diff --git a/crates/devup-mcp/src/server/audit_plan_tests.rs b/crates/devup-mcp/src/server/audit_plan_tests.rs index 4e3cd8b7..caedca6e 100644 --- a/crates/devup-mcp/src/server/audit_plan_tests.rs +++ b/crates/devup-mcp/src/server/audit_plan_tests.rs @@ -778,8 +778,8 @@ fn a_mismatch_with_the_code_still_equal_to_the_export_is_a_converter_bug_with_a_ ]; let queue = work_queue(&plan, &rows, &[]); assert_eq!( - queue[0]["done"], true, - "a filed converter bug does not hold the URL: {queue:?}" + queue[0]["done"], false, + "a filed converter bug remains reported, but a failing cell cannot be called done: {queue:?}" ); assert_eq!(queue[0]["reported"][0]["converterBug"], "10:132"); let report = final_report(&plan, &rows, ""); @@ -833,3 +833,15 @@ fn a_mismatch_without_an_export_record_keeps_its_class_and_says_why() { .contains("No export record") ); } + +#[test] +fn an_asset_capture_waiting_for_comparison_blocks_completion() { + let mut plan = json!({"audit":{}}); + let audit = json!({"screenTable":[{"routePath":"/", "width":1440}], "visualComparisons":[ + {"id":"home|asset|icon|1440", "routePath":"/", "viewport":{"width":1440}, "blockers":[]} + ]}); + record_round(&mut plan, &audit, "run", &[], &BTreeMap::new(), 3); + assert_eq!(plan["audit"]["visualPending"].as_array().unwrap().len(), 1); + record_round(&mut plan, &audit, "run", &[VisualResultInput { id:"home|asset|icon|1440".into(), verdict:"pass".into(), changed_ratio:Some(0.0), diff_path:None, reason:None }], &BTreeMap::new(), 3); + assert!(plan["audit"]["visualPending"].as_array().unwrap().is_empty()); +} diff --git a/crates/devup-mcp/src/server/runtime_audit/tests.rs b/crates/devup-mcp/src/server/runtime_audit/tests.rs index 135750c9..287805a8 100644 --- a/crates/devup-mcp/src/server/runtime_audit/tests.rs +++ b/crates/devup-mcp/src/server/runtime_audit/tests.rs @@ -381,6 +381,11 @@ mod tests { }; let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); assert!(ok(json!({"kind": "page-health"})).is_ok()); + for kind in ["sequence-order", "section-layout", "no-clipped-content"] { + assert!(ok(json!({"kind": kind})).is_ok()); + } + assert!(ok(json!({"kind":"text-align", "subject":node("1:2"), "edge":"center"})).is_ok()); + assert!(ok(json!({"kind":"text-align", "subject":node("1:2"), "edge":"bad"})).is_err()); for kind in ["no-low-contrast-text", "image-loaded"] { assert!(ok(json!({"kind": kind})).is_ok()); assert!(ok(json!({"kind": kind, "subject": node("1:2")})).is_ok()); diff --git a/crates/devup-mcp/src/server/screen_plan/actions.rs b/crates/devup-mcp/src/server/screen_plan/actions.rs index 5a65548a..965306a0 100644 --- a/crates/devup-mcp/src/server/screen_plan/actions.rs +++ b/crates/devup-mcp/src/server/screen_plan/actions.rs @@ -524,7 +524,7 @@ pub(super) async fn audit_plan( for written in array(&result["plans"]) { entries.retain(|entry| entry["url"] != written["url"]); entries.push(json!({"url": written["url"], "screen": written["screen"], - "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); + "missingReferences": written["missingReferences"], "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); } // A dynamic route sampled from its Figma frame is an assumption, and is // reported as one. diff --git a/crates/devup-mcp/src/server/screen_plan/status.rs b/crates/devup-mcp/src/server/screen_plan/status.rs index 2b814e92..e4583f3f 100644 --- a/crates/devup-mcp/src/server/screen_plan/status.rs +++ b/crates/devup-mcp/src/server/screen_plan/status.rs @@ -76,14 +76,24 @@ pub(super) fn status_report( return json!({"width": width, "result": "not-run", "reasons": [if audited { "no runtime audit item for this URL at this width" } else { "no runtime audit report supplied" }]}); } - let result = matching + let mut result = matching .iter() .fold("pass", |acc, row| worst(acc, text(&row["status"]))); + let mut reasons = matching.iter().flat_map(|row| array(&row["reasons"]).to_vec()).collect::>(); + let missing_reference = array(&plan["audit"]["plans"]).iter().filter(|entry| entry["url"] == url) + .flat_map(|entry| array(&entry["missingReferences"])) + .any(|reference| reference["width"].is_null() || reference["width"] == *width); + let pending_visual = array(&plan["audit"]["visualPending"]).iter() + .any(|comparison| comparison["routePath"] == url && comparison["viewport"]["width"] == *width); + if missing_reference || pending_visual { + if result == "pass" { result = "not-run"; } + reasons.push(json!("Figma page, section or asset visual comparison missing, blocked or not yet judged")); + } json!({ "width": width, "result": result, "columns": matching[0]["columns"], - "reasons": matching.iter().flat_map(|row| array(&row["reasons"]).to_vec()).collect::>(), + "reasons": reasons, "screenshots": matching.iter().filter_map(|row| row["screenshot"].as_str()).collect::>(), }) }) diff --git a/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs b/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs index f98b4f10..70f7785b 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs @@ -570,7 +570,7 @@ async fn a_pending_decision_is_taken_at_the_end_and_a_destructive_one_is_left_un ); assert!( text_report - .contains("| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction |") + .contains("| URL | width | result | completeness | health | geometry | drift / converter bugs | page | section | asset | interaction |") ); assert!(text_report.contains("Dependencies: the update step has not been recorded.")); } diff --git a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs index 264e1f6d..8c5d9cf9 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs @@ -916,3 +916,13 @@ fn site_structure_and_url_questions_are_all_in_the_initial_rounds() { assert!(ids.iter().any(|id| id.starts_with("split-route:"))); assert_rounds_cover(&plan); } + +#[test] +fn visual_comparisons_and_missing_asset_references_block_a_passing_cell() { + let mut plan = confirmed_plan(); + plan["audit"] = json!({"visualPending":[{"routePath":"/", "viewport":{"width":1440}}]}); + let audit = json!({"widthTable":[{"width":1440}],"screenTable":[{"routePath":"/", "width":1440,"status":"pass","columns":{"completeness":"pass"}}]}); + assert_eq!(status_report(&plan, &evidence(), Some(&audit))["rows"][0]["widths"][0]["result"], "not-run"); + plan["audit"] = json!({"plans":[{"url":"/", "missingReferences":[{"width":1440,"kind":"nativeRender"}]}]}); + assert_eq!(status_report(&plan, &evidence(), Some(&audit))["rows"][0]["widths"][0]["result"], "not-run"); +} diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index 0b8637ce..a363690f 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -180,8 +180,8 @@ the full rules; these are the steps. recommended option, keep working, take it with `assume` at the end; a destructive one stays unbuilt. 12. **Done = zero fails.** Every check and comparison passes at the drawn - widths and health and overflow pass at every width, or each remaining - finding is a filed converter bug or unresolved with evidence. Also: every + widths and health and overflow pass at every required width. Report filed + converter bugs and unresolved findings with evidence as incomplete. Also: every URL implemented, the production build free of `data-figma-node-id` (the strip plugin in vite.config; `grep -rl data-figma-node-id dist` finds nothing), `bun run build`, `bun run lint`, `bunx oxlint --deny-warnings`, `bun tsc --noEmit` @@ -193,8 +193,8 @@ the full rules; these are the steps. assumptions, design fixes, exclusions, defaults and the dependencies line. Hard rules: visually verify every screen, section and asset at every drawn -width; never stop to wait, reorder the work; done = zero fails, or listed as -unresolved with evidence; layout is exact, a mismatch is drift or a +width; never stop to wait, reorder the work; done = every required cell passes; +report unresolved cells with evidence as incomplete; layout is exact, a mismatch is drift or a converter bug. Run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read its report, fix and rerun until every cell passes. Missing cells are not-run and block done; require rendered completeness and page/section/asset comparisons at drawn widths. Ask all site, structure and URL questionRounds back to back up front before implementation; never ask a later round during the build. diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 512359dc..0d525f81 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -89,8 +89,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 13426, - "sha256": "e3311f246745b4841d5e4b069d2c67513ab5576b1a783c0b88b020a55b622fb2" + "bytes": 13682, + "sha256": "8e9f69df9be904af473844f4db606f2b2f35fe0318da07f142d08a9163a9e783" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", diff --git a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md index 19d1fef4..a40d29f8 100644 --- a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md +++ b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md @@ -302,8 +302,8 @@ routes at concrete URLs, and production builds strip the ids unless overflow, layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its node, file and fix hint. Never stop to wait; reorder the work: a decision after confirm goes to -`defer` and is taken with `assume` at the end. Done = zero fails, or listed as -unresolved with evidence after `maxFixAttempts`. +`defer` and is taken with `assume` at the end. Done = every required URL x width cell passes. Report unresolved findings +with evidence after `maxFixAttempts` as incomplete. Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. Ask all site, structure and URL questions from the draft questions/questionRounds together up front, back to back before implementing. Never ask another round during the build; record conservative assumptions after confirmation. diff --git a/crates/devup-mcp/tests/output_policy.rs b/crates/devup-mcp/tests/output_policy.rs index 346bcd22..00780c25 100644 --- a/crates/devup-mcp/tests/output_policy.rs +++ b/crates/devup-mcp/tests/output_policy.rs @@ -245,7 +245,9 @@ fn rejects_a_directory_symlink_parent_that_escapes_the_root() -> anyhow::Result< let root = unique_temp_dir("symlink-root")?; let outside = unique_temp_dir("symlink-outside")?; if let Err(error) = symlink_dir(&outside, root.join("linked")) { - if error.kind() == std::io::ErrorKind::PermissionDenied { + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(1314) + { fs::remove_dir_all(root)?; fs::remove_dir_all(outside)?; return Ok(()); diff --git a/crates/devup-mcp/tests/screen_plan_tool.rs b/crates/devup-mcp/tests/screen_plan_tool.rs index 945448ae..67e02c45 100644 --- a/crates/devup-mcp/tests/screen_plan_tool.rs +++ b/crates/devup-mcp/tests/screen_plan_tool.rs @@ -212,7 +212,7 @@ async fn draft_asks_everything_at_once_and_confirm_needs_every_answer() -> anyho assert!(!failed, "{status}"); assert_eq!( status["summary"], - "3 URLs: 2 implemented, 0 verified at all widths; no runtime audit report, so every width is not-run; site completeness: 5 missing" + "3 URLs: 0 implemented, 0 verified at all widths, 2 written but not rendered complete at every drawn width; no runtime audit report, so every width is not-run; site completeness: 5 missing" ); std::fs::remove_dir_all(root).ok(); Ok(()) diff --git a/harness/runtime-audit/FAILURES-AUDIT.txt b/harness/runtime-audit/FAILURES-AUDIT.txt new file mode 100644 index 00000000..03a39e8b --- /dev/null +++ b/harness/runtime-audit/FAILURES-AUDIT.txt @@ -0,0 +1,30 @@ +Runtime failure coverage + +1. Error page / missing URL x width execution + page-health records vinext/Next error text, navigation failures, page and console errors, and a document narrower than the viewport. Screen status keeps default 1440/1100/858/390 widths plus drawn/reported widths even when a report is partial. Missing cells are not-run; written files are written-unverified until drawn-width completeness passes. Done requires every required cell to pass. + +2. Numbered cards in column order + Exports and devup_ui_validate flag column-reading-order risks rather than silently treating nested columns as responsive reading order. sequence-order checks numbered and STEP labels in visual row order at every width and names reversed elements. The converter reports the risk; it does not automatically rewrite every masonry layout. + +3. Narrow unstyled layouts and internal clipping + section-layout checks visible mapped top-level sections at every required width, requires at least half the container width, and rejects long text columns below 120px. Existing exact bounds, overflow and clipped-text checks remain. no-clipped-content catches clipped header navigation, technology lists and portfolio rows even when the document itself fits; carousel exceptions require an explicit audit marker and visible controls or scrollbar. Static warnings flag fixed multi-column rows and wide-only responsive sizing without a narrow layout rule. + +4. Flattened icons and wrong alignment + Generated plans compare each asset with its native Figma render at its drawn width. Pending comparisons and missing native references now block a passing cell. Text alignment is checked against Figma textAlignHorizontal in addition to geometry and section/page comparisons. Inspected commit 06241ab on devfive-relay-c70423/multicolor-icon-mask: its fill/stroke colour analysis covers purple outlines with light lavender fills and keeps them as Image; this fix is absent from main. No branch was merged. + +5. Questions during implementation + Draft already assembles site, structure and URL questions in its initial questions/questionRounds. Added a regression asserting all categories are covered and strengthened tool, usage-guide and /devup instructions to ask those rounds back to back before implementation, then make recorded conservative assumptions instead of asking again. + +Retained and completed the prior rendered-completeness work: frame height, visible sections/assets, decoded images and effective text contrast. Unsupported image/colour backgrounds remain inconclusive rather than passing. + +Validation + cargo fmt --check and cargo clippy --all-targets -- -D warnings pass. + npm test in harness/runtime-audit: 34 passed, including Chromium end-to-end checks at all four widths. + Serial Rust suite with the two known Windows symlink tests filtered: 1646 passed, 2 ignored, 2 filtered. + Final unfiltered cargo test -- --test-threads=1: 1648 passed, 2 ignored, 0 filtered. + Windows symlink tests retain their privilege checks and return early when the host cannot create symlinks (error 1314); those checks remain unavailable on this host. + Generator concurrency regression: 1 passed; concurrent wrapper publication is now atomic and avoids rewriting identical output. + An unrelated asset-job timeout test failed once under contention, passed alone, and passed in the serial full-suite run. A second existing race exposed partially written generated JavaScript modules; atomic publication and a concurrent import/generation regression fix that failure. + +Scope + No apps/prime changes, no push, no PR and no branch merges. Prime itself was not rebuilt or visually re-audited; browser regressions use controlled fixtures. Patch changepacks were added and changes are committed on the requested branch. diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index ddbcfb90..b11030dd 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -346,7 +346,7 @@ export async function observeSectionLayout(page, subject, container) { const parent = locate(page, container); const box = await target.count() === 1 ? await target.boundingBox() : null; const containerBox = await parent.count() === 1 ? await parent.boundingBox() : null; - return { sections: [{ name: JSON.stringify(subject), visible: Boolean(box), width: box?.width ?? 0, height: box?.height ?? 0, containerWidth: containerBox?.width ?? page.viewportSize().width }], texts: [] }; + return { sections: [{ name: JSON.stringify(subject), visible: Boolean(box) && await target.evaluate((element) => element.checkVisibility({ opacityProperty: true, visibilityProperty: true })), width: box?.width ?? 0, height: box?.height ?? 0, containerWidth: containerBox?.width ?? page.viewportSize().width }], texts: [] }; } return page.evaluate(() => { const shown = (element) => element.checkVisibility({ opacityProperty: true, visibilityProperty: true }); @@ -354,7 +354,7 @@ export async function observeSectionLayout(page, subject, container) { const box = element.getBoundingClientRect(); return { name: element.id || element.tagName, visible: shown(element), width: box.width, height: box.height, containerWidth: element.parentElement.getBoundingClientRect().width }; }); - const texts = [...document.querySelectorAll('p')].filter((element) => shown(element) && element.textContent.trim().length > 60).map((element) => ({ name: element.textContent.trim().slice(0, 60), width: element.getBoundingClientRect().width })); + const texts = [...document.querySelectorAll('body *')].filter((element) => shown(element) && [...element.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent.trim()).join(' ').length > 24).map((element) => ({ name: element.textContent.trim().slice(0, 60), width: element.getBoundingClientRect().width })); return { sections, texts }; }); } diff --git a/harness/runtime-audit/test/layout.test.mjs b/harness/runtime-audit/test/layout.test.mjs index 1e912214..52ff23cd 100644 --- a/harness/runtime-audit/test/layout.test.mjs +++ b/harness/runtime-audit/test/layout.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { evaluatePageHealth, evaluateTextAlignment, evaluateSequenceOrder, evaluateSectionLayout } from '../checks.mjs'; -import { observeSequence, observeSectionLayout, observeClippedContent } from '../run.mjs'; +import { observeSequence, observeSectionLayout, observeClippedContent, runPlan } from '../run.mjs'; test('health rejects narrow documents and records vinext error text', () => { const healthy = { errorTexts: [], errors: [], viewportWidth: 858, documentWidth: 858 }; @@ -58,3 +58,33 @@ test('text alignment follows Figma even when its text box stays full width', () assert.equal(evaluateTextAlignment({ edge: 'center' }, { count: 1, visible: true, align: 'left', direction: 'ltr' }).status, 'fail'); assert.equal(evaluateTextAlignment({ edge: 'center' }, { count: 1, visible: true, align: 'center', direction: 'ltr' }).status, 'pass'); }); + +test('runner executes the new checks at every required width', async () => { + const { createServer } = await import('node:http'); + const { mkdtemp, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const server = createServer((request, response) => { + response.setHeader('content-type', 'text/html; charset=utf-8'); + response.end(`
01
03
02

This page couldn't load

Title

Devup UI / Vespera
`); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const dir = await mkdtemp(join(tmpdir(), 'devup-layout-')); + try { + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const path = join(dir, 'plan.json'); + const checks = ['page-health','sequence-order','section-layout','no-clipped-content','no-low-contrast-text','image-loaded'].map((kind) => ({ kind, label: kind })); + checks.push({ kind: 'text-align', label: 'title alignment', subject: { testId: 'title' }, edge: 'center' }); + await writeFile(path, JSON.stringify({ schemaVersion: 1, kind: 'devup-runtime-audit-plan', planId: 'layout', plan: { baseUrl, allowedOrigins: [baseUrl], viewports: [390,858,1100,1440].map((width) => ({ width, height: 900, deviceScaleFactor: 1 })), screens: [{ id: 'home', routePath: '/', checks }] } })); + const report = await runPlan({ plan: path, out: join(dir, 'out'), maxMinutes: 1 }); + for (const run of report.runs) { + for (const check of run.screens[0].checks) assert.equal(check.status, 'fail', `${run.viewport.width}: ${JSON.stringify(check)}`); + const health = run.screens[0].checks.find((check) => check.kind === 'page-health'); + assert.match(health.reason, /couldn't load/); + assert.match(health.reason, /300px wide/); + } + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); From b6e15426e7546af7a33f94e1b1c96334fdfbf3a1 Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 17:41:37 +0900 Subject: [PATCH 07/11] Record final generated-script validation evidence --- harness/runtime-audit/FAILURES-AUDIT.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/harness/runtime-audit/FAILURES-AUDIT.txt b/harness/runtime-audit/FAILURES-AUDIT.txt index 03a39e8b..1b577d21 100644 --- a/harness/runtime-audit/FAILURES-AUDIT.txt +++ b/harness/runtime-audit/FAILURES-AUDIT.txt @@ -28,3 +28,5 @@ Validation Scope No apps/prime changes, no push, no PR and no branch merges. Prime itself was not rebuilt or visually re-audited; browser regressions use controlled fixtures. Patch changepacks were added and changes are committed on the requested branch. + +Generated-script parity also exposed concurrent generator writes. The generator now publishes complete files atomically and a dedicated concurrent-generation regression passes. From 4fc6761d421369481744d8210656c3b8d6ce4896 Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 17:41:38 +0900 Subject: [PATCH 08/11] Record final rendered completeness audit validation --- harness/runtime-audit/FAILURES-AUDIT.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/harness/runtime-audit/FAILURES-AUDIT.txt b/harness/runtime-audit/FAILURES-AUDIT.txt index 1b577d21..9fff9580 100644 --- a/harness/runtime-audit/FAILURES-AUDIT.txt +++ b/harness/runtime-audit/FAILURES-AUDIT.txt @@ -21,12 +21,10 @@ Validation cargo fmt --check and cargo clippy --all-targets -- -D warnings pass. npm test in harness/runtime-audit: 34 passed, including Chromium end-to-end checks at all four widths. Serial Rust suite with the two known Windows symlink tests filtered: 1646 passed, 2 ignored, 2 filtered. - Final unfiltered cargo test -- --test-threads=1: 1648 passed, 2 ignored, 0 filtered. + Final unfiltered cargo test -j 2 --no-fail-fast -- --test-threads=1: 1648 passed, 0 failed, 2 ignored, 0 filtered. Windows symlink tests retain their privilege checks and return early when the host cannot create symlinks (error 1314); those checks remain unavailable on this host. Generator concurrency regression: 1 passed; concurrent wrapper publication is now atomic and avoids rewriting identical output. An unrelated asset-job timeout test failed once under contention, passed alone, and passed in the serial full-suite run. A second existing race exposed partially written generated JavaScript modules; atomic publication and a concurrent import/generation regression fix that failure. Scope No apps/prime changes, no push, no PR and no branch merges. Prime itself was not rebuilt or visually re-audited; browser regressions use controlled fixtures. Patch changepacks were added and changes are committed on the requested branch. - -Generated-script parity also exposed concurrent generator writes. The generator now publishes complete files atomically and a dedicated concurrent-generation regression passes. From 2907a71921b1e63e9a6449a11553da0056ceeddb Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 18:33:36 +0900 Subject: [PATCH 09/11] fix: reset button appearance and preserve hero image sources --- .../changepack_log_u8RE3FZrsXubFLyVO98qr.json | 7 ++ .../src/codegen/style/style_properties.rs | 31 +++++++- .../tests/button_appearance.rs | 70 +++++++++++++++++++ .../devup-mcp-figma/src/asset_render_info.rs | 29 ++++++++ .../src/assets/asset_manifest.rs | 26 ++++++- .../tests/hero_source_selection.rs | 55 +++++++++++++++ .../tests/manifest_covers_readers.rs | 1 + .../server/projection/project_operation.rs | 20 ++++++ .../src/server/projection/regression_tests.rs | 1 + .../projection/w1_regressions_hero_source.rs | 44 ++++++++++++ .../src/server/text/devup-figma-export.md | 3 +- fixtures/devup-figma-plugin/manifest.json | 28 ++++---- .../upstream-codegen-122-7ddad74cc1.snap | 6 +- .../upstream-codegen-124-bdedf14335.snap | 6 +- .../upstream-codegen-126-14073e1999.snap | 6 +- .../upstream-codegen-132-9a7b0243b6.snap | 6 +- .../upstream-codegen-143-52b441f9f4.snap | 2 +- .../upstream-codegen-164-33e4c6591c.snap | 1 + .../upstream-codegen-236-621c6d6a9e.snap | 8 ++- .../upstream-codegen-237-01ee478666.snap | 2 +- .../upstream-codegen-238-1a8cf8c870.snap | 2 +- .../upstream-codegen-239-a6dada3682.snap | 8 ++- .../upstream-codegen-240-77d56a1585.snap | 2 +- .../upstream-codegen-241-e171ef72ce.snap | 8 ++- .../upstream-codegen-242-5c198a8490.snap | 8 ++- .../upstream-codegen-250-935ccdb80f.snap | 1 + harness/runtime-audit/test/browser.test.mjs | 40 +++++++++++ 27 files changed, 382 insertions(+), 39 deletions(-) create mode 100644 .changepacks/changepack_log_u8RE3FZrsXubFLyVO98qr.json create mode 100644 crates/devup-mcp-devup-ui/tests/button_appearance.rs create mode 100644 crates/devup-mcp-figma/tests/hero_source_selection.rs create mode 100644 crates/devup-mcp/src/server/projection/w1_regressions_hero_source.rs diff --git a/.changepacks/changepack_log_u8RE3FZrsXubFLyVO98qr.json b/.changepacks/changepack_log_u8RE3FZrsXubFLyVO98qr.json new file mode 100644 index 00000000..eb254b60 --- /dev/null +++ b/.changepacks/changepack_log_u8RE3FZrsXubFLyVO98qr.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Reset generated button appearance and preserve original hero image fills without baking child text.", + "date": "2026-10-19T09:18:21.640928500Z" +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs index b71d0715..832f8e1f 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs @@ -550,6 +550,29 @@ pub(super) fn push_style_props( tokens: length_tokens, viewport: drawn_viewport(snapshot, node), }; + // Reset native faces even with transparent paint, including Figma button + // components that consumers render polymorphically. + let explicit_button = view.string("as") == Some("button") + || view.node_type().eq_ignore_ascii_case("button"); + let button_name = |name: &str| { + name.split(|c: char| !c.is_alphanumeric()) + .next() + .is_some_and(|word| word.eq_ignore_ascii_case("button")) + }; + let named_button = component != "Text" + && (view.name().is_some_and(button_name) + || snapshot.nodes.values().any(|parent| { + let parent = parent.typed_view(); + parent.node_type() == "COMPONENT_SET" + && parent.name().is_some_and(button_name) + && parent.child_ids().any(|id| id == node.id) + })); + if explicit_button || named_button || component.eq_ignore_ascii_case("button") { + if explicit_button { + string_prop(props, "as", "button"); + } + string_prop(props, "appearance", "none"); + } if view.bool("visible") == Some(false) { string_prop(props, "display", "none"); } @@ -805,6 +828,11 @@ pub(super) fn push_style_props( } else if let Some(background) = background_css(snapshot, node, used_tokens, style) { string_prop(props, "bg", background); } + if (explicit_button || named_button || component.eq_ignore_ascii_case("button")) + && !props.iter().any(|(name, _)| name == "bg") + { + string_prop(props, "bg", "transparent"); + } if let Some(mode) = view .value("fills") .and_then(Value::as_array) @@ -819,7 +847,6 @@ pub(super) fn push_style_props( { string_prop(props, "bgBlendMode", mode); } - push_radius(&view, props); if component != "Text" { push_strokes( @@ -969,4 +996,4 @@ fn background_css( } } (!css.is_empty()).then(|| css.join(", ")) -} \ No newline at end of file +} diff --git a/crates/devup-mcp-devup-ui/tests/button_appearance.rs b/crates/devup-mcp-devup-ui/tests/button_appearance.rs new file mode 100644 index 00000000..d74b1fe2 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/button_appearance.rs @@ -0,0 +1,70 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +#[test] +fn white_outline_button_without_fill_disables_the_native_face() { + for (name, tag) in [("Button / Outline", None), ("CTA", Some("button"))] { + for fill in [ + json!([]), + json!([{"type":"SOLID","color":{"r":0,"g":0,"b":0}}]), + ] { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"button", "roots":["button"], "diagnostics":[], "nodes": { + "button":{"id":"button","type":"FRAME","fields":{ + "name":name,"as":tag, "width":160,"height":48,"layoutMode":"HORIZONTAL", + "fills":fill,"strokeWeight":1,"strokeAlign":"INSIDE", + "strokes":[{"type":"SOLID","color":{"r":1,"g":1,"b":1}}], + "childrenIds":["label"] + }}, + "label":{"id":"label","type":"TEXT","fields":{ + "characters":"Get started","fontSize":16, + "fills":[{"type":"SOLID","color":{"r":1,"g":1,"b":1}}] + }} + } + })) + .unwrap(); + let output = + generate_component(&snapshot, "button", &CodegenOptions::default()).unwrap(); + assert!(output.tsx.contains("appearance=\"none\""), "{}", output.tsx); + assert!(output.tsx.contains("color=\"#FFF\""), "{}", output.tsx); + assert!( + output.tsx.contains("border=\"solid 1px #FFF\""), + "{}", + output.tsx + ); + let expected = if fill.as_array().unwrap().is_empty() { + "transparent" + } else { + "#000" + }; + assert!( + output.tsx.contains(&format!("bg=\"{expected}\"")), + "{}", + output.tsx + ); + assert_eq!(output.tsx.matches("bg=").count(), 1, "{}", output.tsx); + if tag.is_some() { + assert!(output.tsx.contains("as=\"button\""), "{}", output.tsx); + } + } + } +} + +#[test] +fn button_variant_inherits_native_appearance_safety_from_its_component_set() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"button","roots":["variant"],"diagnostics":[],"nodes":{ + "set":{"id":"set","type":"COMPONENT_SET","fields":{ + "name":"Button","childrenIds":["variant"] + }}, + "variant":{"id":"variant","type":"COMPONENT","fields":{ + "name":"Style=Outline","width":160,"height":48,"fills":[] + }} + } + })) + .unwrap(); + let output = generate_component(&snapshot, "variant", &CodegenOptions::default()).unwrap(); + assert!(output.tsx.contains("appearance=\"none\""), "{}", output.tsx); + assert!(output.tsx.contains("bg=\"transparent\""), "{}", output.tsx); +} diff --git a/crates/devup-mcp-figma/src/asset_render_info.rs b/crates/devup-mcp-figma/src/asset_render_info.rs index acf64d92..a3d4bb44 100644 --- a/crates/devup-mcp-figma/src/asset_render_info.rs +++ b/crates/devup-mcp-figma/src/asset_render_info.rs @@ -592,6 +592,26 @@ pub fn suggested_asset_filename(snapshot: &Snapshot, asset: &AssetManifestEntry) /// Add snapshot context and safe filename suggestions without changing byte identity /// or choosing a filesystem destination. Live export observations take precedence. +fn has_child_text(snapshot: &Snapshot, node_id: &str) -> bool { + let mut pending = vec![node_id]; + let mut seen = std::collections::BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + if let Some(node) = snapshot.nodes.get(id) { + if node.typed_view().bool("visible") == Some(false) { + continue; + } + if id != node_id && node.typed_view().node_type() == "TEXT" { + return true; + } + pending.extend(node.typed_view().child_ids()); + } + } + false +} + pub fn annotate_assets(snapshot: &Snapshot, assets: &mut [AssetManifestEntry]) { let parents: std::collections::BTreeMap<_, _> = snapshot .nodes @@ -724,6 +744,15 @@ pub fn annotate_assets(snapshot: &Snapshot, assets: &mut [AssetManifestEntry]) { info["byteEvidence"] = Value::Null; } compare(&mut info); + if !asset.render_mode.is_original() && has_child_text(snapshot, &asset.node_id) { + let warning = json!( + "node-render-bakes-child-text; use original-source IMAGE fill for backgrounds" + ); + let warnings = info["warnings"].as_array_mut().unwrap(); + if !warnings.contains(&warning) { + warnings.push(warning); + } + } asset.render_info = Some(info); if let Some(diagnostics) = &mut asset.export_diagnostics { diagnostics.render_info = asset.render_info.clone(); diff --git a/crates/devup-mcp-figma/src/assets/asset_manifest.rs b/crates/devup-mcp-figma/src/assets/asset_manifest.rs index e411ee71..4b1c2093 100644 --- a/crates/devup-mcp-figma/src/assets/asset_manifest.rs +++ b/crates/devup-mcp-figma/src/assets/asset_manifest.rs @@ -905,6 +905,28 @@ pub fn resolve_asset_selections( .ok_or_else(|| { invalid("The selected asset or nodeId is not in the acquired snapshot scope.") })?; + // nodeId is a convenient target, not permission to flatten a hero's + // live heading and controls into its background photograph. + let source_fill = (selection.render_mode == AssetRenderMode::OriginalSource + || (selection.render_mode == AssetRenderMode::Auto + && snapshot.nodes.get(&asset.node_id).is_some_and(|node| { + node.typed_view().child_ids().next().is_some() + }))) + .then(|| { + manifest.assets.iter().find(|candidate| { + candidate.node_id == asset.node_id + && candidate.field.starts_with("fills/") + && candidate.image_hash.is_some() + && (asset.field == "node" || asset.field == candidate.field) + }) + }) + .flatten(); + let asset = source_fill.unwrap_or(asset); + let render_mode = if source_fill.is_some() { + AssetRenderMode::OriginalSource + } else { + selection.render_mode + }; let capture_scope = if selection.render_mode == AssetRenderMode::Visible { Some(visible_scope( snapshot, @@ -925,7 +947,7 @@ pub fn resolve_asset_selections( image_hash: asset.image_hash.clone(), format: selection.format, scale: selection.scale, - render_mode: selection.render_mode, + render_mode, quality: selection.quality, lossless: selection.lossless, }) @@ -933,4 +955,4 @@ pub fn resolve_asset_selections( .collect::, _>>()?; validate_asset_requests(snapshot, &requests)?; Ok(requests) -} \ No newline at end of file +} diff --git a/crates/devup-mcp-figma/tests/hero_source_selection.rs b/crates/devup-mcp-figma/tests/hero_source_selection.rs new file mode 100644 index 00000000..c5115009 --- /dev/null +++ b/crates/devup-mcp-figma/tests/hero_source_selection.rs @@ -0,0 +1,55 @@ +use devup_mcp_figma::{ + AssetRenderMode, AssetSelection, Snapshot, annotate_assets, discover_asset_manifest, + resolve_asset_selections, +}; +use serde_json::json; + +#[test] +fn hero_node_target_selects_original_photo_without_baking_text_or_buttons() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"hero", "roots":["hero"], "diagnostics":[], "nodes":{ + "hero":{"id":"hero","type":"FRAME","fields":{ + "name":"Hero","width":1440,"height":720,"childrenIds":["heading","buttons"], + "fills":[{"type":"IMAGE","imageHash":"hero-photo","scaleMode":"FILL"}] + }}, + "heading":{"id":"heading","type":"TEXT","fields":{"characters":"Build your future"}}, + "buttons":{"id":"buttons","type":"FRAME","fields":{"childrenIds":["button"]}}, + "button":{"id":"button","type":"FRAME","fields":{"name":"Button","childrenIds":["label"],"fills":[]}}, + "label":{"id":"label","type":"TEXT","fields":{"characters":"Get started"}} + } + })).unwrap(); + let mut selection: AssetSelection = serde_json::from_value(json!({ + "assetId":"hero:node","format":"png","scale":1 + })) + .unwrap(); + for mode in [AssetRenderMode::OriginalSource, AssetRenderMode::Auto] { + selection.render_mode = mode; + let requests = resolve_asset_selections(&snapshot, &[selection.clone()]).unwrap(); + assert_eq!(requests[0].asset_id, "hero:fills:0"); + assert_eq!(requests[0].field, "fills/0"); + assert_eq!(requests[0].image_hash.as_deref(), Some("hero-photo")); + assert_eq!(requests[0].render_mode, AssetRenderMode::OriginalSource); + } + selection.render_mode = AssetRenderMode::Node; + let requests = resolve_asset_selections(&snapshot, &[selection]).unwrap(); + assert_eq!(requests[0].field, "node"); + let mut assets = discover_asset_manifest(&snapshot).assets; + let asset = assets + .iter_mut() + .find(|asset| asset.node_id == "hero") + .unwrap(); + asset.render_mode = AssetRenderMode::Node; + annotate_assets(&snapshot, &mut assets); + assert!(assets.iter().any(|asset| { + asset.render_info.as_ref().unwrap()["warnings"] + .as_array() + .unwrap() + .iter() + .any(|warning| { + warning + .as_str() + .unwrap() + .contains("node-render-bakes-child-text") + }) + })); +} diff --git a/crates/devup-mcp-figma/tests/manifest_covers_readers.rs b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs index 3ea6405f..06e9baef 100644 --- a/crates/devup-mcp-figma/tests/manifest_covers_readers.rs +++ b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs @@ -12,6 +12,7 @@ use std::{collections::BTreeSet, fs, path::Path}; /// Names that are read through the same accessors but never come from a Figma /// node, so the manifest has nothing to say about them. const NOT_NODE_FIELDS: &[&str] = &[ + "as", // Optional codegen semantic tag override on supplied snapshots; not a Figma API property. // Written by our own scripts onto the node record. "parentId", "parentType", diff --git a/crates/devup-mcp/src/server/projection/project_operation.rs b/crates/devup-mcp/src/server/projection/project_operation.rs index e07cfbc8..b2da4e4b 100644 --- a/crates/devup-mcp/src/server/projection/project_operation.rs +++ b/crates/devup-mcp/src/server/projection/project_operation.rs @@ -121,6 +121,26 @@ async fn project_operation( delivery, width_policy, } => { + // Source selection can turn a nodeId target into its IMAGE fill. + // Keep requested file destinations and capture reporting attached + // to the resolved asset rather than the convenience node alias. + for capture in &mut asset_captures { + if !matches!(capture.render_mode, devup_mcp_figma::AssetRenderMode::OriginalSource | devup_mcp_figma::AssetRenderMode::Auto) { + continue; + } + let Some(asset) = payload.assets.iter().find(|asset| { + asset.render_mode.is_original() && asset.field.starts_with("fills/") + && (asset.asset_id == capture.asset_id + || capture.asset_id == format!("{}:node", asset.node_id)) + }) else { continue }; + if capture.asset_id != asset.asset_id { + if let Some(path) = asset_output_paths.remove(&capture.asset_id) { + asset_output_paths.insert(asset.asset_id.clone(), path); + } + capture.asset_id = asset.asset_id.clone(); + } + capture.render_mode = asset.render_mode; + } if web_ready_images { asset_captures = payload .assets diff --git a/crates/devup-mcp/src/server/projection/regression_tests.rs b/crates/devup-mcp/src/server/projection/regression_tests.rs index 301ac9e3..cc9eacb1 100644 --- a/crates/devup-mcp/src/server/projection/regression_tests.rs +++ b/crates/devup-mcp/src/server/projection/regression_tests.rs @@ -36,6 +36,7 @@ mod w1_regressions { include!("w1_regressions.rs"); include!("w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs"); include!("w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs"); + include!("w1_regressions_hero_source.rs"); include!("w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs"); include!("w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs"); } diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_hero_source.rs b/crates/devup-mcp/src/server/projection/w1_regressions_hero_source.rs new file mode 100644 index 00000000..6071d702 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_regressions_hero_source.rs @@ -0,0 +1,44 @@ +#[tokio::test] +async fn hero_node_source_selection_preserves_the_requested_file_destination() { + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(8, 4) + .write_to(&mut encoded, image::ImageFormat::Png) + .unwrap(); + let bytes = encoded.into_inner(); + let mut data = p3_asset_payload(&bytes); + data.assets.truncate(1); + let hero = data.snapshot.nodes.get_mut("1:2").unwrap(); + hero.node_type = "FRAME".into(); + hero.fields.insert("name".into(), json!("Hero")); + hero.fields.insert("childrenIds".into(), json!(["heading"])); + data.snapshot.nodes.insert("heading".into(), serde_json::from_value(json!({ + "id":"heading","type":"TEXT","fields":{"characters":"Hero heading"} + })).unwrap()); + data.assets[0].data_base64 = Some(STANDARD.encode(&bytes)); + data.assets[0].byte_length = Some(bytes.len()); + data.assets[0].sha256 = Some(sha256_hex(&bytes)); + data.assets[0].render_mode = devup_mcp_figma::AssetRenderMode::OriginalSource; + let path = std::env::temp_dir().join(format!("hero-original-source-{}.png", std::process::id())); + let mut op = operation(&["assetManifest"]); + if let PendingOperation::Export { asset_captures, asset_output_paths, .. } = &mut op { + asset_captures.push(serde_json::from_value(json!({ + "assetId":"1:2:node","format":"png","scale":1 + })).unwrap()); + asset_output_paths.insert("1:2:node".into(), path.to_string_lossy().into_owned()); + } + let store = ArtifactStore::default(); + let artifact = store.insert( + ArtifactRequestKey::from_collection(&CollectionRequest::new(data.target.clone(), CollectionScope::Node)), + data, + ).await.unwrap(); + let result = complete_operation( + op, &artifact.payload, "fixture", &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), &store, + ).await.unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let asset = &result["assetManifest"]["assets"][0]; + assert_eq!(asset["assetId"], "1:2:fills:0"); + assert_eq!(asset["renderMode"], "original-source"); + assert_eq!(asset["outputPath"], result["outputPaths"]["asset:1:2:fills:0"]); + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/devup-mcp/src/server/text/devup-figma-export.md b/crates/devup-mcp/src/server/text/devup-figma-export.md index d82f87eb..5703e7f3 100644 --- a/crates/devup-mcp/src/server/text/devup-figma-export.md +++ b/crates/devup-mcp/src/server/text/devup-figma-export.md @@ -1 +1,2 @@ -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. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. 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. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). 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. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the annotated output. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} \ No newline at end of file +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. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. 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. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). 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. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the annotated output. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} +An asset request targeting a frame by nodeId defaults to the original IMAGE fill when one is available. This keeps hero headings and buttons live instead of baking them into the background image. Explicit renderMode node retains whole-node capture; its renderInfo warns when child text would be baked into the asset. diff --git a/fixtures/devup-figma-plugin/manifest.json b/fixtures/devup-figma-plugin/manifest.json index 82874c11..7456ea0e 100644 --- a/fixtures/devup-figma-plugin/manifest.json +++ b/fixtures/devup-figma-plugin/manifest.json @@ -1631,7 +1631,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap", - "sha256": "fa160e7ac15e5b3f8f1c441ff03960bff3743a51b320441bfe90b0c8c8d01e13" + "sha256": "d3752dc22fd731f727869c6b8f7bea9bdbf21d1f17b9c581a03d5d5eac6aa55a" }, { "path": "snapshots/codegen/upstream-codegen-123-179129a02c.snap", @@ -1639,7 +1639,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-124-bdedf14335.snap", - "sha256": "fa160e7ac15e5b3f8f1c441ff03960bff3743a51b320441bfe90b0c8c8d01e13" + "sha256": "d3752dc22fd731f727869c6b8f7bea9bdbf21d1f17b9c581a03d5d5eac6aa55a" }, { "path": "snapshots/codegen/upstream-codegen-125-4a4a93c4c9.snap", @@ -1647,7 +1647,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-126-14073e1999.snap", - "sha256": "fa160e7ac15e5b3f8f1c441ff03960bff3743a51b320441bfe90b0c8c8d01e13" + "sha256": "d3752dc22fd731f727869c6b8f7bea9bdbf21d1f17b9c581a03d5d5eac6aa55a" }, { "path": "snapshots/codegen/upstream-codegen-127-1d5ead088d.snap", @@ -1671,7 +1671,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap", - "sha256": "fa160e7ac15e5b3f8f1c441ff03960bff3743a51b320441bfe90b0c8c8d01e13" + "sha256": "d3752dc22fd731f727869c6b8f7bea9bdbf21d1f17b9c581a03d5d5eac6aa55a" }, { "path": "snapshots/codegen/upstream-codegen-133-f425fa006c.snap", @@ -1715,7 +1715,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-143-52b441f9f4.snap", - "sha256": "a3a2a629306c0b50c80cd4e3db7c9bd410e5d5c97dd2d87652a998a5b108403c" + "sha256": "8d1079d0572575cf6144f86be4442d2c503fc30221853125e552400a7388870d" }, { "path": "snapshots/codegen/upstream-codegen-144-042a6de403.snap", @@ -1799,7 +1799,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-164-33e4c6591c.snap", - "sha256": "eebaa0436df2c1bd77b33ae594c7e933874a87bd5167f438eb049ec22dfb35e6" + "sha256": "194f06f9eb7ed13b8f1393737b3a1a91805422d0b2658cf23b8d756134ec0e22" }, { "path": "snapshots/codegen/upstream-codegen-165-501ee1df12.snap", @@ -2087,31 +2087,31 @@ }, { "path": "snapshots/codegen/upstream-codegen-236-621c6d6a9e.snap", - "sha256": "3b2ec85d78cb9730bb9ce463b380c35426a800abe6993201a762f015817350ed" + "sha256": "09b73b6e9ce938be1e0f32b7ce651182cc5fd5cf2b5c1ca6a15692aaf4e1e1aa" }, { "path": "snapshots/codegen/upstream-codegen-237-01ee478666.snap", - "sha256": "fb11d0e3d3c5d7dc8b2c4e84f207e1284eb3902a1020bc8ba8e171954c262181" + "sha256": "6a0cf91ec198452b8696fb591d269cbb7af94842077ee95bbdaf85b222526ec6" }, { "path": "snapshots/codegen/upstream-codegen-238-1a8cf8c870.snap", - "sha256": "c027c04b4adc0cf327d8bdd60f0cfaf3529b60c85cd6955d07e919f1d4f8a1bb" + "sha256": "21ac84461a0cbacd84248dc4fc8c83633ce53f050f3d76b2557331a1c4d61c70" }, { "path": "snapshots/codegen/upstream-codegen-239-a6dada3682.snap", - "sha256": "9cf29f9bc192169ef5d677b3d7592a97c21d24cfc86930666286539a30025628" + "sha256": "4e7d5594ab96b2874e9ab27267c80d49f31ba57699b96af142d193d6777c0f1a" }, { "path": "snapshots/codegen/upstream-codegen-240-77d56a1585.snap", - "sha256": "c027c04b4adc0cf327d8bdd60f0cfaf3529b60c85cd6955d07e919f1d4f8a1bb" + "sha256": "21ac84461a0cbacd84248dc4fc8c83633ce53f050f3d76b2557331a1c4d61c70" }, { "path": "snapshots/codegen/upstream-codegen-241-e171ef72ce.snap", - "sha256": "9cf29f9bc192169ef5d677b3d7592a97c21d24cfc86930666286539a30025628" + "sha256": "4e7d5594ab96b2874e9ab27267c80d49f31ba57699b96af142d193d6777c0f1a" }, { "path": "snapshots/codegen/upstream-codegen-242-5c198a8490.snap", - "sha256": "4d523a1399ab5eb29d7d1cc1a9a398c6e96d73fd6bbf4d99b0ea07fc8a8dfca7" + "sha256": "01f026b07182e959a23c9d2cf1fec0fa90ee2b342e47274a34a9aaad099a1c28" }, { "path": "snapshots/codegen/upstream-codegen-243-14366a47ff.snap", @@ -2143,7 +2143,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-250-935ccdb80f.snap", - "sha256": "f8a7b08cb2be01fa31ec8836a11e6e9e2be951c1315028fa22c807e262b29597" + "sha256": "81f4a447d189627568f6d397cbfbeef4b409fab6f447404ebc2408689f8ce0ef" }, { "path": "snapshots/codegen/upstream-codegen-251-50acddfb96.snap", diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap index c3d7df23..caa033fe 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap @@ -3,7 +3,7 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -" - - +" + + " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-124-bdedf14335.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-124-bdedf14335.snap index c3d7df23..caa033fe 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-124-bdedf14335.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-124-bdedf14335.snap @@ -3,7 +3,7 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -" - - +" + + " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-126-14073e1999.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-126-14073e1999.snap index c3d7df23..caa033fe 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-126-14073e1999.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-126-14073e1999.snap @@ -3,7 +3,7 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -" - - +" + + " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap index c3d7df23..caa033fe 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap @@ -3,7 +3,7 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -" - - +" + + " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap index fcfe3fde..44ae1781 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap @@ -13,7 +13,7 @@ expression: actual export function Button({ size, leftIcon, showLeftIcon, rightIcon, showRightIcon }: ButtonProps) { return ( - + {showLeftIcon && leftIcon} +
+
+
) diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-239-a6dada3682.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-239-a6dada3682.snap index b66c064a..679e1523 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-239-a6dada3682.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-239-a6dada3682.snap @@ -5,7 +5,13 @@ expression: actual "export function SvgDetail() { return ( -
+
+
) diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-241-e171ef72ce.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-241-e171ef72ce.snap index b66c064a..679e1523 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-241-e171ef72ce.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-241-e171ef72ce.snap @@ -5,7 +5,13 @@ expression: actual "export function SvgDetail() { return ( -
+
+
) diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap index 54410e80..b4205053 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap @@ -23,6 +23,7 @@ export function Button({ size, variant }: ButtonProps) { white: [null, null, null, null, "$background"] }[variant] }} + appearance="none" bg={{ primary: "$primary", white: "$containerBackground" diff --git a/harness/runtime-audit/test/browser.test.mjs b/harness/runtime-audit/test/browser.test.mjs index 40448541..dbee0480 100644 --- a/harness/runtime-audit/test/browser.test.mjs +++ b/harness/runtime-audit/test/browser.test.mjs @@ -382,3 +382,43 @@ test('computed button backgrounds and every image resource are observed in Chrom assert.equal(evaluateImageLoaded(fixed).status, 'pass'); } finally { await browser.close(); } }); + +test('white outline no-fill button overrides the reset and retains contrast over a dark hero', async () => { + const { chromium } = await import('playwright'); + const { evaluateNoLowContrastText } = await import('../checks.mjs'); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.setContent(`
`); + const style = await page.locator('button').evaluate((element) => { + const css = getComputedStyle(element); + return { appearance: css.appearance, webkitAppearance: css.webkitAppearance, + background: css.backgroundColor, border: css.borderTopWidth, color: css.color }; + }); + assert.deepEqual(style, { appearance: 'none', webkitAppearance: 'none', + background: 'rgba(0, 0, 0, 0)', border: '1px', color: 'rgb(255, 255, 255)' }); + // Computed transparent alone did not establish what the native face painted. + // Sample the rendered padding, away from the white border and glyphs. + const pixels = await page.locator('button').screenshot(); + const paintedBackground = await page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + return [...context.getImageData(4, 4, 1, 1).data]; + }, pixels.toString('base64')); + assert.deepEqual(paintedBackground, [16, 24, 32, 255]); + assert.equal(evaluateNoLowContrastText({}, await observeCompleteness(page, {}, 800)).status, 'pass'); + await page.locator('.hero').evaluate((element) => { element.style.background = 'white'; }); + assert.equal(evaluateNoLowContrastText({}, await observeCompleteness(page, {}, 800)).status, 'fail'); + } finally { await browser.close(); } +}); From 0c3386a30e265f72d087c8059416c5c580ab9dab Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 20:06:35 +0900 Subject: [PATCH 10/11] feat: show devup-mcp version in bridge plugin and harden runtime audit Plugin shows its own version and the connected devup-mcp version (server-info); hello carries pluginVersion, surfaced in status attachedFiles. Runtime audit adds image-loaded, native-appearance and stale-resources checks. --- ...angepack_log_PlugVer_0a1B2c3D4e5F6g7H.json | 7 + ...angepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json | 7 + .../src/bridge/stamp_file_key.rs | 5 +- .../src/bridge/transport_and_state.rs | 17 + crates/devup-mcp-figma/tests/bridge_relay.rs | 8 +- .../bridge_transport/fixtures_and_cases.rs | 23 +- .../server/audit_plan/planning_and_capture.rs | 2 + .../diagnostics/response_diagnostics.rs | 1 + .../devup-mcp/src/server/diagnostics/tests.rs | 2 + .../runtime_audit/inputs_and_capture.rs | 11 +- .../src/server/runtime_audit/tests.rs | 4 +- .../src/server/runtime_audit/write_plan.rs | 4 +- .../src/server/text/devup-runtime-audit.md | 2 +- crates/devup-mcp/tests/bridge_first.rs | 6 + harness/runtime-audit/package.json | 2 +- harness/runtime-audit/resource-checks.mjs | 290 ++++++++++++++++++ harness/runtime-audit/run.mjs | 75 ++++- .../runtime-audit/test/appearance.test.mjs | 49 +++ plugin/dist/ui.html | 2 +- plugin/rspack.config.js | 4 + plugin/src/ui.html | 1 + plugin/src/ui.ts | 20 ++ 22 files changed, 507 insertions(+), 35 deletions(-) create mode 100644 .changepacks/changepack_log_PlugVer_0a1B2c3D4e5F6g7H.json create mode 100644 .changepacks/changepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json create mode 100644 harness/runtime-audit/resource-checks.mjs create mode 100644 harness/runtime-audit/test/appearance.test.mjs diff --git a/.changepacks/changepack_log_PlugVer_0a1B2c3D4e5F6g7H.json b/.changepacks/changepack_log_PlugVer_0a1B2c3D4e5F6g7H.json new file mode 100644 index 00000000..f7773e7d --- /dev/null +++ b/.changepacks/changepack_log_PlugVer_0a1B2c3D4e5F6g7H.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Report the Devup Bridge plugin version in hello and show plugin and devup-mcp versions in the plugin window", + "date": "2026-10-19T09:00:00.000000000Z" +} diff --git a/.changepacks/changepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json b/.changepacks/changepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json new file mode 100644 index 00000000..dd6ca4dc --- /dev/null +++ b/.changepacks/changepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Make runtime audit image-loaded, native-appearance and stale-resources checks fail on unpainted images, native button chrome and differences from a fresh cache-free load", + "date": "2026-10-19T09:00:00.000000000Z" +} diff --git a/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs b/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs index 56da2547..816f4c33 100644 --- a/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs +++ b/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs @@ -320,6 +320,7 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { match value.get("kind").and_then(Value::as_str) { Some("hello") => { + state.announce_server(&outbox); // 키가 없어도 등록한다. 건너뛰면 창은 "연결됨"이라고 하는데 // 어떤 읽기도 오지 않아 원인을 찾을 수 없다. 키 없는 연결을 // 어디까지 믿을지는 resolve 가 정한다. @@ -350,11 +351,13 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { // 이 값으로 대상을 고르므로, 늦게라도 최신이어야 한다. 중계들도 // 같은 선택을 보도록 함께 알린다. Some("context") => { - let Ok(context) = serde_json::from_value::(value) else { + let Ok(mut context) = serde_json::from_value::(value) else { continue; }; let mut inner = state.inner.lock().await; if let Some(plugin) = inner.plugins.get_mut(&id) { + // 버전은 hello 에만 실려 온다. 맥락을 바꿔 끼워도 잃지 않는다. + context.plugin_version = plugin.context.plugin_version.take(); plugin.context = context; state.plugins_changed(&mut inner); } diff --git a/crates/devup-mcp-figma/src/bridge/transport_and_state.rs b/crates/devup-mcp-figma/src/bridge/transport_and_state.rs index 0cd73223..df30e84e 100644 --- a/crates/devup-mcp-figma/src/bridge/transport_and_state.rs +++ b/crates/devup-mcp-figma/src/bridge/transport_and_state.rs @@ -114,6 +114,10 @@ pub struct PluginContext { /// 보고하기 전에 빌드된 플러그인은 `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub in_view: Option, + /// 플러그인 빌드의 버전. `hello` 에만 실려 오며, 이것을 보고하기 전에 빌드된 + /// 플러그인은 `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_version: Option, } impl PluginContext { @@ -601,6 +605,19 @@ impl BridgeState { Ok(()) } + /// 플러그인이 어느 devup-mcp 에 붙었는지 알 수 있게 먼저 밝힌다. + /// 모르는 메시지는 예전 플러그인이 무시한다. + fn announce_server(&self, outbox: &tokio::sync::mpsc::UnboundedSender) { + let _ = outbox.send( + json!({ + "kind": "server-info", + "version": self.link.me.version, + "buildId": self.link.me.build_id, + }) + .to_string(), + ); + } + /// 플러그인 목록이 바뀌었다. 세고, 중계들에게 알리고, 기다리는 쪽을 깨운다. fn plugins_changed(&self, inner: &mut Inner) { self.connected.store(inner.plugins.len(), Ordering::Relaxed); diff --git a/crates/devup-mcp-figma/tests/bridge_relay.rs b/crates/devup-mcp-figma/tests/bridge_relay.rs index a4bff5f9..af75618f 100644 --- a/crates/devup-mcp-figma/tests/bridge_relay.rs +++ b/crates/devup-mcp-figma/tests/bridge_relay.rs @@ -93,7 +93,11 @@ async fn next_text(socket: &mut Socket) -> Value { .expect("the socket stays open") .expect("the socket reads cleanly"); if let Message::Text(text) = message { - return serde_json::from_str(&text).expect("messages are JSON"); + let value: Value = serde_json::from_str(&text).expect("messages are JSON"); + if value["kind"] == "server-info" { + continue; + } + return value; } } } @@ -358,7 +362,7 @@ async fn the_relay_door_turns_away_browsers_and_unproven_callers() { // None of them got a read through. A forwarded job would have been queued // on this socket long before now, so a short look is enough to see none. assert!( - tokio::time::timeout(Duration::from_millis(300), plugin.next()) + tokio::time::timeout(Duration::from_millis(300), next_text(&mut plugin)) .await .is_err(), "no job may reach the plugin from an unauthenticated relay" diff --git a/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs b/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs index 9be69826..19023f88 100644 --- a/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs +++ b/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs @@ -47,15 +47,20 @@ async fn connect_plugin(server: &BridgeServer) -> Plugin { /// 다음 작업 하나를 받아 요청 id 와 함께 돌려준다. async fn next_job(socket: &mut Plugin) -> Value { - let message = socket - .next() - .await - .expect("a job arrives") - .expect("the socket stays open"); - let Message::Text(text) = message else { - panic!("jobs are sent as text"); - }; - serde_json::from_str(&text).expect("a job is JSON") + loop { + let message = socket + .next() + .await + .expect("a job arrives") + .expect("the socket stays open"); + let Message::Text(text) = message else { + panic!("jobs are sent as text"); + }; + let value: Value = serde_json::from_str(&text).expect("a job is JSON"); + if value["kind"] != "server-info" { + return value; + } + } } #[tokio::test] diff --git a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs index 5f261729..cadcea96 100644 --- a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs +++ b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs @@ -313,6 +313,8 @@ pub(super) fn url_plan( check(json!({"kind": "page-health", "label": "page health"})), check(json!({"kind": "no-low-contrast-text", "label": "visible text contrast"})), check(json!({"kind": "image-loaded", "label": "images and backgrounds loaded"})), + check(json!({"kind": "native-appearance", "label": "controls drawn without native browser chrome"})), + check(json!({"kind": "stale-resources", "label": "no stale or missing build resources"})), check(json!({"kind": "no-horizontal-overflow", "label": "no horizontal overflow"})), check(json!({"kind": "no-clipped-text", "label": "no clipped text"})), ]; diff --git a/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs b/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs index 61f44fa2..6965ea00 100644 --- a/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs @@ -620,6 +620,7 @@ fn attached_file(file: &AttachedFile) -> Value { "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, + "pluginVersion":file.context.plugin_version, "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 { diff --git a/crates/devup-mcp/src/server/diagnostics/tests.rs b/crates/devup-mcp/src/server/diagnostics/tests.rs index 08464e2e..e4f550ae 100644 --- a/crates/devup-mcp/src/server/diagnostics/tests.rs +++ b/crates/devup-mcp/src/server/diagnostics/tests.rs @@ -86,6 +86,7 @@ mod tests { selection: selected.map(|count| (1..=count).map(node).collect()), selection_count: selected, in_view: Some(false), + plugin_version: Some("0.4.6".to_owned()), ..Default::default() }, } @@ -218,6 +219,7 @@ mod tests { assert_eq!(file["selection"][0]["type"], "FRAME"); // Out of view, status says so before a collection meets Figma's refusals. assert_eq!(file["inView"], false); + assert_eq!(file["pluginVersion"], "0.4.6"); assert!( file["inViewNote"] .as_str() diff --git a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs index 906b14a2..30d3c5f4 100644 --- a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs +++ b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs @@ -228,11 +228,15 @@ pub struct CheckInput { /// no-low-contrast-text: text against effective computed background colours. /// image-loaded: every img and CSS background URL decodes with naturalWidth > 0. /// Both accept an optional subject scope. + /// native-appearance: visible form controls (optional subject scope) have + /// computed appearance none, not the browser's native face. + /// stale-resources: no image, script, stylesheet, font or media response + /// while the screen was open has an HTTP error status. /// rendered-completeness: at one drawn width, the page is at least /// minHeightRatio of the frame's height, every listed section and image /// renders (images decoded), and no text is invisible against what is /// painted behind it. A drawn width without a passing one is not-run. - #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden", "rendered-completeness", "sequence-order", "section-layout", "text-align", "no-clipped-content", "no-low-contrast-text", "image-loaded"]))] + #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden", "rendered-completeness", "sequence-order", "section-layout", "text-align", "no-clipped-content", "no-low-contrast-text", "image-loaded", "native-appearance", "stale-resources"]))] pub kind: String, #[serde(default)] pub subject: Option, @@ -621,7 +625,8 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { match check.kind.as_str() { "no-horizontal-overflow" | "page-health" | "no-clipped-content" => needs(false, false)?, "visible" | "view-transition" | "hidden" => needs(true, false)?, - "no-clipped-text" | "no-low-contrast-text" | "image-loaded" => { + "stale-resources" => needs(false, false)?, + "no-clipped-text" | "no-low-contrast-text" | "image-loaded" | "native-appearance" => { if check.container.is_some() { return Err(invalid(format!("A {} check takes no container.", check.kind))); } @@ -914,7 +919,7 @@ fn columns( let health = if unreached { "fail" } else { - fold(&["page-health", "no-horizontal-overflow", "no-clipped-content", "no-clipped-text", "no-low-contrast-text", "image-loaded"]) + fold(&["page-health", "no-horizontal-overflow", "no-clipped-content", "no-clipped-text", "no-low-contrast-text", "image-loaded", "native-appearance", "stale-resources"]) }; let captures = |scope: &str| { let count = judged diff --git a/crates/devup-mcp/src/server/runtime_audit/tests.rs b/crates/devup-mcp/src/server/runtime_audit/tests.rs index 287805a8..faf1eaa0 100644 --- a/crates/devup-mcp/src/server/runtime_audit/tests.rs +++ b/crates/devup-mcp/src/server/runtime_audit/tests.rs @@ -386,7 +386,9 @@ mod tests { } assert!(ok(json!({"kind":"text-align", "subject":node("1:2"), "edge":"center"})).is_ok()); assert!(ok(json!({"kind":"text-align", "subject":node("1:2"), "edge":"bad"})).is_err()); - for kind in ["no-low-contrast-text", "image-loaded"] { + assert!(ok(json!({"kind": "stale-resources"})).is_ok()); + assert!(ok(json!({"kind": "stale-resources", "subject": node("1:2")})).is_err()); + for kind in ["no-low-contrast-text", "image-loaded", "native-appearance"] { assert!(ok(json!({"kind": kind})).is_ok()); assert!(ok(json!({"kind": kind, "subject": node("1:2")})).is_ok()); assert!(ok(json!({"kind": kind, "container": node("1:2")})).is_err()); diff --git a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs index d4567c54..23d1c6f4 100644 --- a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs +++ b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs @@ -917,8 +917,8 @@ fn array_text(value: &Value) -> String { pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { match kind { "page-health" => "runtime-error", - "image-loaded" => "asset-mismatch", - "no-low-contrast-text" => "section-visual", + "image-loaded" | "stale-resources" => "asset-mismatch", + "no-low-contrast-text" | "native-appearance" => "section-visual", "rendered-completeness" => "page-visual", "no-horizontal-overflow" | "no-clipped-text" => "overflow", "bounds" if reason.starts_with("position") => "layout-position", diff --git a/crates/devup-mcp/src/server/text/devup-runtime-audit.md b/crates/devup-mcp/src/server/text/devup-runtime-audit.md index 7f4f8601..ea38d1d2 100644 --- a/crates/devup-mcp/src/server/text/devup-runtime-audit.md +++ b/crates/devup-mcp/src/server/text/devup-runtime-audit.md @@ -1,4 +1,4 @@ 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. 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, hidden, and bounds - size and position against the Figma frame, exact up to 0.5px of rounding) plus page-health, no-clipped-text, interaction, capture and view-transition (follow a link and confirm a view transition ran) - 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. Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. -Rendered completeness requires the frame height (default minimum 90%), listed sections and images, visible text contrast (default minimum 1.5:1), and decoded image resources. no-low-contrast-text checks computed text colours against composited ancestor backgrounds, including browser default control backgrounds. Image or unsupported colour backgrounds are inconclusive, never silently passed. image-loaded checks every img and CSS background URL (including pseudo-elements), requiring naturalWidth > 0; lazy images are loaded for the audit. Both checks accept an optional subject scope and run at every audit width in generated plans. +Rendered completeness requires the frame height (default minimum 90%), listed sections and images, visible text contrast (default minimum 1.5:1), and decoded image resources. no-low-contrast-text checks computed text colours against composited ancestor backgrounds, including browser default control backgrounds. Image or unsupported colour backgrounds are inconclusive, never silently passed. image-loaded checks every img and CSS background URL (including pseudo-elements), requiring naturalWidth > 0; lazy images are loaded for the audit. Both checks accept an optional subject scope and run at every audit width in generated plans. native-appearance fails any visible button whose computed appearance is not none without an author-set background and border, or that paints the browser default grey. stale-resources fails any image, script, stylesheet, font or media response with an HTTP status of 400 or more, and any difference in stylesheet content, image URLs or first-section height from a fresh, cache-free load of the same URL; image-loaded also fails images with no painted box or a failed image request. diff --git a/crates/devup-mcp/tests/bridge_first.rs b/crates/devup-mcp/tests/bridge_first.rs index 771f8627..aab97108 100644 --- a/crates/devup-mcp/tests/bridge_first.rs +++ b/crates/devup-mcp/tests/bridge_first.rs @@ -207,6 +207,9 @@ 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"); + if job["kind"] == "server-info" { + continue; + } let data = if job["script"] == "measureNodes" { Ok( json!({"kind":"devup-measure","schemaVersion":1,"coordinateSpace":"figma-logical", @@ -243,6 +246,9 @@ fn serve_with_context(mut socket: Socket, context: Option) -> Arc { + const url = route.request().url(); + const protocol = url.slice(0, url.indexOf(':')); + if (['data', 'blob', 'about'].includes(protocol) || allowed.has(new URL(url).origin)) { + return route.continue(); + } + blocked.push(url); + return route.abort('blockedbyclient'); + }); +} + +/** Every rendered img and CSS background url in scope, loaded and measured. */ +export function observeImages(scope) { + return scope.evaluate(async (root) => { + const rendered = (element) => !element.checkVisibility || element.checkVisibility({ visibilityProperty: true }); + const absolute = (source) => { + try { return new URL(source, document.baseURI).href; } catch { return source; } + }; + const label = (element) => element.id ? `#${element.id}` : `${element.tagName.toLowerCase()}${element.className && typeof element.className === 'string' ? `.${element.className.trim().split(/\s+/)[0]}` : ''}`; + const images = []; + const pending = []; + let hidden = 0; + for (const element of [root, ...root.querySelectorAll('*')]) { + const shown = rendered(element); + const rect = element.getBoundingClientRect(); + const box = { width: Math.round(rect.width * 100) / 100, height: Math.round(rect.height * 100) / 100 }; + if (element.tagName === 'IMG') { + if (!shown) hidden += 1; + else { + const item = { kind: 'img', element: label(element), source: element.currentSrc || element.src || '', loaded: false, ...box }; + images.push(item); + element.loading = 'eager'; + pending.push(new Promise((resolve) => { + const timer = setTimeout(resolve, 5000); + const done = () => { item.loaded = element.complete && element.naturalWidth > 0; clearTimeout(timer); resolve(); }; + element.decode().then(done, done); + })); + } + } + for (const pseudo of [null, '::before', '::after']) { + const style = getComputedStyle(element, pseudo); + if (pseudo && (style.content === 'none' || style.content === 'normal')) continue; + for (const match of style.backgroundImage.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/g)) { + if (!shown) { hidden += 1; continue; } + const source = absolute(match[1] ?? match[2] ?? match[3]); + const item = { kind: `background${pseudo ?? ''}`, element: label(element), source, loaded: false, ...box }; + images.push(item); + pending.push(new Promise((resolve) => { + const picture = new Image(); + const timer = setTimeout(resolve, 5000); + const done = () => { item.loaded = picture.naturalWidth > 0; clearTimeout(timer); resolve(); }; + picture.onload = done; + picture.onerror = done; + picture.src = source; + if (picture.complete) done(); + })); + } + } + } + await Promise.all(pending); + return { images, hidden }; + }); +} + +/** + * @param {object} observed { images: [{ kind, element, source, loaded, width, height }], hidden } + * @param {object} requests { responses: [{ url, status }], failed: [{ url, failure }], scoped } + * image requests made while the screen was open. When scoped, only requests + * for an image in scope count. + */ +export function evaluateImagesLoaded(observed, requests = {}) { + const images = observed.images ?? []; + const unpainted = images.flatMap((item) => { + const why = []; + if (!item.loaded) why.push('did not load'); + if (!(item.width > 0 && item.height > 0)) why.push('has no painted box'); + return why.length ? [{ ...item, problem: why.join(' and ') }] : []; + }); + const sources = new Set(images.map((item) => item.source)); + const inScope = (item) => !requests.scoped || sources.has(item.url); + const seen = new Set(); + const requestFailures = [ + ...(requests.responses ?? []).filter((item) => item.status >= 400).map((item) => ({ url: item.url, status: item.status })), + ...(requests.failed ?? []).map((item) => ({ url: item.url, status: null, failure: item.failure })), + ].filter(inScope).filter((item) => { + const key = `${item.url} ${item.status}`; + return seen.has(key) ? false : (seen.add(key), true); + }); + const problems = []; + if (unpainted.length) { + problems.push(`${unpainted.length} images ${unpainted.slice(0, 5).map((item) => `${item.source || '(no src)'} on ${item.element} ${item.problem}`).join(', ')}`); + } + if (requestFailures.length) { + problems.push(`${requestFailures.length} image requests failed: ${requestFailures.slice(0, 5).map((item) => `${item.status ?? item.failure} ${item.url}`).join(', ')}`); + } + return { + status: problems.length ? 'fail' : 'pass', + reason: problems.length ? problems.join('; ') : `${images.length} images and backgrounds loaded and painted`, + measured: { listed: images.length, hidden: observed.hidden ?? 0, unpainted: unpainted.slice(0, 20), requestFailures: requestFailures.slice(0, 20) }, + }; +} + +/** Buttons and their computed appearance, against what the browser paints by default. */ +export function observeNativeAppearance(scope) { + return scope.evaluate((root) => { + const isButton = (element) => element.tagName === 'BUTTON' + || element.getAttribute('role') === 'button' + || (element.tagName === 'INPUT' && ['button', 'submit', 'reset'].includes(element.type)); + const frame = document.createElement('iframe'); + frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden'; + document.body.appendChild(frame); + const defaults = new Map(); + const defaultsFor = (element) => { + const key = `${element.tagName}:${element.type ?? ''}`; + if (!defaults.has(key)) { + const doc = frame.contentDocument; + const probe = doc.createElement(element.tagName.toLowerCase()); + if (element.tagName === 'INPUT') probe.type = element.type; + doc.body.appendChild(probe); + const style = frame.contentWindow.getComputedStyle(probe); + defaults.set(key, { background: style.backgroundColor, borderStyle: style.borderTopStyle, borderWidth: style.borderTopWidth, borderColor: style.borderTopColor }); + } + return defaults.get(key); + }; + const transparent = (color) => color === 'transparent' || /^rgba\(.*,\s*0\)$/.test(color); + const controls = []; + try { + for (const element of [root, ...root.querySelectorAll('button, input, [role="button"]')]) { + if (!isButton(element)) continue; + if (element.checkVisibility && !element.checkVisibility({ visibilityProperty: true })) continue; + const rect = element.getBoundingClientRect(); + if (!rect.width || !rect.height) continue; + const style = getComputedStyle(element); + const base = defaultsFor(element); + const authorBackground = (!transparent(style.backgroundColor) && style.backgroundColor !== base.background) + || (style.backgroundImage !== 'none'); + const authorBorder = style.borderTopStyle !== 'none' && parseFloat(style.borderTopWidth) > 0 + && (style.borderTopStyle !== base.borderStyle || style.borderTopWidth !== base.borderWidth || style.borderTopColor !== base.borderColor); + controls.push({ + tag: element.tagName.toLowerCase(), + type: element.type ?? null, + name: (element.getAttribute('aria-label') || element.value || element.textContent || '').trim().slice(0, 40), + appearance: style.appearance || style.webkitAppearance, + backgroundColor: style.backgroundColor, + borderStyle: style.borderTopStyle, + borderWidth: style.borderTopWidth, + authorBackground, + authorBorder, + }); + } + } finally { + frame.remove(); + } + return { controls }; + }); +} + +/** + * A button keeps the browser's native face unless the author set both a + * background and a border; and a computed background of the UA default grey + * is the native face leaking through whatever appearance says. + */ +export function evaluateNativeAppearance(observed) { + const controls = observed.controls ?? []; + const flagged = controls.flatMap((control) => { + const why = []; + if (control.appearance && control.appearance !== 'none' && !(control.authorBackground && control.authorBorder)) { + why.push(`appearance ${control.appearance} without an author-set background and border`); + } + if (UA_GREY.has(control.backgroundColor)) why.push(`UA default grey background ${control.backgroundColor}`); + return why.length ? [{ ...control, problems: why }] : []; + }); + return { + status: flagged.length ? 'fail' : 'pass', + reason: flagged.length + ? `${flagged.length} buttons show native browser chrome: ${flagged.slice(0, 5).map((item) => `<${item.tag}> "${item.name}" (${item.problems.join('; ')})`).join(', ')}` + : `${controls.length} buttons are drawn by the author`, + measured: { listed: controls.length, native: flagged.slice(0, 20) }, + }; +} + +/** Stylesheets (href + rule hash), image urls and the first section's height. */ +export function snapshotLoad(page) { + return page.evaluate(() => { + const hash = (text) => { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + h1 = Math.imul(h1 ^ code, 2654435761); + h2 = Math.imul(h2 ^ code, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16); + }; + const stylesheets = [...document.styleSheets].map((sheet, index) => { + let text = null; + try { text = [...sheet.cssRules].map((rule) => rule.cssText).join('\n'); } catch { text = null; } + return { href: sheet.href ?? `inline#${index}`, hash: text === null ? 'unreadable' : hash(text) }; + }).sort((a, b) => a.href.localeCompare(b.href)); + const images = new Set(); + for (const image of document.images) if (image.currentSrc || image.src) images.add(image.currentSrc || image.src); + for (const element of document.querySelectorAll('*')) { + for (const pseudo of [null, '::before', '::after']) { + for (const match of getComputedStyle(element, pseudo).backgroundImage.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/g)) { + images.add(match[1] ?? match[2] ?? match[3]); + } + } + } + const section = document.querySelector('section') || document.querySelector('main > *') || document.body.firstElementChild; + return { + stylesheets, + images: [...images].sort(), + firstSection: section + ? { name: section.id ? `#${section.id}` : section.tagName.toLowerCase(), height: Math.round(section.getBoundingClientRect().height * 100) / 100 } + : null, + }; + }); +} + +/** What changed between two loads of the same screen; empty when nothing did. */ +export function diffLoads(first, second, tolerance = 0.5) { + const diff = []; + const sheets = (load) => new Map(load.stylesheets.map((sheet) => [sheet.href, sheet.hash])); + const before = sheets(first); + const after = sheets(second); + for (const [href, hash] of before) { + if (!after.has(href)) diff.push(`stylesheet ${href} missing from the fresh load`); + else if (after.get(href) !== hash) diff.push(`stylesheet ${href} content changed (${hash} -> ${after.get(href)})`); + } + for (const href of after.keys()) if (!before.has(href)) diff.push(`stylesheet ${href} only in the fresh load`); + const images = new Set(first.images); + const fresh = new Set(second.images); + for (const url of images) if (!fresh.has(url)) diff.push(`image ${url} missing from the fresh load`); + for (const url of fresh) if (!images.has(url)) diff.push(`image ${url} only in the fresh load`); + const [a, b] = [first.firstSection, second.firstSection]; + if ((a === null) !== (b === null)) diff.push('first section present in only one load'); + else if (a && Math.abs(a.height - b.height) > tolerance) diff.push(`first section ${a.name} height ${round(a.height)}px -> ${round(b.height)}px`); + return diff; +} + +/** + * @param {object} observed { responses: [{ url, status }] subresources of the first load, + * first, second: snapshotLoad results, skip: why no reload was made, reloadError } + */ +export function evaluateStaleResources(observed) { + const errors = (observed.responses ?? []).filter((item) => item.status >= 400); + const problems = []; + if (errors.length) problems.push(`${errors.length} resources answered with an error: ${errors.slice(0, 5).map((item) => `${item.status} ${item.url}`).join(', ')}`); + const diff = observed.first && observed.second ? diffLoads(observed.first, observed.second) : []; + if (diff.length) problems.push(`the page differs from a fresh, cache-free load: ${diff.slice(0, 8).join('; ')}`); + const measured = { responses: (observed.responses ?? []).length, errors: errors.slice(0, 20), diff: diff.slice(0, 40) }; + if (problems.length) return { status: 'fail', reason: problems.join('; '), measured }; + if (!observed.second) { + return { status: 'inconclusive', reason: observed.skip ?? (observed.reloadError ? `the fresh load failed: ${observed.reloadError}` : 'no fresh load to compare'), measured }; + } + return { status: 'pass', reason: 'stylesheets, images and first section height match a fresh, cache-free load', measured }; +} + +/** Load a url in a new context with the HTTP cache disabled and snapshot it. */ +export async function freshLoad({ browser, contextOptions, install, url, timeout, ready }) { + const context = await browser.newContext(contextOptions); + try { + await install(context); + const page = await context.newPage(); + const session = await context.newCDPSession(page); + await session.send('Network.enable'); + await session.send('Network.setCacheDisabled', { cacheDisabled: true }); + await page.goto(url, { waitUntil: 'load', timeout }); + await ready(page); + return await snapshotLoad(page); + } finally { + await context.close(); + } +} diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index b11030dd..77d54673 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -17,8 +17,11 @@ import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { ERROR_PAGE_TEXT, STATE_PROPERTIES, evaluateBounds, evaluateHidden, evaluateCheck, evaluateClippedText, evaluateDimensions, - evaluateInteraction, evaluatePageHealth, evaluateTextAlignment, evaluateSequenceOrder, evaluateSectionLayout, evaluateNoLowContrastText, evaluateImageLoaded, evaluateRenderedCompleteness, evaluateViewTransition, summarize, + evaluateInteraction, evaluatePageHealth, evaluateTextAlignment, evaluateSequenceOrder, evaluateSectionLayout, evaluateNoLowContrastText, evaluateRenderedCompleteness, evaluateViewTransition, summarize, } from './checks.mjs'; +import { + evaluateImagesLoaded, evaluateNativeAppearance, evaluateStaleResources, freshLoad, guardOrigins, observeImages, observeNativeAppearance, snapshotLoad, +} from './resource-checks.mjs'; const PLAN_KIND = 'devup-runtime-audit-plan'; const REPORT_KIND = 'devup-runtime-audit-report'; @@ -485,7 +488,7 @@ async function checkExpectations(page, expect, timeout) { 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({ + const contextOptions = { viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.deviceScaleFactor ?? 1, isMobile: Boolean(viewport.mobile), @@ -493,27 +496,29 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { locale: plan.locale ?? undefined, timezoneId: plan.timezone ?? undefined, serviceWorkers: 'block', - }); + }; + const context = await browser.newContext(contextOptions); const environmentErrors = []; const blocked = []; - await context.route('**/*', (route) => { - const url = route.request().url(); - const protocol = url.slice(0, url.indexOf(':')); - if (['data', 'blob', 'about'].includes(protocol) || allowed.has(new URL(url).origin)) { - return route.continue(); - } - blocked.push(url); - return route.abort('blockedbyclient'); - }); + await guardOrigins(context, allowed, blocked); await context.addInitScript(VIEW_TRANSITION_PROBE); const page = await context.newPage(); page.on('pageerror', (error) => environmentErrors.push({ kind: 'page-error', message: String(error?.message ?? error) })); page.on('console', (message) => { if (message.type() === 'error') environmentErrors.push({ kind: 'console-error', message: message.text() }); }); + const responseLog = []; + page.on('response', (response) => { + const type = response.request().resourceType(); + if (['image', 'script', 'stylesheet', 'font', 'media'].includes(type)) { + responseLog.push({ url: response.url(), status: response.status(), type }); + } + }); + const failedLog = []; page.on('requestfailed', (request) => { const failure = request.failure()?.errorText ?? 'failed'; if (failure.includes('ERR_BLOCKED_BY_CLIENT')) return; + if (request.resourceType() === 'image') failedLog.push({ url: request.url(), failure }); environmentErrors.push({ kind: 'request-failed', message: `${request.url()}: ${failure}` }); }); @@ -530,6 +535,8 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { const record = { id: screen.id, status: 'completed', checks: [] }; run.screens.push(record); const errorsAtStart = environmentErrors.length; + const responsesAtStart = responseLog.length; + const failedAtStart = failedLog.length; const notMeasured = (status, reason) => { record.checks = screen.checks.map((check, index) => ({ index, kind: check.kind, label: check.label, status, reason, measured: {} })); }; @@ -622,16 +629,56 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { record.checks.push({ ...item, ...evaluateHidden({ subjectCount, visible }) }); continue; } - if (check.kind === 'no-low-contrast-text' || check.kind === 'image-loaded') { + if (check.kind === 'image-loaded') { + const scope = check.subject ? locate(page, check.subject) : page.locator('body'); + if ((await scope.count()) !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: 'subject must match exactly one element', measured: {} }); + continue; + } + const requests = { + responses: responseLog.slice(responsesAtStart).filter((entry) => entry.type === 'image'), + failed: failedLog.slice(failedAtStart), + scoped: Boolean(check.subject), + }; + record.checks.push({ ...item, ...evaluateImagesLoaded(await observeImages(scope), requests) }); + continue; + } + if (check.kind === 'no-low-contrast-text') { if (check.subject && await locate(page, check.subject).count() !== 1) { record.checks.push({ ...item, status: 'inconclusive', reason: 'subject must match exactly one element', measured: {} }); continue; } const observed = await observeCompleteness(page, {}, viewport.width, check.subject); - const result = check.kind === 'image-loaded' ? evaluateImageLoaded(observed) : evaluateNoLowContrastText({}, observed); + const result = evaluateNoLowContrastText({}, observed); record.checks.push({ ...item, ...result }); continue; } + if (check.kind === 'native-appearance') { + const scope = check.subject ? locate(page, check.subject) : page.locator('body'); + if ((await scope.count()) !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: 'subject must match exactly one element', measured: {} }); + continue; + } + record.checks.push({ ...item, ...evaluateNativeAppearance(await observeNativeAppearance(scope)) }); + continue; + } + if (check.kind === 'stale-resources') { + const responses = responseLog.slice(responsesAtStart); + const first = await snapshotLoad(page); + let second = null; + let reloadError = null; + try { + second = await freshLoad({ + browser, contextOptions, url: page.url(), timeout: stepTimeout, + install: (fresh) => guardOrigins(fresh, allowed, blocked), + ready: (fresh) => waitReady(fresh, plan, stepTimeout), + }); + } catch (error) { + reloadError = String(error?.message ?? error).split('\n')[0]; + } + record.checks.push({ ...item, ...evaluateStaleResources({ responses, first, second, reloadError }) }); + continue; + } if (check.kind === 'rendered-completeness') { record.checks.push({ ...item, ...evaluateRenderedCompleteness(check.completeness, await observeCompleteness(page, check.completeness, viewport.width)) }); continue; diff --git a/harness/runtime-audit/test/appearance.test.mjs b/harness/runtime-audit/test/appearance.test.mjs new file mode 100644 index 00000000..fda13a06 --- /dev/null +++ b/harness/runtime-audit/test/appearance.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + diffLoads, evaluateImagesLoaded, evaluateNativeAppearance, evaluateStaleResources, observeImages, observeNativeAppearance, +} from '../resource-checks.mjs'; + +const control = (over) => ({ tag: 'button', name: 'Go', appearance: 'none', backgroundColor: 'rgb(0, 0, 255)', authorBackground: true, authorBorder: true, ...over }); + +test('native appearance fails buttons that keep the browser face', () => { + assert.equal(evaluateNativeAppearance({ controls: [control()] }).status, 'pass'); + const result = evaluateNativeAppearance({ controls: [control({ appearance: 'button', authorBackground: false })] }); + assert.equal(result.status, 'fail'); + assert.match(result.reason, /'); + const observed = await observeNativeAppearance(page.locator('body')); + assert.deepEqual(evaluateNativeAppearance(observed).measured.native.map((item) => item.name), ['B']); + await page.setContent('
'); + const images = await observeImages(page.locator('body')); + assert.equal(evaluateImagesLoaded(images).status, 'fail'); + } finally { await browser.close(); } +}); diff --git a/plugin/dist/ui.html b/plugin/dist/ui.html index f25c4264..b887a5cb 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/rspack.config.js b/plugin/rspack.config.js index 1c79c880..d87cd8c9 100644 --- a/plugin/rspack.config.js +++ b/plugin/rspack.config.js @@ -4,9 +4,12 @@ import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' +import { readFileSync } from 'node:fs' +import { DefinePlugin } from '@rspack/core' import HtmlWebpackPlugin from 'html-webpack-plugin' const here = dirname(fileURLToPath(import.meta.url)) +const pluginVersion = JSON.parse(readFileSync(resolve(here, 'package.json'), 'utf8')).version /** @type {import('@rspack/core').Configuration[]} */ export default [ @@ -58,6 +61,7 @@ export default [ ], }, plugins: [ + new DefinePlugin({ __PLUGIN_VERSION__: JSON.stringify(pluginVersion) }), new HtmlWebpackPlugin({ template: resolve(here, 'src/ui.html'), filename: 'ui.html', diff --git a/plugin/src/ui.html b/plugin/src/ui.html index 98b07fa1..451daeba 100644 --- a/plugin/src/ui.html +++ b/plugin/src/ui.html @@ -62,6 +62,7 @@

Devup Bridge

시작하는 중…
+

읽기 전용입니다. 문서를 바꾸지 않고, 데이터는 같은 기기의 devup-mcp 로만 diff --git a/plugin/src/ui.ts b/plugin/src/ui.ts index d1a509ea..cb0ccc6f 100644 --- a/plugin/src/ui.ts +++ b/plugin/src/ui.ts @@ -41,7 +41,15 @@ interface ResultMessage { error?: string } +declare const __PLUGIN_VERSION__: string + +interface ServerInfo { + version: string + buildId: string | null +} + const RETRY_MS = 2000 +let serverInfo: ServerInfo | null = null /** 이 창이 화면에 보이는지. Figma 가 가려지거나 최소화되면 거짓이 된다. */ const inView = () => document.visibilityState === 'visible' @@ -67,10 +75,14 @@ let served = 0 const dot = document.getElementById('dot') as HTMLElement const line = document.getElementById('line') as HTMLElement const detail = document.getElementById('detail') as HTMLElement +const versions = document.getElementById('versions') as HTMLElement function render(state: 'on' | 'off', text: string) { dot.className = `dot ${state}` line.textContent = text + versions.textContent = `플러그인 ${__PLUGIN_VERSION__} · ${ + serverInfo ? `devup-mcp ${serverInfo.version}` : 'devup-mcp 버전 미확인' + }` detail.textContent = bridgeStatus ? `${bridgeStatus.fileName} · 처리 ${served}건` : '파일 정보를 읽는 중…' @@ -100,6 +112,7 @@ function connect() { JSON.stringify({ kind: 'hello', sessionId: SESSION_ID, + pluginVersion: __PLUGIN_VERSION__, fileKey: bridgeStatus?.fileKey ?? null, fileName: bridgeStatus?.fileName ?? '', currentPage: bridgeStatus?.currentPage ?? null, @@ -122,12 +135,19 @@ function connect() { } catch { return } + const info = job as { kind?: string; version?: string; buildId?: string | null } + if (info?.kind === 'server-info') { + serverInfo = { version: String(info.version ?? ''), buildId: info.buildId ?? null } + render('on', 'devup-mcp 에 연결됨') + return + } // 작업은 그대로 메인 스레드로 넘긴다. UI 는 내용을 해석하지 않는다. parent.postMessage({ pluginMessage: job }, '*') } ws.onclose = () => { socket = null + serverInfo = null render('off', 'devup-mcp 연결 대기 중…') setTimeout(connect, RETRY_MS) } From fb0b798e5fb51b48a2cd13331516091e0823ab0d Mon Sep 17 00:00:00 2001 From: "devfive-relay-c70423[bot]" <328770141+devfive-relay-c70423[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:32:50 +0900 Subject: [PATCH 11/11] Verify generated pages with real Chromium rendering and pixel comparisons (#102) * test: require real-browser render verification for generated pages * docs: record CI workflow permission limit --------- Co-authored-by: leader --- .../changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json | 7 + .../examples/render_fixture.rs | 23 + .../skills/devup-implementation-plan/SKILL.md | 23 +- .../src/server/skills/devup/SKILL.md | 11 +- .../devup-mcp/src/server/skills/manifest.json | 8 +- harness/runtime-audit/README.md | 79 ++ .../runtime-audit/fixtures/render-hero.png | Bin 0 -> 877 bytes .../fixtures/render-reference.png | Bin 0 -> 1083 bytes .../fixtures/render-snapshot.json | 28 + harness/runtime-audit/package-lock.json | 933 ++++++++++++++++++ harness/runtime-audit/package.json | 11 +- .../runtime-audit/test/real-render.test.mjs | 107 ++ harness/runtime-audit/verify-render.mjs | 97 ++ 13 files changed, 1316 insertions(+), 11 deletions(-) create mode 100644 .changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json create mode 100644 crates/devup-mcp-devup-ui/examples/render_fixture.rs create mode 100644 harness/runtime-audit/README.md create mode 100644 harness/runtime-audit/fixtures/render-hero.png create mode 100644 harness/runtime-audit/fixtures/render-reference.png create mode 100644 harness/runtime-audit/fixtures/render-snapshot.json create mode 100644 harness/runtime-audit/test/real-render.test.mjs create mode 100644 harness/runtime-audit/verify-render.mjs diff --git a/.changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json b/.changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json new file mode 100644 index 00000000..ce1d882c --- /dev/null +++ b/.changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Require real Chromium rendering and pinned Figma pixel comparisons before page completion; cover generated DevupUI pages, missing backgrounds, wrong colors and failed assets in the end-to-end harness.", + "date": "2026-10-19T14:22:49.794557600Z" +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/examples/render_fixture.rs b/crates/devup-mcp-devup-ui/examples/render_fixture.rs new file mode 100644 index 00000000..ae6f30c6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/examples/render_fixture.rs @@ -0,0 +1,23 @@ +//! Replay a snapshot through production codegen for the browser regression test. +use std::{error::Error, path::Path}; + +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, generate_component, node_ids::annotate_node_ids, +}; +use devup_mcp_figma::Snapshot; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + if args.len() != 2 { + return Err("usage: render_fixture ".into()); + } + let snapshot: Snapshot = serde_json::from_slice(&std::fs::read(&args[0])?)?; + let options = CodegenOptions { + component_name: Some("RenderFixture".into()), + ..Default::default() + }; + let mut output = generate_component(&snapshot, &snapshot.roots[0], &options)?; + annotate_node_ids(&mut output, &snapshot.nodes.keys().cloned().collect()); + std::fs::write(Path::new(&args[1]), output.tsx)?; + Ok(()) +} diff --git a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md index b4597c39..255599ef 100644 --- a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md @@ -378,6 +378,13 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. with the app running. If the runner cannot be installed, call status with `auditPlanPath` and `toolingUnavailable: ""`: that URL's audit is inconclusive and the rest of the work goes on. + This real-browser step is required before claiming any page is done: build + and serve the actual generated app, not a hand-written stand-in. Static + checks and TSX snapshots cannot detect missing background images, wrong + colors or assets that never load. Run `image-loaded` and `page-health`, read + the browser report, and retain screenshots with their viewport and reference + identities. A report's `completed` means execution finished; a capture check's + `pass` means only that pixels were captured. Neither is a visual verdict. 9. **Read the status, fix, re-run.** `devup_screen_plan` `action: "status"` with `auditPlanPath` and `auditReportPath`. Run every `visualComparisons` call (`devup_visual_compare`, each with its `threshold` and `diffPath`) @@ -421,10 +428,15 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. with `assume` (`about`: the decision id, `decision`: the recommended option). A destructive one (deleting, overwriting, reaching outside the project) stays unbuilt and is listed in the report. -11. **Done means zero fails.** A URL is done when every check and comparison - passes at its drawn widths and health and overflow pass at every width - - or when each remaining finding is a converter bug with its repro filed, or - listed as unresolved with its evidence. Before reporting done, also: +11. **Done means zero fails.** A URL is done only after its actual Chromium + screenshots have been compared with independently exported Figma originals, + every check and comparison passes at its drawn widths, and health and + overflow pass at every width. Inspect the page/section/asset diffs for + background images, colors and loaded assets; feed every result back to + status. Missing references, unavailable browser tooling, not-run comparisons, + converter bugs and unresolved findings remain incomplete with evidence. + Preserve screenshots, reports, Figma reference identities and diffs. + Before reporting done, also: - every confirmed URL is implemented; - the build passes: `bun run build` (`vinext build` for every app); @@ -453,7 +465,8 @@ Hard rules, after confirm: - Visually verify every screen, section and asset at every drawn width. - Never stop to wait; reorder the work. -- Done = zero fails, or listed as unresolved with evidence. +- Done = a real browser render and zero failed or missing comparisons; + unresolved findings are reported as incomplete with evidence. - Layout is exact; a mismatch is drift or a converter bug, never tuned. ## What the questions mean diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index a363690f..349dc650 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -179,7 +179,16 @@ the full rules; these are the steps. 11. **Never stop to wait.** A decision mid-run: `action: "defer"` with the recommended option, keep working, take it with `assume` at the end; a destructive one stays unbuilt. -12. **Done = zero fails.** Every check and comparison passes at the drawn +12. **Done = zero fails.** A real Chromium render is mandatory before claiming + any page is done. Build and serve the generated app, execute the Playwright + runner, read its report and compare the actual screenshots with independent + Figma references. Static checks, TSX snapshots, a generated audit plan and a + capture check's `pass` do not prove visual correctness. Verify background + images, colors and loaded assets; inspect page/section/asset diffs and feed + every visual result back to status. Missing references, unavailable browser + tooling, failed assets and not-run comparisons mean incomplete. Preserve the + screenshots, reference identity, report and diffs in the final evidence. + Every check and comparison passes at the drawn widths and health and overflow pass at every required width. Report filed converter bugs and unresolved findings with evidence as incomplete. Also: every URL implemented, the production build free of `data-figma-node-id` (the diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 0d525f81..fa42f605 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -71,8 +71,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 35206, - "sha256": "a60e55d51cc97c563419dcc50b449c8d83c2a8324470cfc6ade304a84b85f541" + "bytes": 36174, + "sha256": "3278b142e6780fb99413185b9c01ebabd1293fea1a924fc09aeae5b818c07f0f" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", @@ -89,8 +89,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 13682, - "sha256": "8e9f69df9be904af473844f4db606f2b2f35fe0318da07f142d08a9163a9e783" + "bytes": 14375, + "sha256": "c866ca964eb42cc01a4477a6922b6187abe3992cee6228476501373c1486a092" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", diff --git a/harness/runtime-audit/README.md b/harness/runtime-audit/README.md new file mode 100644 index 00000000..22bf7a50 --- /dev/null +++ b/harness/runtime-audit/README.md @@ -0,0 +1,79 @@ +# Real-render verification + +Static validation and a successful screenshot capture do not establish visual +correctness. With the generated app running, execute its runtime audit, compare +the actual Chromium screenshots against the independently acquired Figma PNGs, +and inspect the diffs before calling the page done. + +The existing `run.mjs` + `devup_runtime_audit ingest` + `devup_visual_compare` +workflow supports page, section and asset comparisons. A capture check's `pass` +means **captured**, not **visually matched**. Feed every comparison result back +to screen-plan status; missing, failed or inconclusive cells block completion. + +For a command-line page gate (including CI): + +```sh +npm ci +npx playwright install chromium +cargo build -p devup-mcp-visual +node verify-render.mjs --plan plan.json --references references.json --out out/page --visual-bin ../../target/debug/devup-mcp-visual +``` + +On Windows append `.exe` to the comparator path. With a custom Cargo target +directory use the binary under `cargo metadata`'s `target_directory`. + +Use an audit plan containing the drawn viewports at deviceScaleFactor 1, with +`page-health`, `image-loaded` and the relevant geometry checks. Use the normal +runtime audit separately for widths without Figma originals. Each planned +screen/viewport must have exactly one entry in `references.json`: + +```json +{ + "references": [{ + "screen": "home", + "width": 1440, + "height": 900, + "path": "figma/home-1440.png", + "sha256": "", + "threshold": 0.03 + }] +} +``` + +Paths are relative to the manifest. Export the original frame at scale 1 and +the drawn dimensions, without resizing it. Never create a reference from the +generated page, replace an original to pass a test, or raise thresholds to hide +wrong backgrounds, colors or assets. Pin the original hash and retain its Figma +file/node/version provenance alongside the manifest. + +`verify-render.mjs` launches the runtime-audit Chromium runner and compares every +page screenshot with `devup-mcp-visual`. It writes the runtime report, screenshots, +`real-render.json` and `visual-diff-*.png`. Exit codes: 0 means runtime and pixels +passed; 1 means a runtime or visual failure; 2 means tooling, input or reference +failure. Missing/duplicate references, changed reference bytes, missing captures, +browser errors and invalid image dimensions cannot pass. Do not infer success +from `report.status: completed`, which describes execution, not correctness. + +## Offline regression + +`npm test` runs all tests, including `test/real-render.test.mjs`. This needs Rust, +Node and Chromium; it does not silently skip when they are missing. The test +replays `fixtures/render-snapshot.json` through production Rust codegen, builds +the result with the real DevupUI plugin, serves the production app, and compares +the Chromium capture through the production Rust pixel comparator. The correct +page must match exactly. Removing its background, changing a card color or +requesting an unavailable asset must fail while the geometry still passes. + +The small checked-in PNG oracle is **synthetic**, not a live Figma acquisition: +its independent pixel construction represents the snapshot's 320x240 frame, +with `#123456` above y=120, `#abcdef` below, and a red 80x60 card at (32,32). +`render-hero.png` is the same background without the card. The oracle is never +rendered from the generated code or updated by the test. This deterministic +offline contract proves the gate detects rendering failures; verification of an +actual Figma page requires that page's exported original as described above. + +Set `DEVUP_KEEP_RENDER_EVIDENCE=1` to retain regression artifacts under `out/` +for diagnosis or upload by a CI caller. Run `npm ci`, +`npx playwright install --with-deps chromium`, and `npm test` to use this suite +in Linux CI. The repository workflow is not wired to this browser suite yet: +the GitHub proxy rejected pushes containing that workflow-file modification. diff --git a/harness/runtime-audit/fixtures/render-hero.png b/harness/runtime-audit/fixtures/render-hero.png new file mode 100644 index 0000000000000000000000000000000000000000..d0238a82801cf293fba877e70aa8fef54922dbdc GIT binary patch literal 877 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoKX9-C$wJ+|*$fQKCY~;iAr*7pUNK~BFc3I! zAj^R}%Aiwhi^4AI(+rI|%q*LD1Wt@{1|STUWifj+9sE4`$Ey%vHe&E}^>bP0l+XkKq%83i literal 0 HcmV?d00001 diff --git a/harness/runtime-audit/fixtures/render-reference.png b/harness/runtime-audit/fixtures/render-reference.png new file mode 100644 index 0000000000000000000000000000000000000000..cd99c3435cec76d9fc33eef46517e674e006124d GIT binary patch literal 1083 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoKX9-C$wJ+|*$fOUOr9=|Ar*7pUUBqgU=(0E zc)3ArOM;MYhT|c{rsHXszOMMMGWpBrk3YXMiP&CeW?09eFhRA!VHyKdBojvpCk;5) zDjDuSe<;81e{(96#k*hj5o!&OM>zu#24!*%4cfGd<+8l;P=U=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype-extra": { + "version": "0.1.37", + "resolved": "https://registry.npmjs.org/csstype-extra/-/csstype-extra-0.1.37.tgz", + "integrity": "sha512-zrYj38Dm1DAMWM9lW1tto2oGewGfCqRNN80HEV5TVnkTTo1aSYPaiuGz8SXtFwd7534PSGLZ0zxW8+3X74Mcdg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/playwright": { @@ -38,6 +758,219 @@ "engines": { "node": ">=20" } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } } } } diff --git a/harness/runtime-audit/package.json b/harness/runtime-audit/package.json index 4ca404f9..609d93fb 100644 --- a/harness/runtime-audit/package.json +++ b/harness/runtime-audit/package.json @@ -7,10 +7,19 @@ "devup-runtime-audit": "run.mjs" }, "scripts": { - "test": "node --test test/checks.test.mjs test/browser.test.mjs test/layout.test.mjs test/appearance.test.mjs", + "test": "node --test test/*.test.mjs", + "verify:render": "node verify-render.mjs", "test:unit": "node --test test/checks.test.mjs" }, "dependencies": { "playwright": "1.63.0" + }, + "devDependencies": { + "@devup-ui/react": "^1.0.41", + "@devup-ui/reset-css": "^1.0.28", + "@devup-ui/vite-plugin": "^1.0.69", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "vite": "^8.3.0" } } diff --git a/harness/runtime-audit/test/real-render.test.mjs b/harness/runtime-audit/test/real-render.test.mjs new file mode 100644 index 00000000..2f94bfba --- /dev/null +++ b/harness/runtime-audit/test/real-render.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { execFile, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { build, preview } from 'vite'; +import { DevupUI } from '@devup-ui/vite-plugin'; +import { verifyRender } from '../verify-render.mjs'; + +const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repo = resolve(harness, '../..'); +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const execute = promisify(execFile); + +async function cli(args) { + // Keep the Vite preview server's event loop free while Chromium visits it. + try { + return { status: 0, ...await execute(process.execPath, [join(harness, 'verify-render.mjs'), ...args], { windowsHide: true, timeout: 60_000 }) }; + } catch (error) { + return { status: error.code, stdout: error.stdout, stderr: error.stderr }; + } +} + +function cargo(args) { + const result = spawnSync('cargo', args, { cwd: repo, encoding: 'utf8', windowsHide: true, timeout: 300_000 }); + assert.equal(result.status, 0, `${args.join(' ')}\n${result.error ?? ''}\n${result.stderr}`); + return result.stdout; +} + +test('production codegen renders in Chromium; geometry alone cannot pass broken pixels or assets', { timeout: 360_000 }, async () => { + const outRoot = join(harness, 'out'); + await mkdir(outRoot, { recursive: true }); + const dir = await mkdtemp(join(outRoot, 'real-render-')); + let server; + try { + cargo(['run', '--quiet', '-p', 'devup-mcp-devup-ui', '--example', 'render_fixture', '--', + join(harness, 'fixtures/render-snapshot.json'), join(dir, 'Generated.tsx')]); + cargo(['build', '--quiet', '-p', 'devup-mcp-visual']); + const target = JSON.parse(cargo(['metadata', '--format-version', '1', '--no-deps'])).target_directory; + const visualBin = join(target, 'debug', process.platform === 'win32' ? 'devup-mcp-visual.exe' : 'devup-mcp-visual'); + await mkdir(join(dir, 'public/images'), { recursive: true }); + const hero = await readFile(join(harness, 'fixtures/render-hero.png')); + await writeFile(join(dir, 'public/images/Hero.png'), hero); + await writeFile(join(dir, 'devup.json'), JSON.stringify({ theme: {} })); + await writeFile(join(dir, 'index.html'), '

'); + await writeFile(join(dir, 'main.tsx'), `import '@devup-ui/reset-css'; +import { createRoot } from 'react-dom/client'; +import { flushSync } from 'react-dom'; +import { RenderFixture } from './Generated'; +flushSync(() => createRoot(document.getElementById('root')!).render()); +const mode = new URLSearchParams(location.search).get('mode'); +const style = document.createElement('style'); +style.textContent = mode === 'missing-background' ? '[data-figma-node-id="1:1"] img{visibility:hidden!important}' + : mode === 'wrong-color' ? '[data-figma-node-id="1:2"]{background:#00ff00!important}' + : ''; +if (mode === 'unloaded-asset') document.querySelector('img')!.src = '/missing.png'; +document.head.append(style);`); + const config = { root: dir, configFile: false, logLevel: 'error', plugins: [DevupUI()], build: { outDir: 'dist' }, preview: { host: '127.0.0.1', port: 0 } }; + await build(config); + server = await preview(config); + const baseUrl = `http://127.0.0.1:${server.httpServer.address().port}`; + const reference = join(harness, 'fixtures/render-reference.png'); + const references = join(dir, 'references.json'); + await writeFile(references, JSON.stringify({ references: [{ screen: 'hero', width: 320, height: 240, + path: reference, sha256: sha256(await readFile(reference)), threshold: 0.005 }] })); + for (const mode of ['correct', 'missing-background', 'wrong-color', 'unloaded-asset']) { + const plan = join(dir, `${mode}.json`); + await writeFile(plan, JSON.stringify({ schemaVersion: 1, kind: 'devup-runtime-audit-plan', planId: mode, plan: { + baseUrl, allowedOrigins: [baseUrl], viewports: [{ width: 320, height: 240, deviceScaleFactor: 1 }], + readiness: { selector: '[data-figma-node-id="1:2"]' }, + screens: [{ id: 'hero', routePath: `/?mode=${mode}`, checks: [ + { kind: 'bounds', subject: { css: '[data-figma-node-id="1:1"]' }, container: { css: 'body' }, expected: { x: 0, y: 0, width: 320, height: 240 } }, + { kind: 'image-loaded' }, { kind: 'page-health' }, + ] }], + } })); + const output = join(dir, mode); + const command = await cli(['--plan', plan, '--references', references, '--out', output, '--visual-bin', visualBin]); + assert.equal(command.status, mode === 'correct' ? 0 : 1, `${mode}: ${command.stderr}\n${command.stdout}`); + const result = JSON.parse(await readFile(join(output, 'real-render.json'), 'utf8')); + const runtime = JSON.parse(await readFile(join(output, 'report.json'), 'utf8')); + assert.equal(runtime.runs[0].screens[0].checks[0].status, 'pass', JSON.stringify(runtime)); + assert.equal(result.verdict, mode === 'correct' ? 'pass' : 'fail', JSON.stringify(result)); + assert.equal(result.comparisons[0].status, mode === 'correct' ? 'exact' : 'mismatch', JSON.stringify(result)); + assert.ok((await readFile(join(output, 'visual-diff-0.png'))).length > 0); + const images = runtime.runs[0].screens[0].checks[1]; + assert.equal(images.status, mode === 'unloaded-asset' ? 'fail' : 'pass'); + } + const plan = join(dir, 'correct.json'); + const valid = JSON.parse(await readFile(references, 'utf8')); + await writeFile(references, JSON.stringify({ references: [valid.references[0], valid.references[0]] })); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'duplicate-reference'), visualBin }), /one Figma reference/); + valid.references[0].sha256 = '0'.repeat(64); + await writeFile(references, JSON.stringify(valid)); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'changed-reference'), visualBin }), /hash differs/); + await writeFile(references, '{"references":[]}'); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'missing-reference'), visualBin }), /manifest is empty/); + const invalid = await cli(['--plan', plan, '--references', references, '--out', join(dir, 'invalid'), '--visual-bin', visualBin]); + assert.equal(invalid.status, 2, invalid.stderr); + } finally { + if (server) await new Promise((done) => server.httpServer.close(done)); + // Preserve browser evidence on request for local diagnosis and CI uploads. + if (!process.env.DEVUP_KEEP_RENDER_EVIDENCE) await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/harness/runtime-audit/verify-render.mjs b/harness/runtime-audit/verify-render.mjs new file mode 100644 index 00000000..c33cf7d4 --- /dev/null +++ b/harness/runtime-audit/verify-render.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// A capture is evidence, not a visual verdict. Run Chromium, then compare every +// planned screen/viewport against an independently acquired, pinned Figma PNG. +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { runPlan } from './run.mjs'; + +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +function compare(binary, args) { + return new Promise((resolveResult, reject) => { + const child = spawn(binary, args, { windowsHide: true }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => { stdout += data; }); + child.stderr.on('data', (data) => { stderr += data; }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0 && code !== 1) return reject(new Error(`visual comparator exited ${code}: ${stderr}`)); + try { resolveResult({ exitCode: code, ...JSON.parse(stdout) }); } catch (error) { reject(error); } + }); + }); +} + +export async function verifyRender({ plan, references, out, visualBin, maxMinutes = 10 }) { + const document = JSON.parse(await readFile(plan, 'utf8')); + const manifest = JSON.parse(await readFile(references, 'utf8')); + const entries = manifest.references; + if (!Array.isArray(entries) || !entries.length) throw new Error('Figma reference manifest is empty'); + const expected = document.plan.screens.flatMap((screen) => document.plan.viewports.map((viewport) => ({ screen: screen.id, viewport }))); + if (entries.length !== expected.length) throw new Error('Require one Figma reference per planned screen and viewport'); + // Validate and pin all originals before opening the browser. Never derive an + // expected image from the generated page or silently accept missing widths. + const originals = []; + for (const screen of document.plan.screens) { + for (const kind of ['page-health', 'image-loaded']) { + if (!screen.checks.some((check) => check.kind === kind && !check.widths?.length)) { + throw new Error(`Screen ${screen.id} requires ${kind} at every viewport`); + } + } + } + for (const { screen, viewport } of expected) { + const matches = entries.filter((entry) => entry.screen === screen && entry.width === viewport.width && entry.height === viewport.height); + if (matches.length !== 1) throw new Error(`Require exactly one Figma reference for ${screen} ${viewport.width}x${viewport.height}`); + const entry = matches[0]; + if (viewport.deviceScaleFactor != null && viewport.deviceScaleFactor !== 1) throw new Error('Figma comparison requires deviceScaleFactor 1'); + if (!Number.isFinite(entry.threshold) || entry.threshold < 0 || entry.threshold >= 1) throw new Error('Reference threshold must be in [0, 1)'); + const path = resolve(dirname(references), entry.path); + if (!/^[a-f0-9]{64}$/.test(entry.sha256) || hash(await readFile(path)) !== entry.sha256) throw new Error(`Figma reference hash differs: ${path}`); + originals.push({ ...entry, path }); + } + const runtime = await runPlan({ plan, out, maxMinutes }); + const comparisons = []; + for (const [index, original] of originals.entries()) { + const run = runtime.runs.find((item) => item.viewport.width === original.width && item.viewport.height === original.height); + const screen = run?.screens.find((item) => item.id === original.screen); + if (!screen?.screenshot) { + comparisons.push({ screen: original.screen, width: original.width, status: 'not-run' }); + continue; + } + const actual = resolve(out, screen.screenshot.path); + if (hash(await readFile(actual)) !== screen.screenshot.sha256) throw new Error(`Screenshot hash differs: ${actual}`); + const result = await compare(visualBin, ['compare', '--reference', original.path, '--actual', actual, + '--diff', resolve(out, `visual-diff-${index}.png`), '--max-changed-ratio', String(original.threshold)]); + comparisons.push({ screen: original.screen, width: original.width, referenceSha256: original.sha256, actual, ...result }); + } + const passed = runtime.status === 'completed' + && runtime.runs.length === document.plan.viewports.length + && runtime.runs.every((run) => !run.environmentErrors.length && run.screens.length === document.plan.screens.length + && run.screens.every((screen) => screen.status === 'completed' && screen.checks.every((check) => check.status === 'pass'))) + && comparisons.every((item) => item.exitCode === 0 && ['exact', 'within-threshold'].includes(item.status)); + const result = { verdict: passed ? 'pass' : 'fail', runtimeReport: join(out, 'report.json'), comparisons }; + await writeFile(join(out, 'real-render.json'), `${JSON.stringify(result, null, 2)}\n`); + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const options = {}; + const names = { '--plan': 'plan', '--references': 'references', '--out': 'out', '--visual-bin': 'visualBin' }; + for (let index = 2; index < process.argv.length; index += 2) { + const name = names[process.argv[index]]; + if (!name || !process.argv[index + 1]) throw new Error('usage: node verify-render.mjs --plan --references --out --visual-bin '); + options[name] = resolve(process.argv[index + 1]); + } + if (Object.keys(options).length !== 4) throw new Error('plan, references, out and visual-bin are required'); + const result = await verifyRender(options); + console.log(`${result.verdict}: ${result.comparisons.length} real-browser comparisons`); + process.exitCode = result.verdict === 'pass' ? 0 : 1; + } catch (error) { + console.error(String(error?.message ?? error)); + process.exitCode = 2; + } +}