From 06241abe9b53f4deeeaea114ac32d7d09bdb79cb Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 20:37:08 +0900 Subject: [PATCH 01/24] Keep multi-colour icons as instead of a single-colour mask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An icon is emitted as a Box with maskImage + bg only when every paint in it is one flat colour. The sameColor analysis let two kinds of paint slip through: - A visible non-SOLID paint (gradient, image, unreadable solid) counted as Null, "no colour", so {gradient vector, solid, solid} merged to the solid colour and became a mask. A mask keeps only the shape, so the gradient face and the outline flattened into one purple blob (prime /seo-geo "기대 효과" icons). Such a paint now counts as False. - mergeSameColor skipped a Null before the first colour but spoiled the colour with a Null after it, so layer order decided the answer. An unpainted subtree is now neutral wherever it falls. The fixture-compat viewport renderer masked whenever all variants were assets and took the first grandchild's first fill as the colour; it now masks only when every visible paint under the variants is the same SOLID. No existing expectation changed: none of the fixtures encoded the bug. New regression tests cover gradient + solid, gradient stroke, gradient on top, image paint, two solids, all-same solid, and an unpainted vector. Co-Authored-By: Claude Opus 5.5 --- .../devup-mcp-devup-ui/src/codegen/compat.rs | 59 +++++-- .../devup-mcp-devup-ui/src/codegen/style.rs | 57 ++++--- .../tests/multicolor_icon_mask.rs | 159 ++++++++++++++++++ .../devup-mcp-devup-ui/tests/plugin_compat.rs | 44 +++++ 4 files changed, 278 insertions(+), 41 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/multicolor_icon_mask.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/compat.rs b/crates/devup-mcp-devup-ui/src/codegen/compat.rs index 09d961a1..d9de675a 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/compat.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/compat.rs @@ -330,9 +330,14 @@ pub fn render_viewport_component(input: &Value) -> Option { props.push((key.as_str(), prop, boolean)); } let (_, prop, boolean) = props.first()?; - let asset_components = children + // A mask fills the shape with one colour, so only icons painted in one + // flat colour throughout may become one; anything else keeps its pixels. + let mask_color = children .iter() - .all(|child| child.get("isAsset").and_then(Value::as_bool) == Some(true)); + .all(|child| child.get("isAsset").and_then(Value::as_bool) == Some(true)) + .then(|| single_solid_color(children)) + .flatten(); + let asset_components = mask_color.is_some(); let component = if asset_components { "Box" } else { "Image" }; let mut variants = Vec::new(); for child in children { @@ -356,18 +361,9 @@ pub fn render_viewport_component(input: &Value) -> Option { } else { prop.clone() }; - let extra = if asset_components { - let paint = children - .first()? - .get("children")? - .as_array()? - .first()? - .get("fills")? - .as_array()? - .first()?; + let extra = if let Some(color) = mask_color { format!( - " bg=\"{}\"\n maskImage={{{{\n{variant_lines}\n }}[{index}]}}\n maskPos=\"center\"\n maskRepeat=\"no-repeat\"\n maskSize=\"contain\"", - color_hex(paint)? + " bg=\"{color}\"\n maskImage={{{{\n{variant_lines}\n }}[{index}]}}\n maskPos=\"center\"\n maskRepeat=\"no-repeat\"\n maskSize=\"contain\"" ) } else { format!(" src={{{{\n{variant_lines}\n }}[{index}]}}") @@ -563,6 +559,43 @@ fn normalize_prop_name(value: &str) -> String { /// Takes the whole paint because Figma splits a translucent solid across /// `color.a` and the paint's own `opacity`; the effective alpha is the product. /// Reading `color` alone drops `opacity` and renders the fill opaque. +/// The one flat colour every visible fill and stroke under `nodes` shares, +/// or `None` when there is no paint, a paint is not `SOLID`, or two differ. +fn single_solid_color(nodes: &[Value]) -> Option { + let mut color = None; + let mut pending = nodes.iter().collect::>(); + while let Some(node) = pending.pop() { + for field in ["fills", "strokes"] { + for paint in node + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if paint.get("visible").and_then(Value::as_bool) == Some(false) { + continue; + } + if paint.get("type").and_then(Value::as_str) != Some("SOLID") { + return None; + } + let hex = color_hex(paint)?; + match &color { + None => color = Some(hex), + Some(current) if *current != hex => return None, + Some(_) => {} + } + } + } + pending.extend( + node.get("children") + .and_then(Value::as_array) + .into_iter() + .flatten(), + ); + } + color +} + fn color_hex(paint: &Value) -> Option { let color = paint.get("color")?; let channel = diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index 5202bd62..ad4abd80 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -202,6 +202,11 @@ fn svg_asset_kind(snapshot: &Snapshot, node: &RawNode, nested: bool) -> AssetKin /// on nothing, `False` one whose paints disagree, and only a `Color` makes a /// mask. The two non-answers are not the same: a `Null` child leaves a /// running colour alone, a `False` one spoils it. +/// +/// A paint that is not one flat colour (a gradient, an image, a solid with +/// no readable colour) is `False`, not `Null`. A mask keeps only the shape +/// and fills it with one colour, so letting such a paint stay neutral merged +/// a gradient face and a solid outline into one solid blob. #[derive(Debug, Clone, PartialEq, Eq)] enum SameColor { Null, @@ -209,11 +214,15 @@ enum SameColor { Color(String), } -/// The plugin's `mergeSameColor`. +/// The plugin's `mergeSameColor`, except that a `Null` (a subtree with no +/// paint) is neutral wherever it falls. Taken literally, a `Null` after the +/// first colour spoiled it while one before was skipped, so the order of the +/// layers decided whether an icon became a mask. fn merge_same_color(current: SameColor, next: SameColor) -> SameColor { match (current, next) { (_, SameColor::False) => SameColor::False, (SameColor::Null, next) => next, + (current, SameColor::Null) => current, (current, next) if current == next => current, _ => SameColor::False, } @@ -223,20 +232,27 @@ fn merge_same_color(current: SameColor, next: SameColor) -> SameColor { enum OwnColor { /// No visible paint at all. None, - /// A paint that is not a flat colour. - Null, - /// Two flat colours that differ. + /// Two flat colours that differ, or a paint that is not a flat colour. False, Color(String), } +impl OwnColor { + fn same_color(self) -> SameColor { + match self { + Self::None => SameColor::Null, + Self::False => SameColor::False, + Self::Color(color) => SameColor::Color(color), + } + } +} + fn own_same_color( node: &RawNode, variable_tokens: Option<&std::collections::BTreeMap>, ) -> OwnColor { let view = node.typed_view(); let mut target: Option = None; - let mut has_paints = false; for field in ["fills", "strokes"] { let Some(paints) = view.value(field).and_then(Value::as_array) else { continue; @@ -245,12 +261,11 @@ fn own_same_color( if paint.get("visible").and_then(Value::as_bool) != Some(true) { continue; } - has_paints = true; if paint.get("type").and_then(Value::as_str) != Some("SOLID") { - return OwnColor::Null; + return OwnColor::False; } let Some(color) = paint_string(paint, variable_tokens) else { - return OwnColor::Null; + return OwnColor::False; }; match &target { None => target = Some(color), @@ -259,13 +274,7 @@ fn own_same_color( } } } - if !has_paints { - return OwnColor::None; - } - match target { - Some(color) => OwnColor::Color(color), - None => OwnColor::Null, - } + target.map_or(OwnColor::None, OwnColor::Color) } /// A solid paint as the plugin's `solidToString` spells it: the variable it @@ -308,10 +317,7 @@ fn same_color( variable_tokens: Option<&std::collections::BTreeMap>, ) -> SameColor { let view = node.typed_view(); - let own = || match own_same_color(node, variable_tokens) { - OwnColor::Color(color) => SameColor::Color(color), - _ => SameColor::Null, - }; + let own = || own_same_color(node, variable_tokens).same_color(); if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") || view .value("inferredAutoLayout") @@ -365,7 +371,8 @@ fn same_color( && fill_type(fill) == Some("IMAGE") && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") }) { - return SameColor::Null; + // A picture inside an icon has colours a mask cannot keep. + return SameColor::False; } if node_fills.iter().all(|fill| { fill.get("visible").and_then(Value::as_bool) == Some(true) @@ -373,11 +380,7 @@ fn same_color( }) { return if nested { own() } else { SameColor::Null }; } - return match own_same_color(node, variable_tokens) { - OwnColor::Color(color) => SameColor::Color(color), - OwnColor::False => SameColor::False, - _ => SameColor::Null, - }; + return own(); } if nested && node_fills.is_some_and(|fills| { @@ -409,10 +412,8 @@ fn same_color( } let mut same = match own_same_color(node, variable_tokens) { - OwnColor::Null => return SameColor::Null, OwnColor::False => return SameColor::False, - OwnColor::Color(color) => SameColor::Color(color), - OwnColor::None => SameColor::Null, + own => own.same_color(), }; for child in child_ids .into_iter() diff --git a/crates/devup-mcp-devup-ui/tests/multicolor_icon_mask.rs b/crates/devup-mcp-devup-ui/tests/multicolor_icon_mask.rs new file mode 100644 index 00000000..45224b00 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/multicolor_icon_mask.rs @@ -0,0 +1,159 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +fn solid(r: f64, g: f64, b: f64, opacity: f64) -> Value { + json!({ + "type": "SOLID", "visible": true, "opacity": opacity, + "color": {"r": r, "g": g, "b": b} + }) +} + +fn gradient() -> Value { + json!({ + "type": "GRADIENT_LINEAR", "visible": true, + "gradientTransform": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + "gradientStops": [ + {"position": 0.0, "color": {"r": 0.957, "g": 0.953, "b": 1.0, "a": 1.0}}, + {"position": 1.0, "color": {"r": 0.906, "g": 0.902, "b": 1.0, "a": 1.0}} + ] + }) +} + +/// A 64x64 icon frame whose vectors carry the given fills, one per vector. +fn icon(fills: Vec) -> Value { + let ids = (0..fills.len()) + .map(|index| format!("1:v{index}")) + .collect::>(); + let mut nodes = vec![json!({ + "id": "1:find", "type": "FRAME", + "fields": { + "name": "find", "childrenIds": ids, + "width": 64.0, "height": 64.0 + }, + "extra": {}, "fieldErrors": {} + })]; + for (id, fill) in ids.iter().zip(fills) { + nodes.push(json!({ + "id": id, "type": "VECTOR", + "fields": { + "name": "Vector", "parentId": "1:find", "childrenIds": [], + "width": 48.0, "height": 48.0, "x": 8.0, "y": 8.0, + "fills": [fill] + }, + "extra": {}, "fieldErrors": {} + })); + } + Value::Array(nodes) +} + +const PURPLE: (f64, f64, f64) = (0.333, 0.165, 1.0); + +#[test] +fn a_gradient_face_with_a_solid_outline_stays_an_image() { + // The prime "기대 효과" icons: a gradient face under solid #552AFF + // strokes. A mask keeps only the shape and fills it with one colour, so + // the face and outline merged into one purple blob and the glyph inside + // vanished. The gradient used to count as "no colour" and was ignored. + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![gradient(), solid(r, g, b, 0.75), solid(r, g, b, 0.75)]), + ); + + assert!(tsx.contains("src=\"/icons/find.svg\""), "{tsx}"); + assert!(!tsx.contains("maskImage"), "{tsx}"); + assert!(!tsx.contains("bg="), "{tsx}"); +} + +#[test] +fn a_single_colour_icon_is_still_a_mask() { + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![solid(r, g, b, 0.75), solid(r, g, b, 0.75)]), + ); + + assert!(tsx.contains("maskImage=\"url(/icons/find.svg)\""), "{tsx}"); + assert!(tsx.contains("bg=\"#552AFFBF\""), "{tsx}"); + assert!(!tsx.contains("src="), "{tsx}"); +} + +#[test] +fn two_different_solids_stay_an_image() { + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![solid(r, g, b, 1.0), solid(1.0, 0.0, 0.0, 1.0)]), + ); + + assert!(tsx.contains("src=\"/icons/find.svg\""), "{tsx}"); + assert!(!tsx.contains("maskImage"), "{tsx}"); +} + +#[test] +fn a_gradient_stroke_spoils_the_colour_too() { + // Strokes are paints the mask would flatten just the same. + let (r, g, b) = PURPLE; + let mut nodes = icon(vec![solid(r, g, b, 1.0), solid(r, g, b, 1.0)]); + nodes[1]["fields"]["strokes"] = json!([gradient()]); + let tsx = generate("1:find", nodes); + + assert!(tsx.contains("src=\"/icons/find.svg\""), "{tsx}"); + assert!(!tsx.contains("maskImage"), "{tsx}"); +} + +#[test] +fn an_unpainted_vector_leaves_the_colour_alone() { + // No paint at all is neutral: it adds no colour for a mask to lose. + let (r, g, b) = PURPLE; + let mut nodes = icon(vec![solid(r, g, b, 1.0), solid(r, g, b, 1.0)]); + nodes[2]["fields"]["fills"] = json!([]); + let tsx = generate("1:find", nodes); + + assert!(tsx.contains("maskImage=\"url(/icons/find.svg)\""), "{tsx}"); + assert!(tsx.contains("bg=\"#552AFF\""), "{tsx}"); +} + +#[test] +fn layer_order_does_not_decide_the_mask() { + // The same icon with its gradient face on top: the answer must not move. + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![solid(r, g, b, 0.75), solid(r, g, b, 0.75), gradient()]), + ); + + assert!(tsx.contains("src=\"/icons/find.svg\""), "{tsx}"); + assert!(!tsx.contains("maskImage"), "{tsx}"); +} + +#[test] +fn an_image_paint_inside_an_icon_stays_an_image() { + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![ + solid(r, g, b, 1.0), + json!({"type": "IMAGE", "visible": true, "scaleMode": "FILL", "imageHash": "h"}), + ]), + ); + + assert!(!tsx.contains("maskImage"), "{tsx}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/plugin_compat.rs b/crates/devup-mcp-devup-ui/tests/plugin_compat.rs index bc60b365..410ef7c3 100644 --- a/crates/devup-mcp-devup-ui/tests/plugin_compat.rs +++ b/crates/devup-mcp-devup-ui/tests/plugin_compat.rs @@ -124,3 +124,47 @@ fn responsive_provider_covers_sections_variants_and_pure_code_first() { assert!(merged.contains("__variantProp")); assert!(merged.contains("\"size\"")); } + +#[test] +fn viewport_icons_mask_only_when_painted_in_one_colour() { + let viewport = |second_fill: serde_json::Value| { + json!({ + "type": "COMPONENT_SET", + "name": "Icon", + "componentPropertyDefinitions": { + "viewport": {"type": "VARIANT", "variantOptions": ["mobile", "desktop"]} + }, + "children": [ + { + "name": "mobile", "isAsset": true, + "variantProperties": {"viewport": "mobile"}, + "children": [ + {"type": "VECTOR", "fills": [{"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 0.0}}]}, + {"type": "VECTOR", "fills": [second_fill]} + ] + }, + { + "name": "desktop", "isAsset": true, + "variantProperties": {"viewport": "desktop"}, + "children": [ + {"type": "VECTOR", "fills": [{"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 0.0}}]} + ] + } + ] + }) + }; + + let one = render_viewport_component(&viewport( + json!({"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 0.0}}), + )) + .expect("viewport output"); + assert!(one.contains("bg=\"#000000\""), "{one}"); + assert!(one.contains("maskImage="), "{one}"); + + let gradient = render_viewport_component(&viewport( + json!({"type": "GRADIENT_LINEAR", "gradientStops": []}), + )) + .expect("viewport output"); + assert!(gradient.contains("src="), "{gradient}"); + assert!(!gradient.contains("maskImage"), "{gradient}"); +} From ee6c4ab63caad38365ec2322c120eb74eadcb01f Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 20:45:07 +0900 Subject: [PATCH 02/24] Warn in devup_ui_validate when a maskImage would flatten a multi-colour SVG The generator now keeps multi-colour icons as , but hand-written code (or a hand "fix" that turns an icon into a mask) can still point maskImage at one, and nothing in the TSX shows it: the colours are in the file. With projectRoot, every root-relative url(...svg) inside a maskImage value (JSX string, JSX expression or css object) is resolved under the project's public directories (/public first, then package public dirs) and the SVG is read with quick-xml. Fills and strokes are collected with inheritance and the black default, skipping ///gradient bodies and zero-opacity or hidden paints, so a clipPath's fill="white" rect does not count. A url() paint (gradient/pattern), an , or more than one distinct colour is a `multicolor-mask` warning naming the file and telling the caller to use . Strict mode fails on it like any warning; verificationScope lists the rule when projectRoot is given. Path segments that could leave the public dir are refused, files over 2 MiB are not read. Co-Authored-By: Claude Opus 5.5 --- crates/devup-mcp/src/server/mask_guard.rs | 578 +++++++++++++++++++ crates/devup-mcp/src/server/mod.rs | 14 +- crates/devup-mcp/tests/ground_truth_tools.rs | 77 +++ 3 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 crates/devup-mcp/src/server/mask_guard.rs diff --git a/crates/devup-mcp/src/server/mask_guard.rs b/crates/devup-mcp/src/server/mask_guard.rs new file mode 100644 index 00000000..c15e6790 --- /dev/null +++ b/crates/devup-mcp/src/server/mask_guard.rs @@ -0,0 +1,578 @@ +//! `devup_ui_validate`'s `multicolor-mask` rule: a `maskImage` that points at +//! an SVG painted in more than one colour. +//! +//! A CSS mask keeps only the shape of its image and fills it with the one +//! `bg` colour, so a gradient face and a solid outline become one solid blob +//! and whatever the icon drew inside them disappears. The generator no +//! longer emits such a mask, but hand-written code (or a hand "fix" of an +//! `` icon) can, and nothing in the TSX alone shows it: the colours +//! are in the file. So when `projectRoot` is given, each `maskImage` URL is +//! looked up under the project's public directories and the SVG's own paints +//! are read. +//! +//! Like the rest of the ground-truth tools this reads the files on every +//! call and never caches them. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use devup_mcp_devup_ui::ui_validate::{Severity, Violation}; +use quick_xml::{Reader, events::BytesStart, events::Event}; +use serde_json::{Map, json}; + +use super::project_root::find_dirs_named; + +pub(super) const RULE: &str = "multicolor-mask"; + +/// SVG files larger than this are not read; no icon is anywhere near it. +const MAX_SVG_BYTES: u64 = 2 * 1024 * 1024; + +/// How deep to look for `public` directories, enough for `apps/web/public`. +const PUBLIC_DIR_DEPTH: usize = 4; + +/// One `multicolor-mask` warning per `maskImage` URL in `sources` whose SVG, +/// found under a public directory of `project_root`, paints more than one +/// colour. URLs that do not resolve to a file are left alone: the check +/// cannot speak about a file it cannot read. +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 { + for (url, byte_range) in mask_urls(source) { + let public_dirs = public_dirs.get_or_insert_with(|| public_dirs_of(root)); + let Some(file) = resolve(public_dirs, &url) else { + continue; + }; + let Some(svg) = read_svg(&file) else { + continue; + }; + let paints = svg_paints(&svg); + if !paints.is_multicolor() { + continue; + } + let shown = file + .strip_prefix(root) + .unwrap_or(&file) + .to_string_lossy() + .replace('\\', "/"); + let mut context = Map::new(); + context.insert("maskUrl".into(), json!(url)); + context.insert("file".into(), json!(shown)); + context.insert("paints".into(), json!(paints.describe())); + if !name.is_empty() { + context.insert("sourceName".into(), json!(name)); + } + found.push(Violation { + rule: RULE, + severity: Severity::Warning, + byte_range, + context, + message: format!( + "maskImage points at {shown}, which paints {}. A mask keeps only the shape and fills it with one bg colour, so this icon flattens to a single-colour blob.", + paints.summary() + ), + suggestion: Some(format!( + "Render it as with the same size and drop bg/maskImage/maskSize/maskPos/maskRepeat. Keep maskImage only for single-colour icons." + )), + }); + } + } + found +} + +/// Every `url(...)` inside a `maskImage` value, as JSX (`maskImage="..."`, +/// `maskImage={...}`) or object (`maskImage: "..."`), with its byte range. +/// Only root-relative `.svg` URLs are kept, decoded and without query or +/// fragment. +fn mask_urls(source: &str) -> Vec<(String, [usize; 2])> { + let bytes = source.as_bytes(); + let mut urls = Vec::new(); + let mut from = 0; + while let Some(offset) = source[from..].find("maskImage") { + let mut index = from + offset + "maskImage".len(); + from = index; + index = skip_space(bytes, index); + if !matches!(bytes.get(index), Some(b'=' | b':')) { + continue; + } + index = skip_space(bytes, index + 1); + let Some(end) = value_end(bytes, index) else { + continue; + }; + let value = &source[index..end]; + let mut cursor = 0; + while let Some(start) = value[cursor..].find("url(") { + let open = cursor + start + "url(".len(); + let Some(close) = value[open..].find(')') else { + break; + }; + let raw = value[open..open + close] + .trim() + .trim_matches(|c| matches!(c, '\'' | '"')); + cursor = open + close + 1; + let path = raw.split(['?', '#']).next().unwrap_or(raw); + if path.starts_with('/') + && !path.starts_with("//") + && path.to_ascii_lowercase().ends_with(".svg") + { + urls.push(( + percent_decode(path), + [index + open - "url(".len(), index + cursor], + )); + } + } + from = end; + } + urls +} + +fn skip_space(bytes: &[u8], mut index: usize) -> usize { + while bytes.get(index).is_some_and(u8::is_ascii_whitespace) { + index += 1; + } + index +} + +/// Where a prop or property value that starts at `start` ends: the closing +/// quote of a string, or the brace that balances an expression container. +fn value_end(bytes: &[u8], start: usize) -> Option { + match *bytes.get(start)? { + quote @ (b'"' | b'\'' | b'`') => bytes[start + 1..] + .iter() + .position(|byte| *byte == quote) + .map(|position| start + 1 + position + 1), + b'{' => { + let mut depth = 0usize; + let mut quote = None; + for (position, byte) in bytes.iter().enumerate().skip(start) { + match (quote, *byte) { + (Some(open), byte) if byte == open => quote = None, + (Some(_), _) => {} + (None, b'"' | b'\'' | b'`') => quote = Some(*byte), + (None, b'{') => depth += 1, + (None, b'}') => { + depth -= 1; + if depth == 0 { + return Some(position + 1); + } + } + _ => {} + } + } + None + } + _ => None, + } +} + +fn percent_decode(path: &str) -> String { + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && let Some(byte) = path + .get(index + 1..index + 3) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()) + { + decoded.push(byte); + index += 3; + continue; + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8(decoded).unwrap_or_else(|_| path.to_owned()) +} + +/// `/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 { + let mut dirs = Vec::new(); + let top = root.join("public"); + if top.is_dir() { + dirs.push(top); + } + for dir in find_dirs_named(root, "public", PUBLIC_DIR_DEPTH).0 { + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + dirs +} + +/// The file a root-relative URL is served from, refusing any segment that +/// could leave the public directory. +fn resolve(public_dirs: &[PathBuf], url: &str) -> Option { + let segments = url.trim_start_matches('/').split('/').collect::>(); + if segments.iter().any(|segment| { + segment.is_empty() || matches!(*segment, "." | "..") || segment.contains(['\\', ':']) + }) { + return None; + } + public_dirs.iter().find_map(|dir| { + let file = segments + .iter() + .fold(dir.clone(), |path, segment| path.join(segment)); + file.is_file().then_some(file) + }) +} + +fn read_svg(file: &Path) -> Option { + let length = std::fs::metadata(file).ok()?.len(); + if length > MAX_SVG_BYTES { + return None; + } + std::fs::read_to_string(file).ok() +} + +/// The paints an SVG draws with, outside ``, `` and other +/// definitions that are never painted themselves. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct SvgPaints { + /// Flat colours, normalised so `#FFF` and `white` are one. + colors: BTreeSet, + /// `url(#...)` paints: gradients or patterns, more than one colour each. + references: BTreeSet, + /// `` elements: pixels a mask would throw away. + images: usize, +} + +impl SvgPaints { + fn is_multicolor(&self) -> bool { + self.colors.len() > 1 || !self.references.is_empty() || self.images > 0 + } + + fn describe(&self) -> Vec { + let mut paints = self.colors.iter().cloned().collect::>(); + paints.extend(self.references.iter().cloned()); + if self.images > 0 { + paints.push(format!("{} ", self.images)); + } + paints + } + + fn summary(&self) -> String { + let mut parts = Vec::new(); + if !self.colors.is_empty() { + parts.push(format!( + "{} colour(s) ({})", + self.colors.len(), + self.colors.iter().cloned().collect::>().join(", ") + )); + } + if !self.references.is_empty() { + parts.push(format!( + "{} gradient/pattern fill(s)", + self.references.len() + )); + } + if self.images > 0 { + parts.push(format!("{} embedded image(s)", self.images)); + } + parts.join(" and ") + } +} + +/// Elements whose subtree defines something rather than painting it. +const UNPAINTED: &[&str] = &[ + "clippath", + "mask", + "defs", + "lineargradient", + "radialgradient", + "pattern", + "symbol", + "marker", + "filter", + "style", + "title", + "desc", + "metadata", +]; + +/// Elements that paint their fill and stroke. +const SHAPES: &[&str] = &[ + "path", "rect", "circle", "ellipse", "polygon", "polyline", "line", "text", "use", +]; + +/// Inherited state for the element being read. +#[derive(Clone, Default)] +struct Inherited { + fill: Option, + stroke: Option, + unpainted: bool, +} + +pub(super) fn svg_paints(svg: &str) -> SvgPaints { + let mut paints = SvgPaints::default(); + let mut reader = Reader::from_str(svg); + reader.config_mut().check_end_names = false; + let mut stack = vec![Inherited::default()]; + loop { + let (element, opens) = match reader.read_event() { + Ok(Event::Start(element)) => (element, true), + Ok(Event::Empty(element)) => (element, false), + Ok(Event::End(_)) => { + if stack.len() > 1 { + stack.pop(); + } + continue; + } + Ok(Event::Eof) | Err(_) => break, + Ok(_) => continue, + }; + let parent = stack.last().cloned().unwrap_or_default(); + let current = paint_element(&element, &parent, &mut paints); + if opens { + stack.push(current); + } + } + paints +} + +/// Records what `element` paints and returns what its children inherit. +fn paint_element( + element: &BytesStart<'_>, + parent: &Inherited, + paints: &mut SvgPaints, +) -> Inherited { + let name = element.local_name().as_ref().to_ascii_lowercase(); + let mut fill = None; + let mut stroke = None; + let mut hidden = false; + let mut fill_hidden = false; + let mut stroke_hidden = false; + let mut apply = |key: &str, value: &str| match key { + "fill" => fill = Some(value.trim().to_owned()), + "stroke" => stroke = Some(value.trim().to_owned()), + "opacity" => hidden |= is_zero(value), + "fill-opacity" => fill_hidden |= is_zero(value), + "stroke-opacity" => stroke_hidden |= is_zero(value), + "display" => hidden |= value.trim() == "none", + "visibility" => hidden |= value.trim() == "hidden", + _ => {} + }; + for attribute in element.attributes().with_checks(false).flatten() { + let key = attribute.key.local_name().as_ref().to_ascii_lowercase(); + let value = quick_xml::escape::unescape(&attribute.value) + .map_or_else(|_| attribute.value.to_string(), |value| value.into_owned()); + if key == "style" { + for declaration in value.split(';') { + if let Some((key, value)) = declaration.split_once(':') { + apply(&key.trim().to_ascii_lowercase(), value); + } + } + } else { + apply(&key, &value); + } + } + let inherit = |own: Option, inherited: &Option| match own { + Some(value) if value != "inherit" => Some(value), + _ => inherited.clone(), + }; + let current = Inherited { + fill: inherit(fill, &parent.fill), + stroke: inherit(stroke, &parent.stroke), + unpainted: parent.unpainted || hidden || UNPAINTED.contains(&name.as_str()), + }; + if current.unpainted { + return current; + } + if name == "image" { + paints.images += 1; + } else if SHAPES.contains(&name.as_str()) { + // An unset fill is black; a line has no area to fill. + if name != "line" && !fill_hidden { + add_paint(paints, current.fill.as_deref().unwrap_or("black")); + } + if let Some(stroke) = current.stroke.as_deref().filter(|_| !stroke_hidden) { + add_paint(paints, stroke); + } + } + current +} + +fn is_zero(value: &str) -> bool { + value + .trim() + .trim_end_matches('%') + .parse::() + .is_ok_and(|number| number == 0.0) +} + +fn add_paint(paints: &mut SvgPaints, value: &str) { + let value = value.trim().to_ascii_lowercase(); + match value.as_str() { + "" | "none" | "transparent" => {} + reference if reference.starts_with("url(") => { + paints.references.insert(reference.to_owned()); + } + color => { + paints.colors.insert(normalize_color(color)); + } + } +} + +fn normalize_color(color: &str) -> String { + match color { + "black" => return "#000000".to_owned(), + "white" => return "#ffffff".to_owned(), + _ => {} + } + if let Some(hex) = color.strip_prefix('#') + && matches!(hex.len(), 3 | 4) + && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + let mut long = String::from("#"); + for digit in hex.chars() { + long.push(digit); + long.push(digit); + } + return long; + } + color.split_whitespace().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The prime "find" icon as Figma exports it: a gradient face under a + /// solid outline, clipped by a white rect. + const FIND: &str = r##" + + + + + + + + + + + + + + +"##; + + #[test] + fn a_gradient_with_a_solid_outline_is_multicolor() { + let paints = svg_paints(FIND); + assert!(paints.is_multicolor(), "{paints:?}"); + assert_eq!(paints.colors, BTreeSet::from(["#552aff".to_owned()])); + assert_eq!(paints.references.len(), 1); + } + + #[test] + fn a_clip_path_rect_does_not_count_as_a_paint() { + let svg = r##" + + + +"##; + let paints = svg_paints(svg); + assert!(!paints.is_multicolor(), "{paints:?}"); + assert_eq!(paints.colors, BTreeSet::from(["#552aff".to_owned()])); + } + + #[test] + fn two_colours_and_inherited_or_default_fills_are_counted() { + let two = + r##""##; + assert!(svg_paints(two).is_multicolor()); + + // `` reaches its paths, and an unset fill is black. + let inherited = r##""##; + assert_eq!(svg_paints(inherited).colors.len(), 2); + + let style = r##""##; + assert!(!svg_paints(style).is_multicolor()); + } + + #[test] + fn hidden_paints_and_embedded_images() { + let hidden = r##""##; + assert!(!svg_paints(hidden).is_multicolor()); + + let image = r##""##; + assert!(svg_paints(image).is_multicolor()); + } + + #[test] + fn mask_urls_are_read_from_jsx_and_object_values() { + let source = r#" + +const s = css({ maskImage: 'url("/icons/c.svg")' }) +"#; + let urls = mask_urls(source) + .into_iter() + .map(|(url, range)| { + assert!(source[range[0]..range[1]].starts_with("url(")); + url + }) + .collect::>(); + assert_eq!( + urls, + [ + "/icons/find.svg", + "/icons/속성 1=a.svg", + "/icons/b.svg", + "/icons/c.svg" + ] + ); + } + + #[test] + fn urls_cannot_leave_the_public_directory() { + let dirs = vec![PathBuf::from("public")]; + assert_eq!(resolve(&dirs, "/../secret.svg"), None); + assert_eq!(resolve(&dirs, "/icons//x.svg"), None); + assert_eq!(resolve(&dirs, "/C:/x.svg"), None); + } + + #[test] + fn a_project_file_is_read_and_named() -> std::io::Result<()> { + let root = std::env::temp_dir().join(format!( + "mask-guard-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(root.join("public/icons"))?; + std::fs::write(root.join("public/icons/find.svg"), FIND)?; + std::fs::write( + root.join("public/icons/arrow.svg"), + r##""##, + )?; + 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::Warning); + assert_eq!(violation.context["maskUrl"], "/icons/find.svg"); + assert_eq!(violation.context["sourceName"], "Page.tsx"); + assert!( + violation.message.contains("find.svg"), + "{}", + violation.message + ); + assert!( + violation + .suggestion + .as_deref() + .is_some_and(|suggestion| suggestion.contains(">() }) .unwrap_or_else(|| vec![("", input.tsx.as_str())]); - for (name, tsx) in sources { + for &(name, tsx) in &sources { for diagnostic in devup_mcp_devup_ui::codegen::validate_asset_effects(tsx, &input.asset_policies) { @@ -2288,6 +2289,12 @@ impl DevupServer { } } } + // A mask flattens its SVG to one colour, which only the file shows. + let masks = mask_guard::violations(&sources, input.project_root.as_deref()); + if input.strict && !masks.is_empty() { + report.ok = false; + } + report.violations.extend(masks); // 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 @@ -2515,6 +2522,9 @@ fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { let mut rules = devup_mcp_devup_ui::ui_validate::rule_names() .into_iter() .collect::>(); + if input.project_root.is_some() { + rules.push(mask_guard::RULE); + } if input.files.is_some() { rules.extend([ "file-placement", diff --git a/crates/devup-mcp/tests/ground_truth_tools.rs b/crates/devup-mcp/tests/ground_truth_tools.rs index f2c76ac8..389ddc5e 100644 --- a/crates/devup-mcp/tests/ground_truth_tools.rs +++ b/crates/devup-mcp/tests/ground_truth_tools.rs @@ -598,3 +598,80 @@ async fn ui_validate_blur_only_and_double_effect_warnings_are_complementary() -> assert_eq!(result["ok"], false); Ok(()) } + +#[tokio::test] +async fn ui_validate_warns_when_a_mask_would_flatten_a_multicolor_svg() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!( + "mask-guard-mcp-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(root.join("public/icons"))?; + std::fs::write(root.join("package.json"), "{}")?; + std::fs::write( + root.join("public/icons/find.svg"), + r##" + +"##, + )?; + std::fs::write( + root.join("public/icons/arrow.svg"), + r##" +"##, + )?; + let project_root = root.to_string_lossy().into_owned(); + let masks = |tsx: &str, strict: bool| { + call_tool( + "devup_ui_validate", + json!({"tsx": tsx, "projectRoot": project_root, "strict": strict}), + ) + }; + + let result = masks( + r##""##, + true, + ) + .await? + .structured_content + .unwrap(); + let warning = result["violations"] + .as_array() + .unwrap() + .iter() + .find(|v| v["rule"] == "multicolor-mask") + .unwrap_or_else(|| panic!("{result}")); + assert_eq!(warning["severity"], "warning"); + assert_eq!(warning["file"], "public/icons/find.svg"); + assert!( + warning["suggestion"] + .as_str() + .is_some_and(|s| s.contains(""##, + true, + ) + .await? + .structured_content + .unwrap(); + assert!( + !result["violations"] + .as_array() + .unwrap() + .iter() + .any(|v| v["rule"] == "multicolor-mask"), + "{result}" + ); + std::fs::remove_dir_all(&root)?; + Ok(()) +} From 2310338d1e5ac58fabd64499c2b71b452745d233 Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 20:55:34 +0900 Subject: [PATCH 03/24] Compact export job polls, honour responseFields on them, explain pending files Polling {jobId, jobAction:"status"} returned the whole job each time: 30-100 KB with every asset's exportDiagnostics/renderInfo, twice, because assetJob repeated exportJob's body. responseFields on a poll were ignored. - Job replies carry compact per-asset rows (assetId, status, byteLength, sha256, errorCode/errorDetail, acquisitionState, outputPath, requestedOutputPath, fileState, fileStateReason, suggestedFilename). exportJob.assetDetail says how to read full rows; a completed job's assetManifest still has them. - responseFields/path on a poll select from the full reply (same grammar and 64 KiB ceiling as an export selection, via a shared `walk`) and return only the selections plus the job's progress. - assetJob is now {aliasOf:"exportJob", jobId, state} instead of a copy. The batch-merge CLI reads exportJob.assets and falls back to assetJob.assets for batches saved before this. - A requested file that is still `pending` carries fileStateReason: an exported asset is held in memory until the projection-and-write stage. Test expectation changes: tests that read assetJob.assets/calls/ assetTimeoutMs/assetProgress now read exportJob (the alias no longer holds the body), and asset_jobs unit tests that inspect renderInfo or quality ask for full rows. No assertion about values changed. Tool description and usage guide: say that polls are compact and how to select full rows, and that generated code masks only single-colour icons, multi-colour icons stay , and an agent must not turn an icon into a mask or recolour it by hand. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- crates/devup-mcp/src/asset_batches.rs | 11 +- crates/devup-mcp/src/server/asset_jobs.rs | 175 ++++++++++++++++++--- crates/devup-mcp/src/server/compact.rs | 164 ++++++++++++------- crates/devup-mcp/src/server/guide.rs | 10 +- crates/devup-mcp/src/server/mod.rs | 38 +++-- crates/devup-mcp/tests/composite_export.rs | 30 +++- 7 files changed, 323 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index e611d0a0..ed32369d 100644 --- a/README.md +++ b/README.md @@ -1116,7 +1116,7 @@ absolute 자식이 있는 프레임은 생성 코드가 containing block을 만 `overflowDirection`은 캡처 manifest에 포함됩니다. `NONE`은 명시적 비스크롤, JSON `null`은 수집 시 원본 속성이 없음, 키 부재는 이전 수집기의 미수집, `fieldErrors.overflowDirection`은 읽기 실패입니다. 세로/가로/양방향 스크롤은 각각 `overflowY="auto"`/`overflowX="auto"`/`overflow="auto"`로 생성합니다. `clipsContent=true`는 스크롤을 취소하지 않으며, 한 축 스크롤에서는 다른 축을 clip합니다. ABSOLUTE FIXED 높이는 파생 padding이 있어도 명시적 높이로 보존합니다. FILL/HUG를 px로 고정하지 않습니다. -일반 화면 export도 수집이 길어지면 약 1초의 초기 대기 후 `exportJob`을 반환합니다(`assetJob`은 호환 별칭). `jobId`로 상태를 조회하고 paused 상태는 `jobAction:"resume"`으로 재개합니다. `calls`의 frame/root ID·pagination·elapsedMs와 projection 시간을 확인할 수 있습니다. 예산은 frame-output unit당 5–20초의 계획용 추정이며 보장이 아닙니다. 요청 한도 6 frames/12 units와 별개로 upstream 지연은 job으로 처리합니다. 지연 프레임을 분리하려면 한 프레임씩 호출하십시오. 작업은 서버 프로세스 안에서 30분, 완료 응답은 5분 보존되며 재시작을 넘겨 보존되지 않습니다. +일반 화면 export도 수집이 길어지면 약 1초의 초기 대기 후 `exportJob`을 반환합니다(`assetJob`은 `exportJob`을 가리키는 호환 참조이며 본문을 반복하지 않습니다). `jobId`로 상태를 조회하고(자산 행은 assetId·status·byteLength·sha256·fileState 위주로 간결하게 반환되며, 전체 행은 조회에 `responseFields:["exportJob.assets"]`를 붙여 선택합니다) paused 상태는 `jobAction:"resume"`으로 재개합니다. `calls`의 frame/root ID·pagination·elapsedMs와 projection 시간을 확인할 수 있습니다. 예산은 frame-output unit당 5–20초의 계획용 추정이며 보장이 아닙니다. 요청 한도 6 frames/12 units와 별개로 upstream 지연은 job으로 처리합니다. 지연 프레임을 분리하려면 한 프레임씩 호출하십시오. 작업은 서버 프로세스 안에서 30분, 완료 응답은 5분 보존되며 재시작을 넘겨 보존되지 않습니다. artifact의 `artifactState`는 `expired`(확인된 TTL 만료), `evicted`(확인된 캐시 제거), `issued-by-another-process`(다른 devup-mcp 프로세스가 발급한 id — 재시작 전의 서버나 다른 세션의 서버), `unknown`(현재 서버에서 원인 판단 불가)을 구분합니다. artifactId의 앞 6바이트가 발급한 프로세스의 표식이라 디스크에 아무것도 쓰지 않고 구분하며, id는 그대로 43자입니다. 최근 제거 64개 이내의 복구 metadata가 있으면 canonical URL과 선택을 `nextAction.arguments`로 제공합니다. URL 원문의 파일 제목은 저장하지 않아도 같은 대상을 가리키는 canonical URL을 구성합니다. metadata가 없으면 `recoveryState:"unrecoverable"`로 명시하며, 서버 재시작이라고 단정하지 않습니다. diff --git a/crates/devup-mcp/src/asset_batches.rs b/crates/devup-mcp/src/asset_batches.rs index b382426f..52adbeac 100644 --- a/crates/devup-mcp/src/asset_batches.rs +++ b/crates/devup-mcp/src/asset_batches.rs @@ -130,7 +130,16 @@ pub fn merge(values: &[Value]) -> anyhow::Result { } for (items, requested) in [ (&value["assetManifest"]["assets"], false), - (&value["assetJob"]["assets"], true), + // `assetJob` is only a reference to `exportJob` now; batches saved + // before that still carry the rows under the old name. + ( + if value["exportJob"]["assets"].is_array() { + &value["exportJob"]["assets"] + } else { + &value["assetJob"]["assets"] + }, + true, + ), ] { for item in items.as_array().into_iter().flatten() { let Some(id) = item["assetId"].as_str() else { diff --git a/crates/devup-mcp/src/server/asset_jobs.rs b/crates/devup-mcp/src/server/asset_jobs.rs index 4b7db4dd..c8cbda57 100644 --- a/crates/devup-mcp/src/server/asset_jobs.rs +++ b/crates/devup-mcp/src/server/asset_jobs.rs @@ -21,6 +21,23 @@ const RESULT_RETENTION: Duration = Duration::from_secs(5 * 60); const MAX_JOBS: usize = 8; const MAX_COMPLETED_JOBS: usize = 16; +/// What a compact asset row keeps: enough to see progress, verify bytes and +/// find the file, without the per-asset diagnostics. +const COMPACT_ASSET_FIELDS: &[&str] = &[ + "assetId", + "status", + "byteLength", + "sha256", + "errorCode", + "errorDetail", + "acquisitionState", + "outputPath", + "requestedOutputPath", + "fileState", + "fileStateReason", + "suggestedFilename", +]; + #[derive(Clone, Default)] pub(super) struct AssetJobs(Arc>>>); @@ -388,7 +405,17 @@ impl AssetJob { state.finished = Some(Instant::now()); } + #[cfg(test)] fn response(&self) -> Result { + self.response_with(false) + } + + /// The job as a poll reports it. Without `full`, one compact row per + /// asset: the full rows (`exportDiagnostics`, `renderInfo`, ...) repeated + /// tens of kilobytes on every poll of a running job. A poll that selects + /// with responseFields reads them, and a completed job's asset manifest + /// carries them too. + fn response_with(&self, full: bool) -> Result { let state = self.state.lock().unwrap(); let mut calls = state.calls.clone(); for call in &mut calls { @@ -414,6 +441,27 @@ impl AssetJob { } else { "pending" }); + // Bytes are held until the whole job is projected, so an + // exported asset reads `pending` until then; say so. + if asset["fileState"] == "pending" { + asset["fileStateReason"] = json!(if asset["status"] == "exported" { + "Acquired and held in memory. Files are written together in the projection-and-write stage, after the last asset of this job is acquired." + } else { + "Not acquired yet." + }); + } + } + if !full { + return COMPACT_ASSET_FIELDS + .iter() + .filter_map(|field| { + asset + .get(*field) + .filter(|value| !value.is_null()) + .map(|value| ((*field).to_owned(), value.clone())) + }) + .collect::>() + .into(); } if state.assets.len() > 32 { asset["renderInfoSummary"] = json!({"representation":asset["renderInfo"]["representation"],"outputSize":asset["renderInfo"]["outputSize"],"fidelity":asset["renderInfo"]["fidelity"],"compression":asset["renderInfo"]["compression"]}); @@ -445,7 +493,7 @@ impl AssetJob { } else { json!({"tool":"devup_figma_export","arguments":{"jobId":self.id,"jobAction":if state.state=="paused" {"resume"} else {"status"}}}) }; - let job = json!({"jobId":self.id,"budget":self.budget,"state":state.state,"stage":state.stage,"assets":assets, + let mut job = json!({"jobId":self.id,"budget":self.budget,"state":state.state,"stage":state.stage,"assets":assets, "assetProgress":{"total":state.assets.len(),"exported":state.assets.iter().filter(|a|a["status"]=="exported").count(),"failed":state.assets.iter().filter(|a|a["status"]=="failed").count(), "pending":state.assets.iter().filter(|a|a["status"]=="pending").count(),"maxAssetsPerBatch":6,"maxRetainedAssetBytes":devup_mcp_figma::MAX_COLLECTED_ASSET_BYTES, "observations":if state.assets.len()>32 {"summarized; full observations in completed manifest/audit resources"}else{"full"}}, @@ -455,6 +503,18 @@ impl AssetJob { "assetTimeoutMs":devup_mcp_figma::asset_timeout().as_millis() as u64, "nextActionReason":if terminal && next_action.is_null() { Some("This job has finished; status polling repeats the retained result. No corrective action is available.") } else { None }, "nextAction":next_action}); + if full { + job["assetRows"] = json!("full"); + } else { + job["assetRows"] = json!("compact"); + job["assetDetail"] = json!({ + "how": "Rows carry identity, status, bytes and file state only. Poll with responseFields to read full rows (exportDiagnostics, renderInfo); a completed job also carries them in its assetManifest.", + "arguments": {"jobId": self.id, "jobAction": "status", "responseFields": ["exportJob.assets"]}, + }); + } + // `assetJob` is a compatibility name for this same object. Repeating + // the body doubled every poll, so the alias now points at it. + let alias = json!({"aliasOf": "exportJob", "jobId": self.id, "state": state.state}); // Preserve the existing error contract for code-only upstream refusals, // while returning the retained job ID needed to resume the pending read. if state.state == "paused" @@ -475,18 +535,20 @@ impl AssetJob { if !error.details.is_object() { error.details = json!({}); } - error.details["exportJob"] = job.clone(); - error.details["assetJob"] = job; + error.details["exportJob"] = job; + error.details["assetJob"] = alias; return Err(error); } None => json!({"status":if state.state=="paused" {"paused"} else {"in_progress"}}), }; - value["exportJob"] = job.clone(); - value["assetJob"] = job; + value["exportJob"] = job; + value["assetJob"] = alias; Ok(value) } - pub(super) async fn wait_briefly(&self) -> Result { + /// Waits up to a second for a running job, then answers it; `full` keeps + /// every per-asset field, for a poll that selects from them. + pub(super) async fn wait_briefly(&self, full: bool) -> Result { let deadline = Instant::now() + Duration::from_secs(1); while Instant::now() < deadline { if self.state.lock().unwrap().state != "running" { @@ -494,7 +556,7 @@ impl AssetJob { } tokio::time::sleep(Duration::from_millis(10)).await; } - self.response() + self.response_with(full) } } @@ -534,7 +596,7 @@ impl DevupServer { worker.finish(result); }); } - job.wait_briefly().await + job.wait_briefly(false).await } } @@ -639,9 +701,9 @@ mod tests { let (cached_preset, _) = jobs.acquire(&operation, &request, false).unwrap(); cached_preset.captured(&[serde_json::from_value(json!({"assetId":"cached:fills:0","nodeId":"cached","field":"fills/0","sourceKind":"image-fill", "format":"webp","scale":1,"quality":80,"lossless":false,"status":"failed","errorCode":"DEVUP_ASSET_NODE_HIDDEN"})).unwrap()]); - let cached = cached_preset.response().unwrap(); - assert_eq!(cached["assetJob"]["assets"].as_array().unwrap().len(), 1); - assert_eq!(cached["assetJob"]["assets"][0]["quality"], 80); + let cached = cached_preset.response_with(true).unwrap(); + assert_eq!(cached["exportJob"]["assets"].as_array().unwrap().len(), 1); + assert_eq!(cached["exportJob"]["assets"][0]["quality"], 80); cached_preset.state.lock().unwrap().state = "paused"; cached_preset.resume(); assert_eq!(cached_preset.state.lock().unwrap().state, "running"); @@ -652,8 +714,9 @@ mod tests { false, ))); let cancelled = cached_preset.response().unwrap_err(); + assert_eq!(cancelled.details["exportJob"]["state"], "cancelled"); assert_eq!(cancelled.details["assetJob"]["state"], "cancelled"); - assert!(cancelled.details["assetJob"]["nextAction"].is_null()); + assert!(cancelled.details["exportJob"]["nextAction"].is_null()); assert_eq!( automatic.state.lock().unwrap().assets[0]["renderMode"], "auto" @@ -664,13 +727,13 @@ mod tests { "renderMode":"fill","renderInfo":{"outputSize":{"width":32,"height":16}}, "suggestedFilename":"gallery-picture-fill.png"}]}}]}), )); - let progress = fill.response().unwrap(); + let progress = fill.response_with(true).unwrap(); assert_eq!( - progress["assetJob"]["assets"][0]["renderInfo"]["outputSize"]["width"], + progress["exportJob"]["assets"][0]["renderInfo"]["outputSize"]["width"], 32 ); assert_eq!( - progress["assetJob"]["assets"][0]["suggestedFilename"], + progress["exportJob"]["assets"][0]["suggestedFilename"], "gallery-picture-fill.png" ); } @@ -723,16 +786,16 @@ mod tests { ))); let error = job.response().unwrap_err(); assert_eq!(error.details["exportJob"]["nextAction"], next); - assert_eq!(error.details["assetJob"]["nextAction"], next); - assert_eq!(error.details["assetJob"]["assets"][0]["status"], "failed"); + assert_eq!(error.details["assetJob"]["aliasOf"], "exportJob"); + assert_eq!(error.details["exportJob"]["assets"][0]["status"], "failed"); assert_eq!( - error.details["assetJob"]["assets"][0]["acquisitionState"], + error.details["exportJob"]["assets"][0]["acquisitionState"], "not-attempted" ); - assert_eq!(error.details["assetJob"]["assetProgress"]["pending"], 0); - assert_eq!(error.details["assetJob"]["assetProgress"]["failed"], 1); + assert_eq!(error.details["exportJob"]["assetProgress"]["pending"], 0); + assert_eq!(error.details["exportJob"]["assetProgress"]["failed"], 1); assert_eq!( - error.details["assetJob"]["assetProgress"]["maxRetainedAssetBytes"], + error.details["exportJob"]["assetProgress"]["maxRetainedAssetBytes"], devup_mcp_figma::MAX_COLLECTED_ASSET_BYTES ); if !correction { @@ -785,11 +848,77 @@ mod tests { json!({"outputPaths":{"asset:a":path_text,"asset:b":path_text}}), )); let response = job.response().unwrap(); - assert_eq!(response["assetJob"]["assets"][0]["fileState"], "written"); + assert_eq!(response["exportJob"]["assets"][0]["fileState"], "written"); assert_eq!( - response["assetJob"]["assets"][1]["fileState"], + response["exportJob"]["assets"][1]["fileState"], "not-written" ); std::fs::remove_file(path).unwrap(); } + + #[test] + fn polls_answer_compact_rows_and_reference_the_alias() { + let job = AssetJob { + id: "compact".into(), + key: "compact".into(), + budget: json!({}), + started: Instant::now(), + resume: Notify::new(), + cancellation: Notify::new(), + cancelled: std::sync::atomic::AtomicBool::new(false), + paths: BTreeMap::from([ + ("a".into(), "/out/a.svg".into()), + ("b".into(), "/out/b.svg".into()), + ]), + state: Mutex::new(JobState { + state: "running", + stage: "asset-export", + calls: vec![], + completed_calls: 1, + error: None, + result: None, + finished: None, + assets: vec![ + json!({"assetId":"a","status":"exported","byteLength":10,"sha256":"0".repeat(64), + "exportDiagnostics":{"large":"x".repeat(4096)},"renderInfo":{"large":"y".repeat(4096)}}), + json!({"assetId":"b","status":"pending"}), + ], + }), + }; + + let compact = job.response().unwrap(); + let rows = compact["exportJob"]["assets"].as_array().unwrap(); + assert_eq!(compact["exportJob"]["assetRows"], "compact"); + assert!(rows[0].get("exportDiagnostics").is_none(), "{compact}"); + assert!(rows[0].get("renderInfo").is_none(), "{compact}"); + assert_eq!(rows[0]["sha256"], "0".repeat(64)); + assert_eq!(rows[0]["requestedOutputPath"], "/out/a.svg"); + // Exported but not yet written: the state says why. + assert_eq!(rows[0]["fileState"], "pending"); + assert!( + rows[0]["fileStateReason"] + .as_str() + .unwrap() + .contains("projection-and-write") + ); + assert_eq!(rows[1]["fileStateReason"], "Not acquired yet."); + assert_eq!( + compact["exportJob"]["assetDetail"]["arguments"]["responseFields"], + json!(["exportJob.assets"]) + ); + // The alias names the job; it no longer repeats it. + assert_eq!( + compact["assetJob"], + json!({"aliasOf":"exportJob","jobId":"compact","state":"running"}) + ); + assert!( + serde_json::to_vec(&compact).unwrap().len() < 4096, + "{compact}" + ); + + let full = job.response_with(true).unwrap(); + assert_eq!(full["exportJob"]["assetRows"], "full"); + assert!(full["exportJob"]["assets"][0]["renderInfo"].is_object()); + assert!(full["exportJob"].get("assetDetail").is_none()); + } } diff --git a/crates/devup-mcp/src/server/compact.rs b/crates/devup-mcp/src/server/compact.rs index c69a45ad..f49070ac 100644 --- a/crates/devup-mcp/src/server/compact.rs +++ b/crates/devup-mcp/src/server/compact.rs @@ -192,6 +192,71 @@ pub fn outline(summary: &Value, options: &ResponseSelection) -> Value { "scanTruncated":summary["scanTruncated"],"missingCapturedNodes":summary["missingCapturedNodes"],"evidenceLimit":summary["evidenceLimit"]}) } +/// The values `path` reaches from `root`, each with the node or asset id it +/// belongs to when one is in reach. A field step over an array maps across +/// its items. +fn walk<'a>(root: &'a Value, path: &str) -> Result)>, DevupError> { + let mut values = vec![(root, None::)]; + for step in parse(path)? { + values = match step { + Step::Field(key) => values + .into_iter() + .flat_map(|(v, id)| { + if let Some(items) = v.as_array() { + items + .iter() + .filter_map(|v| { + v.get(&key).map(|field| { + ( + field, + v.get("nodeId") + .or_else(|| v.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| id.clone()), + ) + }) + }) + .collect::>() + } else { + v.get(&key).map(|field| (field, id)).into_iter().collect() + } + }) + .collect(), + Step::Index(index) => values + .into_iter() + .filter_map(|(v, id)| { + v.get(index).map(|item| { + ( + item, + item.get("nodeId") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .or(id), + ) + }) + }) + .collect(), + Step::Exact(key, value) => values + .into_iter() + .flat_map(|(v, _)| v.as_array().into_iter().flatten()) + .filter(|v| v.get(&key).and_then(Value::as_str) == Some(value.as_str())) + .map(|v| { + ( + v, + v.get("nodeId") + .or_else(|| v.get("id")) + .and_then(Value::as_str) + .map(str::to_owned), + ) + }) + .collect(), + }; + } + Ok(values) +} + pub fn select( result: &mut serde_json::Map, summary: &Value, @@ -205,64 +270,7 @@ pub fn select( root["nodes"] = summary["nodes"].clone(); let mut selections = Vec::new(); for path in options.response_fields.iter().chain(options.path.iter()) { - let mut values = vec![(&root, None::)]; - for step in parse(path)? { - values = match step { - Step::Field(key) => values - .into_iter() - .flat_map(|(v, id)| { - if let Some(items) = v.as_array() { - items - .iter() - .filter_map(|v| { - v.get(&key).map(|field| { - ( - field, - v.get("nodeId") - .or_else(|| v.get("id")) - .and_then(Value::as_str) - .map(str::to_owned) - .or_else(|| id.clone()), - ) - }) - }) - .collect::>() - } else { - v.get(&key).map(|field| (field, id)).into_iter().collect() - } - }) - .collect(), - Step::Index(index) => values - .into_iter() - .filter_map(|(v, id)| { - v.get(index).map(|item| { - ( - item, - item.get("nodeId") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .map(str::to_owned) - .or(id), - ) - }) - }) - .collect(), - Step::Exact(key, value) => values - .into_iter() - .flat_map(|(v, _)| v.as_array().into_iter().flatten()) - .filter(|v| v.get(&key).and_then(Value::as_str) == Some(value.as_str())) - .map(|v| { - ( - v, - v.get("nodeId") - .or_else(|| v.get("id")) - .and_then(Value::as_str) - .map(str::to_owned), - ) - }) - .collect(), - }; - } + let values = walk(&root, path)?; let count = values.len(); let end = count.min( options @@ -367,6 +375,46 @@ pub fn select( ))) } +/// A job poll's `responseFields`: the selected paths of the poll's own reply, +/// plus the job's progress, instead of the whole reply again. The same path +/// grammar and the same 64 KiB ceiling as an export's selection. +pub fn pick(reply: &Value, options: &ResponseSelection) -> Result { + options.validate()?; + let mut selections = Vec::new(); + for path in options.response_fields.iter().chain(options.path.iter()) { + let values = walk(reply, path)?; + let count = values.len(); + let end = count.min( + options + .response_offset + .saturating_add(options.response_limit), + ); + let values = values + .into_iter() + .skip(options.response_offset) + .take(options.response_limit) + .map(|(value, _)| value.clone()) + .collect::>(); + selections.push(json!({"path":path,"values":values,"matched":count, + "offset":options.response_offset,"nextOffset":(end < count).then_some(end), + "state":if count == 0 {"not-found"} else {"selected"},"truncated":end < count})); + } + let job = &reply["exportJob"]; + let picked = json!({"status":reply["status"],"selections":selections, + "exportJob":{"jobId":job["jobId"],"state":job["state"],"stage":job["stage"], + "assetProgress":job["assetProgress"],"nextAction":job["nextAction"], + "nextActionReason":job["nextActionReason"]}, + "selectionScope":"Selected from this poll's reply. Omit responseFields to read the whole reply."}); + if serde_json::to_vec(&picked).unwrap_or_default().len() > 65_536 { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "Selected data exceeds 65536 bytes. Select a narrower field or lower responseLimit; a scalar is never silently truncated.", + false, + )); + } + Ok(picked) +} + /// Standalone outlines keep the small data body inline and move unrelated /// full acquisition/projection evidence into the same bounded resource path. pub fn outline_response( diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index e181a6d1..5db148df 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -153,6 +153,12 @@ A `maskImage` or `Image` src is built from the layer name. Rename the file to fit the project. If the asset varies per usage, lift it into a prop instead of hardcoding it. +`maskImage` + `bg` is emitted only for an icon painted in one flat colour; a +multi-colour icon (gradient, several colours, an image) stays ``. +Never turn an `` icon into a mask or recolour it by hand: a mask keeps +only the shape and fills it with one colour. `devup_ui_validate` with +`projectRoot` warns `multicolor-mask` when one does. + ## 11. A referenced asset must actually exist A fixed asset such as an icon must be exported, never referenced by a path that @@ -190,7 +196,9 @@ assetManifest/imageAudit and `asset-css-map.json`, schedules sorted batches of a most six and retains hidden, missing-source, failed and limit dispositions. Use assetPublicRoot for written `.webp` files and mapped code, or resources for reusable bytes. Custom assetRequests remain separate. Poll exportJob; resume -paused work or cancel with jobAction. Accepted results/checkpoints live in this +paused work or cancel with jobAction. Polls return compact asset rows; add +`responseFields` (e.g. `exportJob.assets`) to a poll for full rows. `assetJob` +only references `exportJob`. Accepted results/checkpoints live in this server process for the reported retention period. Scheduling is bounded to4096 sources and128 MiB retained asset bytes; remaining IDs carry explicit failures and can be retried in smaller scopes. Read completeness and failures before use. diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index fbd519fc..8ff02d60 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -1622,7 +1622,7 @@ impl DevupServer { } #[tool( - description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. \ + description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. 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. \ 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. \ @@ -1697,21 +1697,29 @@ impl DevupServer { // their TSX. Checking it only on the synchronous returns left // every real export unchecked. let project_root = input.project_root.clone(); - return job - .wait_briefly() + // responseFields on a poll selects from this reply; without them + // the reply carries compact per-asset rows. + let selection = &input.response_selection; + selection.validate().map_err(to_mcp_error)?; + let result = job + .wait_briefly(selection.active()) .await - .map(|result| { - tool_result(with_project_checks( - result, - project_root.as_deref(), - &skills::Lookup::new( - project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ), - )) - }) - .map_err(to_mcp_error); + .map_err(to_mcp_error)?; + let result = with_project_checks( + result, + project_root.as_deref(), + &skills::Lookup::new( + project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ), + ); + if selection.active() { + return compact::pick(&result, selection) + .map(tool_result) + .map_err(to_mcp_error); + } + return Ok(tool_result(result)); } if workflow.job_action.is_some() { return Err(to_mcp_error(DevupError::new( diff --git a/crates/devup-mcp/tests/composite_export.rs b/crates/devup-mcp/tests/composite_export.rs index 9527c924..c30e4d45 100644 --- a/crates/devup-mcp/tests/composite_export.rs +++ b/crates/devup-mcp/tests/composite_export.rs @@ -654,11 +654,11 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> .expect("structured compatibility summary"); let summaries = structured["resources"].as_array().unwrap(); assert_eq!( - result.structured_content.as_ref().unwrap()["assetJob"]["assets"][0]["status"], + result.structured_content.as_ref().unwrap()["exportJob"]["assets"][0]["status"], "exported" ); assert_eq!( - result.structured_content.as_ref().unwrap()["assetJob"]["assets"][0]["fileState"], + result.structured_content.as_ref().unwrap()["exportJob"]["assets"][0]["fileState"], "written" ); assert_eq!( @@ -1240,14 +1240,14 @@ async fn r3_assert_resume(timeout_first: bool) -> anyhow::Result<()> { let mut paused = call(&client, "devup_figma_export", args.clone()).await?; if timeout_first { assert_eq!(paused["assetJob"]["state"], "running"); - let timeout_ms = paused["assetJob"]["calls"] + let timeout_ms = paused["exportJob"]["calls"] .as_array() .unwrap() .last() .unwrap()["timeoutMs"] .as_u64() .unwrap(); - assert!(timeout_ms >= paused["assetJob"]["assetTimeoutMs"].as_u64().unwrap()); + assert!(timeout_ms >= paused["exportJob"]["assetTimeoutMs"].as_u64().unwrap()); tokio::time::advance(std::time::Duration::from_millis(timeout_ms + 1000)).await; paused = call( &client, @@ -1258,11 +1258,25 @@ async fn r3_assert_resume(timeout_first: bool) -> anyhow::Result<()> { } assert_eq!(paused["assetJob"]["state"], "paused", "{paused}"); let id = paused["assetJob"]["jobId"].as_str().unwrap(); - assert_eq!(paused["assetJob"]["assets"][0]["status"], "exported"); + assert_eq!(paused["exportJob"]["assets"][0]["status"], "exported"); assert_eq!( - paused["assetJob"]["assets"][0]["fileState"], + paused["exportJob"]["assets"][0]["fileState"], "not-requested" ); + // Polls answer compact rows, and the alias no longer repeats the job. + assert!(paused["exportJob"]["assets"][0].get("renderInfo").is_none()); + assert_eq!(paused["assetJob"]["aliasOf"], "exportJob"); + // responseFields on a poll answers only what it selected. + let picked = call( + &client, + "devup_figma_export", + json!({"jobId":id,"responseFields":["exportJob.assets.renderInfo"]}), + ) + .await?; + assert_eq!(picked["selections"][0]["matched"], 1, "{picked}"); + assert!(picked["selections"][0]["values"][0].is_object()); + assert_eq!(picked["exportJob"]["state"], "paused"); + assert!(picked.get("assetJob").is_none(), "{picked}"); let recovered = call(&client, "devup_figma_export", args).await?; assert_eq!(recovered["assetJob"]["jobId"], id); let resumed = call( @@ -1474,7 +1488,7 @@ async fn r3_cancelled_client_recovers_retained_asset_job() -> anyhow::Result<()> let id = &recovered["assetJob"]["jobId"]; assert!(id.is_string()); assert_eq!(upstream.snapshots.load(Ordering::SeqCst), 1); - let timeout_ms = recovered["assetJob"]["calls"] + let timeout_ms = recovered["exportJob"]["calls"] .as_array() .unwrap() .last() @@ -1523,7 +1537,7 @@ async fn r3_distinct_resource_jobs_keep_both_manifests_readable() -> anyhow::Res for value in [first, second] { assert_eq!(value["assetJob"]["state"], "complete"); assert!( - value["assetJob"]["assets"] + value["exportJob"]["assets"] .as_array() .unwrap() .iter() From 88429473830aa9ab058901ed3c994eacabbf5a83 Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 21:29:16 +0900 Subject: [PATCH 04/24] Report a per-screen, per-width table from runtime audit ingest ingest now returns screenTable: one row per plan screen and viewport with pass, fail, inconclusive or not-run and the reasons. A failed no-horizontal-overflow check reads as horizontal overflow, a navigation-failed error is pinned on the screen whose route it names, an unattributed page error leaves that width inconclusive, and a missing run or screen item is not-run. Co-Authored-By: Claude Opus 5.5 --- crates/devup-mcp/src/server/runtime_audit.rs | 180 +++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/crates/devup-mcp/src/server/runtime_audit.rs b/crates/devup-mcp/src/server/runtime_audit.rs index 73f2507f..4bc37ef2 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -905,6 +905,7 @@ fn ingest_references( let mut environment_invalid = false; let mut visual_comparisons = Vec::new(); let mut dimension_rows = Vec::new(); + let mut screen_rows = Vec::new(); for viewport in &plan.viewports { let matching_runs = report["runs"] .as_array() @@ -965,6 +966,7 @@ fn ingest_references( .iter() .find(|candidate| candidate["id"] == screen.id.as_str()) }); + let mut outcomes = Vec::new(); for (index, check) in screen.checks.iter().enumerate() { if reported.is_some_and(|s| { s["checks"] @@ -1027,10 +1029,29 @@ fn ingest_references( "measured": dimension.as_ref().map(|row| &row["domCssPx"]).or_else(|| item.map(|item| &item["measured"])), })); } + outcomes.push(( + status, + check.kind.as_str(), + dimension + .as_ref() + .map(|row| &row["reason"]) + .or_else(|| item.map(|item| &item["reason"])) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + )); if let Some(row) = dimension { dimension_rows.push(row); } } + screen_rows.push(screen_row( + screen, + viewport, + run.is_some(), + reported, + &environment_errors, + &outcomes, + )); let Some(shot) = reported .map(|screen| &screen["screenshot"]) .filter(|shot| shot.is_object()) @@ -1134,6 +1155,7 @@ fn ingest_references( "runs": runs, "figmaReferences": figma, "visualComparisons": visual_comparisons, + "screenTable": screen_rows, "widthTable": plan.viewports.iter().map(|viewport| json!({ "width": viewport.width, "height": viewport.height, "deviceScaleFactor": viewport.device_scale_factor, @@ -1162,6 +1184,95 @@ fn ingest_references( })) } +/// One screen at one width, folded from its checks, its own record and the +/// environment errors the run could pin on it. This is the row a person reads +/// to learn whether `/about` works at 390; `failures` and `runs` say the same +/// things but spread across three lists. +/// +/// A page error is attributed only when its message names this screen's route +/// (`navigation-failed` does). An uncaught script error carries no route, so +/// it leaves every screen at that width inconclusive rather than failing the +/// one that may not have thrown it. +fn screen_row( + screen: &ScreenInput, + viewport: &ViewportInput, + run_reported: bool, + reported: Option<&Value>, + environment_errors: &[Value], + outcomes: &[(&str, &str, String)], +) -> Value { + let message = |error: &Value| error["message"].as_str().unwrap_or_default().to_owned(); + let own_navigation = environment_errors + .iter() + .filter(|error| { + error["kind"] == "navigation-failed" + && screen.route_path.as_deref().is_some_and(|route| { + error["message"] + .as_str() + .is_some_and(|text| text.starts_with(&format!("{route}:"))) + }) + }) + .map(|error| format!("page error: {}", message(error))) + .collect::>(); + let unattributed = environment_errors + .iter() + .filter(|error| error["kind"] == "page-error") + .map(|error| format!("page error during this width's run: {}", message(error))) + .collect::>(); + let failed_checks = outcomes + .iter() + .filter(|(status, _, _)| *status == "fail") + .map(|(_, kind, reason)| match *kind { + "no-horizontal-overflow" => format!("horizontal overflow: {reason}"), + kind => format!("{kind}: {reason}"), + }) + .collect::>(); + let record_status = reported.and_then(|screen| screen["status"].as_str()); + let record_reason = reported + .and_then(|screen| screen["reason"].as_str()) + .unwrap_or_default(); + let (status, reasons) = if !run_reported || reported.is_none() { + ( + "not-run", + vec!["no report item for this screen at this width".to_owned()], + ) + } else if !own_navigation.is_empty() { + ("fail", own_navigation) + } else if record_status == Some("failed") { + ("fail", vec![format!("screen not reached: {record_reason}")]) + } else if !failed_checks.is_empty() { + ("fail", failed_checks) + } else if record_status == Some("skipped") { + ("not-run", vec![format!("skipped: {record_reason}")]) + } else if !unattributed.is_empty() { + ("inconclusive", unattributed) + } else if outcomes.is_empty() { + ( + "inconclusive", + vec!["reached, but the plan names no checks for this screen".to_owned()], + ) + } else if outcomes.iter().all(|(status, _, _)| *status == "pass") { + ("pass", Vec::new()) + } else { + ( + "inconclusive", + outcomes + .iter() + .filter(|(status, _, _)| *status != "pass") + .map(|(status, kind, reason)| format!("{kind} {status}: {reason}")) + .collect(), + ) + }; + json!({ + "screen": screen.id, + "routePath": screen.route_path, + "width": viewport.width, + "status": status, + "reasons": reasons, + "screenshot": reported.and_then(|screen| screen["screenshot"]["path"].as_str()), + }) +} + #[allow(clippy::too_many_arguments)] fn dimension_row( screen: &ScreenInput, @@ -1837,6 +1948,75 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// The screen table is what `devup_screen_plan status` reads: one row per + /// screen and width, a failed overflow check named as overflow, a failed + /// navigation pinned on its own route, and a width with no run not-run. + #[test] + fn the_screen_table_folds_checks_and_page_errors_per_width() { + let (dir, policy) = root(); + let (id, plan_file) = write_plan(&dir, &policy); + let mut report = report_for(&dir, &plan_file, &id, "pass", 800); + report["runs"][0]["screens"][0]["checks"][0]["status"] = json!("fail"); + report["runs"][0]["screens"][0]["checks"][0]["reason"] = json!("content reaches 412px"); + report["runs"][1]["environmentErrors"] = + json!([{"kind": "navigation-failed", "message": "/today: HTTP 500"}]); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + let row = |screen: &str, width: u32| { + result["screenTable"] + .as_array() + .unwrap() + .iter() + .find(|row| row["screen"] == screen && row["width"] == width) + .cloned() + .unwrap() + }; + assert_eq!(row("today", 360)["status"], "fail"); + assert_eq!( + row("today", 360)["reasons"][0], + "horizontal overflow: content reaches 412px" + ); + assert_eq!(row("today", 1200)["status"], "fail"); + assert_eq!( + row("today", 1200)["reasons"][0], + "page error: /today: HTTP 500" + ); + assert_eq!(row("next", 360)["status"], "pass"); + assert_eq!(row("next", 360)["screenshot"], "360-next.png"); + + report["runs"].as_array_mut().unwrap().truncate(1); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + let missing = result["screenTable"] + .as_array() + .unwrap() + .iter() + .filter(|row| row["width"] == 1200) + .collect::>(); + assert_eq!(missing.len(), 2); + assert!(missing.iter().all(|row| row["status"] == "not-run")); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + /// Only the screen's drawn width has a visual original, and its verified /// screenshot comes back as a ready comparison: what the audit knows is /// filled in, what only the consumer knows is named. From cdbcfc3f60a5f08de73456003fb750230a9e1ecd Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 21:29:16 +0900 Subject: [PATCH 05/24] Add devup_screen_plan and gate page-writing exports on a confirmed plan devup_screen_plan maps every Figma screen to a URL before any page is written (action draft | confirm | status). draft groups frames drawn at several widths, proposes a route only from an existing App Router page, a nav label bound to an existing href, or a navigationTargets entry, and turns every open decision into one questions array shaped for the runtime's question tool, written to .devup/screen-plan.json and .md. confirm refuses a partial set of answers by id; status reports each URL's implementation and per-width result from a runtime audit, never counting a missing width as pass. devup_figma_export with projectRoot now answers plan_required instead of writing a page (pageScaffold write, outputPaths under app/, several frames with outputPaths) when no confirmed plan covers the frames, takes the pageScaffold route from the plan, and refuses a different one. Explore's body moved into a helper so draft lists screens through the same read. Co-Authored-By: Claude Opus 5.5 --- crates/devup-mcp/src/server/guide.rs | 7 +- crates/devup-mcp/src/server/mod.rs | 259 ++- crates/devup-mcp/src/server/page_scaffold.rs | 5 +- crates/devup-mcp/src/server/screen_plan.rs | 1983 +++++++++++++++++ .../devup-mcp/src/server/screen_plan_tests.rs | 531 +++++ crates/devup-mcp/tests/screen_plan_tool.rs | 215 ++ crates/devup-mcp/tests/self_description.rs | 5 +- .../tests/stdio_schema_compat_smoke.rs | 4 +- crates/devup-mcp/tests/stdio_smoke.rs | 3 +- crates/devup-mcp/tests/stdio_tools.rs | 1 + 10 files changed, 2943 insertions(+), 70 deletions(-) create mode 100644 crates/devup-mcp/src/server/screen_plan.rs create mode 100644 crates/devup-mcp/src/server/screen_plan_tests.rs create mode 100644 crates/devup-mcp/tests/screen_plan_tool.rs diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index e181a6d1..b5ba6e9b 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -39,6 +39,8 @@ pub const INSTRUCTIONS: &str = concat!( "run devup_ui_validate and report wired/tested counts before functional completion.\n", "2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ", "workspace lacks and installs the ones it carries.\n", + "2c. Several pages: devup_screen_plan first; ask ALL its questions in ONE question-tool call, not prose ", + "(skill devup-implementation-plan).\n", "3. Request only outputs you will read. Before your second call, resources/read \"devup://guide/usage\" ", "for output sizing, verification boundaries, SECTION batching, assets and delivery." ); @@ -245,10 +247,13 @@ mod tests { /// The ceiling moved once, for rule 1a: the connection order has to be /// read before the first Figma call, because an agent that met a plugin /// attached and a `disconnected` direct path asked its user to log in. + /// It moved again for rule 2c: the screen plan has to come before the + /// first page is written, and an agent that has not read the guide yet + /// is exactly the one that writes pages straight away. #[test] fn instructions_are_far_smaller_than_the_guidance_they_point_at() { assert!( - INSTRUCTIONS.len() < 1_400, + INSTRUCTIONS.len() < 1_500, "instructions grew back to {} bytes; the per-session cost is the thing being fixed", INSTRUCTIONS.len() ); diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 8ae4b95e..dfb605a8 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -25,6 +25,7 @@ pub mod resources; mod result_contract; mod review; mod runtime_audit; +mod screen_plan; mod screenshot_observations; mod section_checklist; pub mod self_update; @@ -831,6 +832,130 @@ impl DevupServer { } /// Both paths to Figma as they stand right now. + /// `devup_figma_explore` without the tool envelope, so `devup_screen_plan` + /// lists screens through the same read rather than a second one. + async fn explore(&self, input: FigmaExploreInput) -> Result { + if let Some(hints) = &input.screenshot_observations { + hints.validate()?; + } + let target = self + .bind_to_bridge( + parse_link(input.url.as_deref())?, + Unnamed::Selection, + &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit,"screenshotObservations":input.screenshot_observations } }), + ) + .await + ?; + target.node_id.as_ref().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Exploring neighboring Figma screens requires a node-id.", + false, + ) + })?; + if !(1..=MATCH_CEILING).contains(&input.limit) { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "The explore limit must be between 1 and 100 inclusive.", + false, + )); + } + let requested_target = target.clone(); + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.resource_scope = ResourceScope::None; + request.explore = Some(ExploreReadOptions { + // Fixed, not derived from `limit`. Derived, it made `limit` two + // things at once - how many screens come back, and how much of the + // page the collection script is allowed to walk - and the second + // one is why limit 33 could answer with fewer screens than limit + // 30. Asking for the script's own ceiling every time also means + // one anchor's projection can be reused for a neighbour's, since + // every explore of a file now reads the same amount. + projection_limit: EXPLORE_PROJECTION_LIMIT, + text_preview_limit: if input.include_text_preview + || input.screenshot_observations.is_some() + { + 160 + } else { + 0 + }, + }); + let result = self + .start_operation( + PendingOperation::Explore { + // The projection ranks up to the ceiling and `limit` cuts + // the ranked list afterwards, in `apply_explore_limit`, so + // that what was found and what was returned are both known + // and can be reported apart. + limit: MATCH_CEILING, + target: requested_target, + }, + request, + input.refresh, + ) + .await?; + let mut result = result; + screenshot_observations::rank(&mut result, input.screenshot_observations.as_ref()); + Ok(apply_explore_limit(result, input.limit)) + } + + /// The screens `devup_screen_plan draft` plans: the caller's list from an + /// earlier explore, or one explore of the linked Section or selection. + async fn plan_screens( + &self, + input: &screen_plan::ScreenPlanInput, + ) -> Result<(Vec, Value), DevupError> { + if !input.screens.is_empty() { + return Ok(( + input.screens.clone(), + json!({"from": "caller", "url": input.url, + "nodeId": input.url.as_deref().and_then(|url| FigmaTarget::parse(url).ok()).and_then(|target| target.node_id)}), + )); + } + let explored = self + .explore(FigmaExploreInput { + screenshot_observations: None, + url: input.url.clone(), + limit: MATCH_CEILING, + include_text_preview: false, + refresh: input.refresh, + }) + .await?; + let candidates = explored["candidates"] + .as_array() + .cloned() + .unwrap_or_default(); + let screens_only = candidates + .iter() + .any(|candidate| candidate["node"]["kind"] == "screen"); + let screens = candidates + .iter() + .map(|candidate| &candidate["node"]) + .filter(|node| !screens_only || node["kind"] == "screen") + .filter_map(|node| { + Some(screen_plan::PlanScreenInput { + node_id: node["nodeId"].as_str()?.to_owned(), + name: node["name"].as_str().unwrap_or_default().to_owned(), + width: node["bounds"]["width"].as_f64()?, + height: node["bounds"]["height"].as_f64(), + parts: Vec::new(), + links: Vec::new(), + }) + }) + .collect(); + Ok(( + screens, + json!({ + "from": "devup_figma_explore", + "url": input.url, + "nodeId": explored["anchor"]["nodeId"], + "targetKind": explored["targetKind"], + "fileKey": explored["source"]["fileKey"], + "truncation": explored["truncation"], + }), + )) + } + async fn connection_report(&self) -> Result { let status = self.services.auth.status().await?; let direct = self.services.auth.direct_path_snapshot().await?; @@ -1555,73 +1680,14 @@ impl DevupServer { &self, Parameters(input): Parameters, ) -> Result { - if let Some(hints) = &input.screenshot_observations { - hints.validate().map_err(to_mcp_error)?; - } - let target = self - .bind_to_bridge( - parse_link(input.url.as_deref()).map_err(to_mcp_error)?, - Unnamed::Selection, - &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit,"screenshotObservations":input.screenshot_observations } }), - ) + self.explore(input) .await - .map_err(to_mcp_error)?; - target.node_id.as_ref().ok_or_else(|| { - to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Exploring neighboring Figma screens requires a node-id.", - false, - )) - })?; - if !(1..=MATCH_CEILING).contains(&input.limit) { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "The explore limit must be between 1 and 100 inclusive.", - false, - ))); - } - let requested_target = target.clone(); - let mut request = CollectionRequest::new(target, CollectionScope::Node); - request.resource_scope = ResourceScope::None; - request.explore = Some(ExploreReadOptions { - // Fixed, not derived from `limit`. Derived, it made `limit` two - // things at once - how many screens come back, and how much of the - // page the collection script is allowed to walk - and the second - // one is why limit 33 could answer with fewer screens than limit - // 30. Asking for the script's own ceiling every time also means - // one anchor's projection can be reused for a neighbour's, since - // every explore of a file now reads the same amount. - projection_limit: EXPLORE_PROJECTION_LIMIT, - text_preview_limit: if input.include_text_preview - || input.screenshot_observations.is_some() - { - 160 - } else { - 0 - }, - }); - let result = self - .start_operation( - PendingOperation::Explore { - // The projection ranks up to the ceiling and `limit` cuts - // the ranked list afterwards, in `apply_explore_limit`, so - // that what was found and what was returned are both known - // and can be reported apart. - limit: MATCH_CEILING, - target: requested_target, - }, - request, - input.refresh, - ) - .await - .map_err(to_mcp_error)?; - let mut result = result; - screenshot_observations::rank(&mut result, input.screenshot_observations.as_ref()); - Ok(tool_result(apply_explore_limit(result, input.limit))) + .map(tool_result) + .map_err(to_mcp_error) } #[tool( - description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. \ + description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. 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 (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. \ @@ -1647,6 +1713,49 @@ impl DevupServer { } } } + // Before anything is validated or collected: a page write without a + // confirmed screen plan is answered with the plan to make, and a + // pageScaffold route comes from the plan when one covers the frame. + if workflow.job_id.is_none() + && let Some(project_root) = input.project_root.clone() + { + let mut frames = input.frame_ids.clone(); + if frames.is_empty() { + let node = match (&input.url, &input.artifact_id) { + (Some(url), _) => FigmaTarget::parse(url).ok().and_then(|t| t.node_id), + (None, Some(id)) => self + .artifacts + .get(id) + .await + .and_then(|artifact| artifact.payload.target.node_id.clone()), + _ => None, + }; + frames.extend(node); + } + let request = screen_plan::ExportRequest { + project_root: &project_root, + url: input.url.as_deref(), + frames, + all_screens: input.all_screens, + scaffold_write: input.page_scaffold.as_ref().is_some_and(|s| s.write), + scaffold_route: input.page_scaffold.as_ref().map(|s| s.route.as_str()), + output_paths: input + .output_paths + .iter() + .filter(|(key, _)| !key.ends_with("pageScaffold")) + .map(|(_, path)| path.as_str()) + .collect(), + }; + match screen_plan::export_gate(&request).map_err(to_mcp_error)? { + screen_plan::Gate::Open => {} + screen_plan::Gate::Blocked(response) => return Ok(tool_result(response)), + screen_plan::Gate::Route(route) => { + if let Some(scaffold) = input.page_scaffold.as_mut() { + scaffold.route = route; + } + } + } + } projection::page_scaffold::validate(input.page_scaffold.as_ref(), &input.outputs) .map_err(to_mcp_error)?; validation::validate_asset_budget(&input.asset_requests).map_err(to_mcp_error)?; @@ -2478,6 +2587,30 @@ impl DevupServer { .map_err(to_mcp_error)?; Ok(tool_result(result)) } + #[tool( + description = "Plan which Figma screen becomes which URL before any page is written, confirm it once, then report it URL by width (action: draft | confirm | status). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore), groups frames drawn at several widths, and proposes a route only from evidence: an existing App Router page, a nav link label bound to an existing href, or a navigationTargets entry. Anything else is proposedRoute null, routeEvidence UNVERIFIED, and a question. It writes .devup/screen-plan.json and .md and returns `questions`, already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed). Ask ALL of them in ONE call to your runtime's question tool (`question` in OpenCode, `AskUserQuestion` in Claude Code) - never one at a time and never as prose - and do not implement before confirm. confirm takes every answer keyed by question id and refuses the call, naming the missing ids, if any is unanswered. 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. The devup-implementation-plan skill (devup_skills) carries the whole workflow.", + output_schema = permissive_object_output_schema() + )] + async fn devup_screen_plan( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = match input.action.as_str() { + "draft" => { + let (screens, source) = self.plan_screens(&input).await.map_err(to_mcp_error)?; + screen_plan::draft(&input, &screens, source, &self.output_policy).await + } + "confirm" => screen_plan::confirm(&input, &self.output_policy), + "status" => screen_plan::status(&input, &self.output_policy, &self.artifacts).await, + other => Err(DevupError::new( + ErrorCode::DevupInvalidInput, + format!("action must be draft, confirm or status, not {other}."), + false, + )), + } + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } } /// Adds what `devup_ui_validate` checked beyond its rules, kept apart from diff --git a/crates/devup-mcp/src/server/page_scaffold.rs b/crates/devup-mcp/src/server/page_scaffold.rs index 3a7f6b5b..41e9570b 100644 --- a/crates/devup-mcp/src/server/page_scaffold.rs +++ b/crates/devup-mcp/src/server/page_scaffold.rs @@ -13,7 +13,10 @@ use std::{ // Object-form false schema keeps strict option parsing compatible with MCP clients. #[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] pub struct PageScaffoldOptions { - /// Caller-supplied App Router route, e.g. /settings/profile or / for the root page. + /// App Router route, e.g. /settings/profile or / for the root page. Omit + /// it when a confirmed devup_screen_plan covers the frame: the plan's + /// route is used, and a different one is refused as a mismatch. + #[serde(default)] pub route: String, /// Preview by default, including assets and other outputPaths in this call. #[serde(default)] diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs new file mode 100644 index 00000000..87a9bcf4 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -0,0 +1,1983 @@ +//! `devup_screen_plan`: which Figma screen becomes which URL, decided and +//! confirmed before any page is written. +//! +//! An agent handed "implement this design" used to export and write pages +//! straight away. Afterwards nobody could say which Figma screen had become +//! which URL, so nobody could say what had been built or whether it worked: +//! on one site the pages rendered "This page couldn't load" at some widths and +//! were cut off on the right at others, and there was no URL-by-width table on +//! which anyone could have noticed. +//! +//! So the order is fixed. `draft` maps every screen to a route from evidence +//! only - an existing App Router page, a nav label already bound to an href, +//! or an explicit `navigationTargets` entry - and turns everything it cannot +//! decide into one batch of questions shaped for the runtime's question tool. +//! `confirm` takes every answer at once and refuses to proceed with any +//! missing. `status` reads the confirmed plan against the routes on disk and a +//! runtime audit report, one row per URL and width, where a width nobody ran is +//! `not-run` and never `pass`. +//! +//! Nothing here reaches Figma or runs a browser. The screen list comes from +//! `devup_figma_explore` (or the caller, from an earlier explore), the routes +//! from `devup_project_context`, the links from the navigation parser and the +//! widths from `devup_runtime_audit`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use devup_mcp_figma::{DevupError, ErrorCode, FigmaTarget}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use super::artifacts::ArtifactStore; +use super::output::{OutputPolicy, OutputTransaction}; + +pub(super) const PLAN_KIND: &str = "devup-screen-plan"; +const SCHEMA_VERSION: u64 = 1; +/// Where the plan lives, relative to the project root. The export gate reads +/// it from the same place, so the two cannot disagree about which plan holds. +pub(super) const PLAN_FILE: &str = ".devup/screen-plan.json"; +const PLAN_MARKDOWN: &str = ".devup/screen-plan.md"; +pub(super) const ASK_INSTRUCTION: &str = "Ask all of these in ONE call to your question tool; do not ask in prose; do not implement before confirm."; +/// The question tools this is shaped for cut headers short. OpenCode's has no +/// hard limit; 30 characters stays readable in all of them. +pub(super) const HEADER_LIMIT: usize = 30; +/// Claude Code's AskUserQuestion takes two to four options and adds its own +/// "Other" for a custom answer. Four is the most every runtime accepts. +const MAX_OPTIONS: usize = 4; +const RECOMMENDED: &str = " (Recommended)"; +const MAX_SCREENS: usize = 100; +const MAX_PLAN_BYTES: usize = 2 * 1024 * 1024; +/// The widths `devup_runtime_audit` plans by default. Without a report they +/// are what `status` lists as not-run, because the failures this tool exists +/// to surface were at widths nobody drew. +const DEFAULT_WIDTHS: [u32; 4] = [1440, 1100, 858, 390]; +const MAX_SCANNED_SOURCES: usize = 400; +const MAX_SOURCE_BYTES: u64 = 512 * 1024; +const SKIP: &str = "Skip this screen"; +const NOT_A_PAGE: &str = "Not a page (part of another screen)"; + +const MODAL_WORDS: &[&str] = &[ + "modal", + "popup", + "pop-up", + "pop up", + "dialog", + "overlay", + "bottom sheet", + "bottomsheet", + "drawer", + "toast", + "팝업", + "모달", + "레이어", +]; +const DEVICE_WORDS: &[&str] = &[ + "pc", "mobile", "mo", "mob", "m", "tablet", "tab", "desktop", "web", "dt", +]; +const ROOT_WORDS: &[&str] = &["home", "main", "index", "landing", "메인", "홈"]; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ScreenPlanInput { + /// draft: map screens to routes and return every open question. + /// confirm: apply all answers at once. status: URL x width report. + #[schemars(extend("enum" = ["draft", "confirm", "status"]))] + pub action: String, + /// The Next App Router project. The plan is written to + /// /.devup/screen-plan.json and .md, under an allowed root. + pub project_root: Option, + /// draft: Figma Section or frame link. Omitted while one Devup Bridge + /// plugin is attached, the Figma selection is the anchor. + #[serde(default)] + pub url: Option, + /// draft: screens already listed by devup_figma_explore or search. When + /// given, Figma is not read again. + #[serde(default)] + pub screens: Vec, + /// draft: explicit Figma node id -> route bindings. The only way to name a + /// route that no page or nav link already shows. + #[serde(default)] + pub navigation_targets: BTreeMap, + /// draft: free text recorded in the plan, never interpreted. + #[serde(default)] + pub notes: Option, + /// confirm/status: the planId draft returned. + #[serde(default)] + pub plan_id: Option, + /// confirm: every question id -> the chosen option label (or labels when + /// multiple), or custom text. + #[serde(default)] + pub answers: BTreeMap, + /// status: the runtime audit plan JSON, together with auditReportPath. + #[serde(default)] + pub audit_plan_path: Option, + /// status: the report.json the runtime audit executor wrote. + #[serde(default)] + pub audit_report_path: Option, + /// draft: re-read Figma instead of reusing a cached exploration. + #[serde(default)] + pub refresh: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum AnswerInput { + One(String), + Many(Vec), +} + +impl AnswerInput { + fn values(&self) -> Vec<&str> { + match self { + Self::One(value) => vec![value.as_str()], + Self::Many(values) => values.iter().map(String::as_str).collect(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanScreenInput { + pub node_id: String, + pub name: String, + pub width: f64, + #[serde(default)] + pub height: Option, + /// Shared part names the caller saw in this screen, e.g. Header, Footer. + #[serde(default)] + pub parts: Vec, + /// Links the caller saw in this screen, e.g. prototype destinations. + #[serde(default)] + pub links: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanLinkInput { + pub label: String, + #[serde(default)] + pub destination_node_id: Option, + #[serde(default)] + pub url: Option, +} + +fn invalid(message: impl Into) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} + +fn text(value: &Value) -> &str { + value.as_str().unwrap_or_default() +} + +fn array(value: &Value) -> &[Value] { + value.as_array().map(Vec::as_slice).unwrap_or_default() +} + +fn sha256(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn now() -> String { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default(); + super::format_epoch_rfc3339(seconds) +} + +/// What the project already says about its routes, gathered once per call. +#[derive(Debug, Clone, Default)] +pub(super) struct ProjectEvidence { + /// (route pattern, page file) from the App Router inventory. + pub routes: Vec<(String, String)>, + /// (link label, href, file:line) for every static link in scanned sources. + pub nav_links: Vec<(String, String, String)>, + /// Components the app's layouts import, so every page already shares them. + pub layout_parts: Vec, + /// `src/app` or `app`, whichever the existing pages use. + pub app_dir: String, + pub inventory_note: Option, +} + +/// Reads routes, layout components and static links through the same +/// inventory `devup_project_context scope:"ui"` returns, then the navigation +/// parser `devup_ui_validate` uses on each scanned file. +pub(super) async fn project_evidence(project_root: &str) -> Result { + let inventory = super::project_context::run("ui", Some(project_root), None).await?; + let mut evidence = ProjectEvidence { + app_dir: "src/app".into(), + ..ProjectEvidence::default() + }; + if inventory["found"] != true { + evidence.inventory_note = Some( + "No App Router source was found; every route is unverified until pages exist or navigationTargets name them." + .into(), + ); + return Ok(evidence); + } + if inventory["truncation"]["truncated"] == true || inventory["unparsedFiles"] != 0 { + evidence.inventory_note = Some( + "The route inventory is truncated or has unparsed files; a route missing here may still exist.".into(), + ); + } + let root = PathBuf::from(text(&inventory["projectRoot"])); + let mut sources = BTreeSet::new(); + for route in array(&inventory["routes"]) { + evidence.routes.push(( + text(&route["route"]).to_owned(), + text(&route["page"]).to_owned(), + )); + sources.insert(text(&route["page"]).to_owned()); + for component in array(&route["components"]) { + sources.insert(text(component).to_owned()); + } + } + if evidence + .routes + .iter() + .any(|(_, page)| page.starts_with("app/")) + && !evidence + .routes + .iter() + .any(|(_, page)| page.starts_with("src/app/")) + { + evidence.app_dir = "app".into(); + } + for component in array(&inventory["components"]) { + sources.insert(text(&component["path"]).to_owned()); + let in_layout = array(&component["importedBy"]).iter().any(|site| { + let path = text(&site["path"]); + path.ends_with("layout.tsx") || path.ends_with("layout.jsx") + }); + if in_layout { + for name in array(&component["exports"]) { + let name = text(name); + if !name.is_empty() + && name != "default" + && !evidence.layout_parts.iter().any(|part| part == name) + { + evidence.layout_parts.push(name.to_owned()); + } + } + } + } + for relative in sources + .iter() + .filter(|path| !path.is_empty()) + .take(MAX_SCANNED_SOURCES) + { + let path = root.join(relative); + if std::fs::metadata(&path).map_or(true, |meta| meta.len() > MAX_SOURCE_BYTES) { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + for element in array(&super::navigation::source_evidence(&source)["elements"]) { + if element["isLink"] == true + && let Some(href) = element["href"].as_str() + && href.starts_with('/') + && !text(&element["name"]).is_empty() + { + evidence.nav_links.push(( + text(&element["name"]).to_owned(), + href.to_owned(), + format!("{relative}:{}", element["line"]), + )); + } + } + } + Ok(evidence) +} + +/// Lowercased words of a layer name with widths and device words removed, so +/// `About - 1440`, `[MO] About_390` and `about` are one screen. +fn base_words(name: &str) -> Vec { + let lowered = name.to_lowercase(); + let words = lowered + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_owned) + .collect::>(); + let kept = words + .iter() + .filter(|word| { + let digits = word.trim_end_matches("px"); + let width = !digits.is_empty() + && digits.chars().all(|c| c.is_ascii_digit()) + && digits + .parse::() + .is_ok_and(|n| (240..=4000).contains(&n)); + !width && !DEVICE_WORDS.contains(&word.as_str()) + }) + .cloned() + .collect::>(); + if kept.is_empty() { words } else { kept } +} + +/// The URL slug a name would have, from its ASCII words only. Empty for a +/// name with none, which is why a slug is offered as an option and never +/// proposed on its own. +fn slug(words: &[String]) -> String { + words + .iter() + .filter(|word| word.chars().all(|c| c.is_ascii_alphanumeric())) + .cloned() + .collect::>() + .join("-") +} + +fn compact(value: &str) -> String { + value + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + +fn is_modal(name: &str) -> bool { + let lowered = name.to_lowercase(); + MODAL_WORDS.iter().any(|word| lowered.contains(word)) +} + +/// The same rule `pageScaffold.route` is held to, so a confirmed route is one +/// the export will accept. +pub(super) fn valid_route(route: &str) -> bool { + let trimmed = route.trim_matches('/'); + route.starts_with('/') + && !route.starts_with("//") + && !route.ends_with("//") + && trimmed.split('/').all(|segment| { + (trimmed.is_empty() || !segment.is_empty()) + && segment != "." + && segment != ".." + && segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.[]()@".contains(c)) + }) +} + +fn normalize_route(href: &str) -> Option { + let path = href.split(['?', '#']).next().unwrap_or_default().trim(); + if !path.starts_with('/') || path.starts_with("//") { + return None; + } + let trimmed = path.trim_end_matches('/'); + Some(if trimmed.is_empty() { + "/".to_owned() + } else { + trimmed.to_owned() + }) +} + +fn truncate(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + return value.to_owned(); + } + let mut cut = value.chars().take(limit - 1).collect::(); + cut.push('…'); + cut +} + +fn header(prefix: &str, name: &str) -> String { + truncate(&format!("{prefix}: {name}"), HEADER_LIMIT) +} + +/// One option as the question tool shows it, and what choosing it does. +struct Choice { + label: String, + description: String, + action: Value, +} + +fn choice(label: impl Into, description: impl Into, action: Value) -> Choice { + Choice { + label: label.into(), + description: description.into(), + action, + } +} + +/// A question in the shape every runtime's question tool takes: the +/// recommended option first with " (Recommended)" on its label, two to four +/// options, and custom answers allowed. `resolve` stays in the plan file and is +/// stripped from what the caller is asked. +fn question(id: String, header: String, prompt: String, choices: Vec, kind: &str) -> Value { + let mut seen = BTreeSet::new(); + let choices = choices + .into_iter() + .filter(|choice| seen.insert(choice.label.clone())) + .take(MAX_OPTIONS) + .collect::>(); + let options = choices + .iter() + .enumerate() + .map(|(index, choice)| { + json!({ + "label": if index == 0 { format!("{}{RECOMMENDED}", choice.label) } else { choice.label.clone() }, + "description": choice.description, + }) + }) + .collect::>(); + let resolve = choices + .iter() + .map(|choice| json!({"label": choice.label, "action": choice.action})) + .collect::>(); + json!({ + "id": id, + "header": header, + "question": prompt, + "options": options, + "multiple": false, + "custom": true, + "kind": kind, + "resolve": resolve, + }) +} + +/// The questions as they go to the question tool: no resolution table. +fn ask_shape(questions: &[Value]) -> Vec { + questions + .iter() + .map(|question| { + let mut shaped = question.clone(); + if let Some(object) = shaped.as_object_mut() { + object.remove("resolve"); + object.remove("kind"); + } + shaped + }) + .collect() +} + +#[derive(Debug, Clone)] +struct Frame { + node_id: String, + name: String, + width: f64, + height: Option, +} + +#[derive(Debug, Clone)] +struct DraftScreen { + frames: Vec, + words: Vec, + parts: Vec, + links: Vec, +} + +impl DraftScreen { + fn id(&self) -> &str { + &self.frames[0].node_id + } + fn name(&self) -> &str { + &self.frames[0].name + } + fn widths(&self) -> Vec { + let mut widths = self + .frames + .iter() + .map(|frame| frame.width.round().max(0.0) as u32) + .collect::>(); + widths.sort_unstable_by(|a, b| b.cmp(a)); + widths.dedup(); + widths + } +} + +/// Frames that share a base name and could be one screen at several widths +/// become one screen, widest first. The rule is the one `responsiveTsx` is +/// refused by, so a group here is a group the export can merge. +fn group_screens(screens: &[PlanScreenInput]) -> Vec { + let mut order = Vec::::new(); + let mut groups = BTreeMap::>::new(); + for screen in screens { + let key = base_words(&screen.name).join(" "); + if !groups.contains_key(&key) { + order.push(key.clone()); + } + groups.entry(key).or_default().push(screen); + } + let mut result = Vec::new(); + for key in order { + let members = &groups[&key]; + // Asked of the base name, not the layer name: `Main - 1440` and + // `Main - 390` are one name across two widths once the width is gone. + let named = members + .iter() + .map(|screen| (key.as_str(), screen.width)) + .collect::>(); + let family = members.len() > 1 + && devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible(&named); + let make = |members: Vec<&PlanScreenInput>| { + let mut frames = members + .iter() + .map(|screen| Frame { + node_id: screen.node_id.clone(), + name: screen.name.clone(), + width: screen.width, + height: screen.height, + }) + .collect::>(); + frames.sort_by(|a, b| b.width.total_cmp(&a.width)); + let mut parts = Vec::::new(); + let mut links = Vec::new(); + for screen in &members { + for part in &screen.parts { + if !parts.contains(part) { + parts.push(part.clone()); + } + } + links.extend(screen.links.iter().cloned()); + } + DraftScreen { + frames, + words: base_words(&members[0].name), + parts, + links, + } + }; + if family { + result.push(make(members.clone())); + } else { + for member in members { + result.push(make(vec![member])); + } + } + } + result +} + +/// Every route the evidence supports for one screen, each with where it came +/// from. Several different routes are a conflict and are asked, not picked. +fn route_evidence( + screen: &DraftScreen, + targets: &BTreeMap, + evidence: &ProjectEvidence, +) -> (Vec<(String, String)>, Vec) { + let mut notes = Vec::new(); + let explicit = screen + .frames + .iter() + .filter_map(|frame| { + let target = targets.get(&frame.node_id)?; + match normalize_route(target) { + Some(route) => Some((route, format!("navigationTargets[{}]", frame.node_id))), + None => { + notes.push(format!( + "navigationTargets[{}] = {target:?} is not an app route and was not used.", + frame.node_id + )); + None + } + } + }) + .collect::>(); + if !explicit.is_empty() { + return (explicit, notes); + } + let mut found = Vec::new(); + let screen_slug = slug(&screen.words); + let is_root = screen + .words + .iter() + .any(|word| ROOT_WORDS.contains(&word.as_str())); + for (route, page) in &evidence.routes { + // The last segment only: `/news/[id]` is a detail page, and a screen + // named News is the list, not that page. + let last = route + .trim_matches('/') + .rsplit('/') + .next() + .unwrap_or_default(); + if (!screen_slug.is_empty() && last == screen_slug) || (route == "/" && is_root) { + found.push((route.clone(), format!("existing-route: {page}"))); + } + } + let name = compact(&screen.words.join("")); + for (label, href, location) in &evidence.nav_links { + let label_key = compact(label); + if label_key.is_empty() || label_key != name { + continue; + } + if let Some((route, _)) = evidence + .routes + .iter() + .find(|(route, _)| super::navigation::matches_route(route, href)) + { + found.push(( + route.clone(), + format!("nav-label: {label:?} -> {href} ({location})"), + )); + } + } + (found, notes) +} + +fn existing_page<'a>(route: &str, evidence: &'a ProjectEvidence) -> Option<&'a str> { + evidence + .routes + .iter() + .find(|(candidate, _)| candidate == route) + .map(|(_, page)| page.as_str()) +} + +fn planned_page(route: &str, app_dir: &str) -> String { + let route = route.trim_matches('/'); + if route.is_empty() { + format!("{app_dir}/page.tsx") + } else { + format!("{app_dir}/{route}/page.tsx") + } +} + +/// Builds the draft plan. Pure: every input is passed in, so the rules are +/// tested without Figma or a project on disk. +pub(super) fn draft_plan( + screens: &[PlanScreenInput], + targets: &BTreeMap, + evidence: &ProjectEvidence, + source: Value, + notes: Option<&str>, +) -> Result { + if screens.is_empty() { + return Err(invalid( + "draft found no screens. Link a Section or frame, or pass screens from devup_figma_explore.", + )); + } + if screens.len() > MAX_SCREENS { + return Err(invalid(format!( + "draft plans at most {MAX_SCREENS} screens; link one Section at a time." + ))); + } + let drafts = group_screens(screens); + let mut rows = Vec::new(); + let mut questions = Vec::new(); + let mut route_owner = BTreeMap::>::new(); + let mut resolved_routes = Vec::new(); + for (index, screen) in drafts.iter().enumerate() { + let (found, evidence_notes) = route_evidence(screen, targets, evidence); + let distinct = found + .iter() + .map(|(route, _)| route.clone()) + .collect::>(); + let proposed = (distinct.len() == 1).then(|| found[0].0.clone()); + if let Some(route) = &proposed { + route_owner.entry(route.clone()).or_default().push(index); + } + resolved_routes.push((proposed, found, evidence_notes)); + } + // Two screens on one URL is a decision, not a fact: neither keeps it unasked. + for owners in route_owner.values().filter(|owners| owners.len() > 1) { + for index in owners { + let entry = &mut resolved_routes[*index]; + if let Some(route) = entry.0.take() { + entry.2.push(format!( + "{route} is also the evidence-backed route of another screen in this plan." + )); + } + } + } + let claimed = resolved_routes + .iter() + .filter_map(|(route, _, _)| route.clone()) + .collect::>(); + let page_screens = drafts + .iter() + .enumerate() + .filter(|(_, screen)| !is_modal(screen.name())) + .map(|(index, _)| index) + .collect::>(); + let frame_owner = drafts + .iter() + .enumerate() + .flat_map(|(index, screen)| { + screen + .frames + .iter() + .map(move |frame| (frame.node_id.clone(), index)) + }) + .collect::>(); + let mut part_screens = BTreeMap::>::new(); + let mut grouped = Vec::new(); + for (index, screen) in drafts.iter().enumerate() { + let (proposed, found, evidence_notes) = &resolved_routes[index]; + let modal = is_modal(screen.name()); + let id = screen.id().to_owned(); + let name = screen.name().to_owned(); + let widths = screen.widths(); + let mut decisions = Vec::new(); + let candidates = found + .iter() + .map(|(route, _)| route.clone()) + .collect::>(); + let screen_slug = slug(&screen.words); + let new_route = (!screen_slug.is_empty()).then(|| format!("/{screen_slug}")); + let similar = |route: &str| { + route + .trim_matches('/') + .split(['/', '-']) + .filter(|segment| screen.words.iter().any(|word| word == segment)) + .count() + }; + let mut unclaimed = evidence + .routes + .iter() + .filter(|(route, _)| !claimed.contains(route) && !candidates.contains(route)) + .map(|(route, page)| (similar(route), route.clone(), page.clone())) + .collect::>(); + unclaimed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + let route_choice = |route: &str, why: &str| { + let description = match existing_page(route, evidence) { + Some(page) => format!("{why} Exists at {page}; the screen is merged into it."), + None => format!( + "{why} New page at {}.", + planned_page(route, &evidence.app_dir) + ), + }; + choice(route, description, json!({"route": route})) + }; + if modal { + decisions.push("modal-or-route"); + let mut hosts = page_screens + .iter() + .filter(|other| **other != index) + .map(|other| &drafts[*other]) + .map(|host| { + let shared = host + .words + .iter() + .filter(|word| screen.words.contains(word)) + .count(); + (shared, host) + }) + .collect::>(); + hosts.sort_by_key(|host| std::cmp::Reverse(host.0)); + let state_choices = hosts + .iter() + .take(2) + .map(|(_, host)| { + choice( + format!("State on {}", truncate(host.name(), 40)), + format!( + "Open/close state inside the page built from {} ({}); no URL of its own.", + host.name(), + host.id() + ), + json!({"state": host.id()}), + ) + }) + .collect::>(); + let own_route = proposed + .as_ref() + .map(|route| route_choice(route, "Its own URL, from route evidence.")) + .or_else(|| { + new_route + .as_ref() + .map(|route| route_choice(route, "Its own URL, named after the layer.")) + }); + let mut choices = Vec::new(); + if proposed.is_some() { + choices.extend(own_route); + choices.extend(state_choices); + } else { + choices.extend(state_choices); + choices.extend(own_route); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + questions.push(question( + format!("modal:{id}"), + header("Modal", &name), + format!( + "\"{name}\" ({id}) looks like a modal or overlay. Is it a state of another screen, or a page with its own URL?" + ), + choices, + "modal", + )); + } else if proposed.is_none() { + decisions.push(if candidates.len() > 1 { + "conflicting-route-evidence" + } else { + "no-route-evidence" + }); + let mut choices = Vec::new(); + for route in &candidates { + choices.push(route_choice(route, "Named by conflicting evidence.")); + } + for (_, route, _) in unclaimed.iter().filter(|(score, _, _)| *score > 0) { + choices.push(route_choice(route, "Existing route with a similar name.")); + } + if let Some(route) = new_route.as_ref().filter(|route| !claimed.contains(*route)) { + choices.push(route_choice(route, "Named after the layer.")); + } + for (_, route, _) in unclaimed.iter().filter(|(score, _, _)| *score == 0).take(1) { + choices.push(route_choice( + route, + "Existing route not claimed by another screen.", + )); + } + choices.truncate(MAX_OPTIONS - 1); + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let reason = if candidates.len() > 1 { + format!( + "The evidence names {} different routes.", + candidates.iter().cloned().collect::>().join(", ") + ) + } else { + "No existing route, nav link or navigationTargets entry names it.".to_owned() + }; + questions.push(question( + format!("route:{id}"), + header("Route", &name), + format!( + "Which URL should the Figma screen \"{name}\" ({id}, drawn at {}) be served at? {reason}", + widths.iter().map(u32::to_string).collect::>().join("/") + ), + choices, + "route", + )); + } + let existing = proposed + .as_deref() + .and_then(|route| existing_page(route, evidence)); + if !modal && let (Some(route), Some(page)) = (proposed, existing) { + decisions.push("collides-with-existing-page"); + questions.push(question( + format!("collision:{id}"), + header("Existing page", route), + format!( + "{route} already has a page ({page}). What should building \"{name}\" ({id}) do to it?" + ), + vec![ + choice( + "Merge into the existing page", + "Keep the page file and its data wiring; replace its markup with the screen.", + json!({"collision": "merge"}), + ), + choice( + "Overwrite the existing page", + "Replace the page file outright; existing wiring is lost.", + json!({"collision": "overwrite"}), + ), + choice(SKIP, "Leave the existing page as it is.", json!({"collision": "skip"})), + ], + "collision", + )); + } + if let Some(route) = proposed.as_ref().filter(|route| route.contains('[')) { + decisions.push("dynamic-segment"); + questions.push(question( + format!("dynamic:{id}"), + header("Dynamic route", route), + format!( + "{route} has a dynamic segment. Where does \"{name}\" get the record it shows?" + ), + vec![ + choice( + "Sample data from the design", + "Render the Figma content as fixture data for one id; wire real data later.", + json!({"data": "sample"}), + ), + choice( + "Wire to the API now", + "Fetch the record by the segment value; needs the endpoint from devup_project_context scope api.", + json!({"data": "api"}), + ), + ], + "dynamic", + )); + } + if widths.len() > 1 { + decisions.push("several-widths"); + grouped.push(format!( + "{name} ({})", + widths + .iter() + .map(u32::to_string) + .collect::>() + .join("/") + )); + } + 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"}) + }) + }) + }); + if resolved.is_none() { + decisions.push("unresolved-link"); + let mut choices = vec![choice( + "Leave unlinked", + "Render it without an href; wire it once the destination exists.", + json!({"link": null}), + )]; + for (route, _) in evidence.routes.iter().take(MAX_OPTIONS - 1) { + choices.push(choice( + route.as_str(), + format!("Link to the existing route {route}."), + json!({"link": route}), + )); + } + if choices.len() < 2 { + choices.push(choice( + "Remove the link", + "Render the label as plain text.", + json!({"link": "remove"}), + )); + } + questions.push(question( + format!("link:{id}:{link_index}"), + header("Link", &link.label), + format!( + "\"{}\" on \"{name}\" ({id}) links somewhere this plan cannot see{}. Where should it go?", + link.label, + link.destination_node_id + .as_deref() + .map(|node| format!(" (Figma destination {node})")) + .unwrap_or_default() + ), + choices, + "link", + )); + } + links.push(json!({"label": link.label, "destinationNodeId": link.destination_node_id, "resolved": resolved})); + } + let mut shared = evidence.layout_parts.clone(); + for part in &screen.parts { + part_screens + .entry(part.clone()) + .or_default() + .push(id.clone()); + if !shared.contains(part) { + shared.push(part.clone()); + } + } + rows.push(json!({ + "id": id, + "name": name, + "kind": if modal { "modal" } else { "page" }, + "frames": screen.frames.iter().map(|frame| json!({"nodeId": frame.node_id, "name": frame.name, "width": frame.width, "height": frame.height})).collect::>(), + "drawnWidths": widths, + "proposedRoute": proposed, + "routeEvidence": if proposed.is_some() { json!(found.iter().map(|(_, why)| why.clone()).collect::>().join("; ")) } else { json!("UNVERIFIED") }, + "routeCandidates": candidates, + "existingFile": existing, + "sharedParts": shared, + "links": links, + "openDecisions": decisions, + "notes": evidence_notes, + })); + } + if !grouped.is_empty() { + questions.push(question( + "responsive".into(), + "Widths: one URL or several".into(), + format!( + "These screens are drawn at several widths: {}. Should each be one responsive route, or a separate route per width?", + grouped.join(", ") + ), + vec![ + choice( + "One responsive route", + "Each screen keeps one URL; its widths become devup-ui responsive arrays (responsiveTsx).", + json!({"responsive": "single"}), + ), + choice( + "Separate route per width", + "The widest frame keeps the URL; each narrower width gets /.", + json!({"responsive": "separate"}), + ), + ], + "responsive", + )); + } + for (part, owners) in part_screens.iter().filter(|(part, owners)| { + owners.len() > 1 + && !evidence + .layout_parts + .iter() + .any(|existing| compact(existing) == compact(part)) + }) { + questions.push(question( + format!("layout:{}", compact(part)), + header("Shared part", part), + format!( + "\"{part}\" appears on {} screens and no layout renders it yet. Extract it once into a shared layout?", + owners.len() + ), + vec![ + choice( + "Extract into a shared layout", + "Build it once and render it from the App Router layout.", + json!({"layout": "shared"}), + ), + choice( + "Keep it in each page", + "Every page renders its own copy.", + json!({"layout": "per-page"}), + ), + ], + "layout", + )); + } + let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes}); + let plan_id = format!( + "sp-{}", + &sha256(&serde_json::to_vec(&body).unwrap_or_default())[..16] + ); + Ok(json!({ + "kind": PLAN_KIND, + "schemaVersion": SCHEMA_VERSION, + "planId": plan_id, + "status": "draft", + "source": body["source"], + "notes": notes, + "appDir": evidence.app_dir, + "inventoryNote": evidence.inventory_note, + "screens": body["screens"], + "questions": body["questions"], + "answers": {}, + "routes": [], + })) +} + +fn plan_path(project_root: &str, relative: &str) -> String { + Path::new(project_root) + .join(relative) + .to_string_lossy() + .into_owned() +} + +/// Writes the plan JSON and its Markdown twin in one transaction, so the two +/// never describe different plans. +fn write_plan( + policy: &OutputPolicy, + project_root: &str, + plan: &Value, +) -> Result { + let json_target = policy.resolve(&plan_path(project_root, PLAN_FILE))?; + let markdown_target = policy.resolve(&plan_path(project_root, PLAN_MARKDOWN))?; + let written = json!([ + json_target.display_path().display().to_string(), + markdown_target.display_path().display().to_string(), + ]); + let bytes = serde_json::to_vec_pretty(plan) + .map_err(|error| invalid(format!("Cannot serialize the screen plan: {error}")))?; + let mut transaction = OutputTransaction::new(); + transaction.stage("screenPlan", json_target, &bytes)?; + transaction.stage( + "screenPlanMarkdown", + markdown_target, + markdown(plan).as_bytes(), + )?; + transaction.commit()?; + Ok(written) +} + +/// Reads `/.devup/screen-plan.json`, or `None` when there is +/// none. A file that exists but is not a plan is an error, never "no plan". +pub(super) fn read_plan(project_root: &str) -> Result, DevupError> { + let path = Path::new(project_root).join(PLAN_FILE); + let Ok(metadata) = std::fs::metadata(&path) else { + return Ok(None); + }; + if metadata.len() > MAX_PLAN_BYTES as u64 { + return Err(invalid(format!( + "{} exceeds the plan size limit.", + path.display() + ))); + } + let bytes = std::fs::read(&path) + .map_err(|error| invalid(format!("Cannot read {}: {error}", path.display())))?; + let plan: Value = serde_json::from_slice(&bytes) + .map_err(|error| invalid(format!("{} is not valid JSON: {error}", path.display())))?; + if plan["kind"] != PLAN_KIND || plan["schemaVersion"] != SCHEMA_VERSION { + return Err(invalid(format!( + "{} is not a devup screen plan; create one with devup_screen_plan action draft.", + path.display() + ))); + } + Ok(Some(plan)) +} + +fn require_root(input: &ScreenPlanInput) -> Result<&str, DevupError> { + input + .project_root + .as_deref() + .filter(|root| !root.trim().is_empty()) + .ok_or_else(|| invalid(format!("action {} needs projectRoot.", input.action))) +} + +/// `draft`: plan the given screens against the project and write both files. +pub(super) async fn draft( + input: &ScreenPlanInput, + screens: &[PlanScreenInput], + source: Value, + policy: &OutputPolicy, +) -> Result { + let root = require_root(input)?; + let evidence = project_evidence(root).await?; + let mut plan = draft_plan( + screens, + &input.navigation_targets, + &evidence, + source, + input.notes.as_deref(), + )?; + plan["createdAt"] = json!(now()); + let replaced = read_plan(root) + .ok() + .flatten() + .filter(|previous| previous["status"] == "confirmed") + .map(|previous| previous["planId"].clone()); + let written = write_plan(policy, root, &plan)?; + let questions = ask_shape(array(&plan["questions"])); + let unverified = array(&plan["screens"]) + .iter() + .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") + .count(); + Ok(json!({ + "status": "draft", + "planId": plan["planId"], + "screens": screen_table(&plan), + "questions": questions, + "questionCount": questions.len(), + "askInstruction": ASK_INSTRUCTION, + "unverifiedRoutes": unverified, + "inventoryNote": plan["inventoryNote"], + "writtenTo": written, + "replacedConfirmedPlan": replaced, + "nextAction": if questions.is_empty() { + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": {}}, + "how": "Nothing is open. Show the user the screens table, then confirm with empty answers."}) + } else { + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": " chosen label>"}, + "how": ASK_INSTRUCTION}) + }, + })) +} + +fn strip_recommended(label: &str) -> &str { + label + .trim() + .strip_suffix(RECOMMENDED) + .unwrap_or(label.trim()) +} + +/// The action an answer selects, or the custom text when it matches no +/// option. A custom answer to a question that places a screen must itself be +/// a route; anything else is recorded and the recommended action applies. +fn resolve_answer( + question: &Value, + answer: &AnswerInput, +) -> Result<(Value, Option), DevupError> { + let id = text(&question["id"]); + let values = answer.values(); + let [value] = values.as_slice() else { + return Err(invalid(format!("{id} takes exactly one answer."))); + }; + let label = strip_recommended(value); + if let Some(found) = array(&question["resolve"]) + .iter() + .find(|option| text(&option["label"]) == label) + { + return Ok((found["action"].clone(), None)); + } + match text(&question["kind"]) { + "route" | "modal" => normalize_route(label) + .filter(|route| valid_route(route)) + .map(|route| (json!({"route": route}), Some(label.to_owned()))) + .ok_or_else(|| { + invalid(format!( + "{id}: a custom answer must be an app route such as /about, not {label:?}." + )) + }), + "link" => Ok(( + json!({"link": normalize_route(label)}), + Some(label.to_owned()), + )), + _ => Ok(( + array(&question["resolve"]) + .first() + .map(|option| option["action"].clone()) + .unwrap_or(Value::Null), + Some(label.to_owned()), + )), + } +} + +/// `confirm`: every question answered, in one call, or nothing changes. +pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + let plan_id = input + .plan_id + .as_deref() + .ok_or_else(|| invalid("action confirm needs planId."))?; + if plan["planId"] != plan_id { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "planId does not match the plan on disk; it was redrafted. Ask the questions of the current draft.", + false, + json!({"planId": plan_id, "currentPlanId": plan["planId"]}), + )); + } + let questions = array(&plan["questions"]).to_vec(); + let known = questions + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect::>(); + let missing = known + .iter() + .filter(|id| !input.answers.contains_key(*id)) + .cloned() + .collect::>(); + let unknown = input + .answers + .keys() + .filter(|id| !known.contains(*id)) + .cloned() + .collect::>(); + if !missing.is_empty() || !unknown.is_empty() { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "confirm needs an answer to every question in one call. Missing: {}.", + if missing.is_empty() { + "none".to_owned() + } else { + missing.join(", ") + } + ), + false, + json!({"missing": missing, "unknown": unknown, "askInstruction": ASK_INSTRUCTION, + "questions": ask_shape(&questions).into_iter().filter(|question| missing.iter().any(|id| question["id"] == id.as_str())).collect::>()}), + )); + } + let mut actions = BTreeMap::new(); + let mut custom = Vec::new(); + for question in &questions { + let id = text(&question["id"]).to_owned(); + let (action, note) = resolve_answer(question, &input.answers[&id])?; + if let Some(note) = note { + custom.push(json!({"question": id, "answer": note})); + } + actions.insert(id, action); + } + let separate = actions + .get("responsive") + .is_some_and(|action| action["responsive"] == "separate"); + let app_dir = text(&plan["appDir"]).to_owned(); + let mut routes = Vec::new(); + let mut screen_routes = BTreeMap::new(); + let screens = array(&plan["screens"]).to_vec(); + for screen in &screens { + let id = text(&screen["id"]).to_owned(); + let placed = actions + .get(&format!("route:{id}")) + .or_else(|| actions.get(&format!("modal:{id}"))); + let (implementation, route, host) = match placed { + Some(action) if action["skip"] == true => ("skip", None, None), + Some(action) if action["notPage"] == true => ("not-a-page", None, None), + Some(action) if action["state"].is_string() => { + ("state", None, Some(text(&action["state"]).to_owned())) + } + Some(action) => ("page", action["route"].as_str().map(str::to_owned), None), + None => ( + "page", + screen["proposedRoute"].as_str().map(str::to_owned), + None, + ), + }; + let collision = actions + .get(&format!("collision:{id}")) + .map(|action| text(&action["collision"]).to_owned()); + let (implementation, route) = match collision.as_deref() { + Some("skip") => ("skip", None), + _ => (implementation, route), + }; + if implementation == "page" && route.is_none() { + return Err(invalid(format!( + "{id} has no route after the answers; answer route:{id} with a route." + ))); + } + let data = actions + .get(&format!("dynamic:{id}")) + .map(|action| action["data"].clone()); + let frames = array(&screen["frames"]).to_vec(); + let split = separate && implementation == "page" && frames.len() > 1; + let groups = if split { + frames + .iter() + .map(|frame| vec![frame.clone()]) + .collect::>() + } else { + vec![frames.clone()] + }; + for (index, group) in groups.into_iter().enumerate() { + let url = route.as_ref().map(|route| { + if index == 0 { + route.clone() + } else { + format!( + "{}/{}", + route.trim_end_matches('/'), + group[0]["width"] + .as_f64() + .map_or(0, |width| width.round() as i64) + ) + } + }); + if let Some(url) = &url { + screen_routes + .entry(id.clone()) + .or_insert_with(|| url.clone()); + } + let existing = url.as_deref().and_then(|url| { + (screen["proposedRoute"] == url && !screen["existingFile"].is_null()) + .then(|| screen["existingFile"].clone()) + }); + routes.push(json!({ + "url": url, + "screen": id, + "name": screen["name"], + "nodeIds": group.iter().map(|frame| frame["nodeId"].clone()).collect::>(), + "drawnWidths": group.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), + "implementation": implementation, + "hostScreen": host, + "file": existing.clone().unwrap_or_else(|| url.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &app_dir)))), + "fileExists": existing.is_some(), + "collisionPolicy": collision, + "data": data, + "routeEvidence": screen["routeEvidence"], + })); + } + } + let mut seen = BTreeMap::::new(); + for row in &routes { + if let Some(url) = row["url"].as_str() + && let Some(other) = seen.insert(url.to_owned(), text(&row["screen"]).to_owned()) + && other != text(&row["screen"]) + { + return Err(invalid(format!( + "Two screens resolve to {url} ({other} and {}); answer their route questions with different URLs.", + text(&row["screen"]) + ))); + } + } + for row in routes + .iter_mut() + .filter(|row| row["implementation"] == "state") + { + row["hostUrl"] = json!(screen_routes.get(text(&row["hostScreen"]))); + } + if let Some(rows) = plan["screens"].as_array_mut() { + for screen in rows { + let id = text(&screen["id"]).to_owned(); + let mut resolved = Vec::new(); + for (index, link) in array(&screen["links"]).iter().enumerate() { + let mut link = link.clone(); + if let Some(action) = actions.get(&format!("link:{id}:{index}")) { + 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)); + } + resolved.push(link); + } + screen["links"] = json!(resolved); + } + } + plan["answers"] = json!(input.answers); + plan["customAnswers"] = json!(custom); + plan["layout"] = json!( + actions + .iter() + .filter(|(id, _)| id.starts_with("layout:")) + .map(|(id, action)| (id.clone(), action["layout"].clone())) + .collect::>() + ); + plan["responsive"] = json!(if separate { "separate" } else { "single" }); + plan["routes"] = json!(routes); + plan["status"] = json!("confirmed"); + plan["confirmedAt"] = json!(now()); + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "planId": plan["planId"], + "routes": routes, + "markdownTable": route_markdown(&routes), + "customAnswers": custom, + "writtenTo": written, + "nextAction": {"how": "Implement URL by URL. devup_figma_export with projectRoot now takes each page's route from this plan; run devup_runtime_audit at every width, then devup_screen_plan action status."}, + })) +} + +fn screen_table(plan: &Value) -> Vec { + array(&plan["screens"]) + .iter() + .map(|screen| { + json!({ + "id": screen["id"], + "name": screen["name"], + "kind": screen["kind"], + "drawnWidths": screen["drawnWidths"], + "proposedRoute": screen["proposedRoute"], + "routeEvidence": screen["routeEvidence"], + "existingFile": screen["existingFile"], + "sharedParts": screen["sharedParts"], + "openDecisions": screen["openDecisions"], + }) + }) + .collect() +} + +fn cell(value: &Value) -> String { + let rendered = match value { + Value::Null => "-".to_owned(), + Value::String(text) => text.clone(), + Value::Array(items) if items.is_empty() => "-".to_owned(), + Value::Array(items) => items.iter().map(cell).collect::>().join(", "), + other => other.to_string(), + }; + rendered.replace('|', "\\|").replace('\n', " ") +} + +fn route_markdown(routes: &[Value]) -> String { + let mut out = String::from( + "| URL | Figma screen | nodeIds | widths | file | implementation |\n|---|---|---|---|---|---|\n", + ); + for row in routes { + let url = if row["implementation"] == "state" { + format!("(state on {})", cell(&row["hostUrl"])) + } else { + cell(&row["url"]) + }; + out.push_str(&format!( + "| {url} | {} | {} | {} | {} | {} |\n", + cell(&row["name"]), + cell(&row["nodeIds"]), + cell(&row["drawnWidths"]), + cell(&row["file"]), + cell(&row["implementation"]), + )); + } + out +} + +/// The Markdown twin of the plan: the same content, for a person. +fn markdown(plan: &Value) -> String { + let mut out = format!( + "# Screen plan {}\n\nStatus: **{}**. Source: {}.\n", + cell(&plan["planId"]), + cell(&plan["status"]), + cell(&plan["source"]["url"]), + ); + if let Some(notes) = plan["notes"].as_str() { + out.push_str(&format!("\nNotes: {notes}\n")); + } + if let Some(note) = plan["inventoryNote"].as_str() { + out.push_str(&format!("\n> {note}\n")); + } + out.push_str("\n## Screens\n\n| Figma screen | nodeId | widths | proposed route | evidence | existing file | shared parts | open decisions |\n|---|---|---|---|---|---|---|---|\n"); + for screen in array(&plan["screens"]) { + out.push_str(&format!( + "| {} | {} | {} | {} | {} | {} | {} | {} |\n", + cell(&screen["name"]), + cell(&screen["id"]), + cell(&screen["drawnWidths"]), + cell(&screen["proposedRoute"]), + cell(&screen["routeEvidence"]), + cell(&screen["existingFile"]), + cell(&screen["sharedParts"]), + cell(&screen["openDecisions"]), + )); + } + let questions = array(&plan["questions"]); + if !questions.is_empty() { + out.push_str(&format!("\n## Open questions\n\n{ASK_INSTRUCTION}\n\n")); + for question in questions { + let id = text(&question["id"]); + out.push_str(&format!( + "- **{}** (`{id}`): {}\n", + cell(&question["header"]), + text(&question["question"]) + )); + for option in array(&question["options"]) { + out.push_str(&format!( + " - {}: {}\n", + text(&option["label"]), + text(&option["description"]) + )); + } + if let Some(answer) = plan["answers"].get(id) { + out.push_str(&format!(" - Answer: {}\n", cell(answer))); + } + } + } + let routes = array(&plan["routes"]); + if !routes.is_empty() { + out.push_str("\n## Confirmed routes\n\n"); + out.push_str(&route_markdown(routes)); + } + out +} + +fn worst(a: &str, b: &str) -> &'static str { + let rank = |status: &str| match status { + "fail" => 3, + "inconclusive" => 2, + "not-run" => 1, + _ => 0, + }; + match rank(a).max(rank(b)) { + 3 => "fail", + 2 => "inconclusive", + 1 => "not-run", + _ => "pass", + } +} + +/// `status`: the confirmed plan read against the routes on disk and, when +/// given, a runtime audit. A width without an audit item is `not-run`. +pub(super) async fn status( + input: &ScreenPlanInput, + policy: &OutputPolicy, + artifacts: &ArtifactStore, +) -> Result { + let root = require_root(input)?; + let plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Ok(json!({ + "status": "not-confirmed", + "planId": plan["planId"], + "questions": ask_shape(array(&plan["questions"])), + "askInstruction": ASK_INSTRUCTION, + "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"]}, "how": ASK_INSTRUCTION}, + })); + } + let audit = match (&input.audit_plan_path, &input.audit_report_path) { + (Some(plan_path), Some(report_path)) => Some( + super::runtime_audit::ingest_with_artifacts(policy, plan_path, report_path, artifacts) + .await?, + ), + (None, None) => None, + _ => { + return Err(invalid( + "status needs both auditPlanPath and auditReportPath, or neither.", + )); + } + }; + let evidence = project_evidence(root).await?; + Ok(status_report(&plan, &evidence, audit.as_ref())) +} + +/// The pure half of `status`, so the not-run rule is tested on its own. +pub(super) fn status_report( + plan: &Value, + evidence: &ProjectEvidence, + audit: Option<&Value>, +) -> Value { + let audit_rows = audit + .map(|audit| array(&audit["screenTable"])) + .unwrap_or_default(); + let mut widths = audit + .map(|audit| { + array(&audit["widthTable"]) + .iter() + .filter_map(|row| row["width"].as_u64()) + .collect::>() + }) + .unwrap_or_default(); + let audited = !widths.is_empty(); + let mut rows = Vec::new(); + let mut states = Vec::new(); + for route in array(&plan["routes"]) { + if route["implementation"] == "state" { + states.push(json!({"screen": route["screen"], "name": route["name"], "hostUrl": route["hostUrl"]})); + continue; + } + let Some(url) = route["url"].as_str() else { + continue; + }; + let page = evidence + .routes + .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::>() + }; + row_widths.sort_unstable_by(|a, b| b.cmp(a)); + row_widths.dedup(); + let per_width = row_widths + .iter() + .map(|width| { + let matching = audit_rows + .iter() + .filter(|row| { + row["width"] == *width + && row["routePath"] + .as_str() + .and_then(normalize_route) + .is_some_and(|path| path == url) + }) + .collect::>(); + if matching.is_empty() { + 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 + .iter() + .fold("pass", |acc, row| worst(acc, text(&row["status"]))); + json!({ + "width": width, + "result": result, + "reasons": matching.iter().flat_map(|row| array(&row["reasons"]).to_vec()).collect::>(), + "screenshots": matching.iter().filter_map(|row| row["screenshot"].as_str()).collect::>(), + }) + }) + .collect::>(); + rows.push(json!({ + "url": url, + "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" }, + "widths": per_width, + })); + } + if !audited { + widths = DEFAULT_WIDTHS + .iter() + .map(|width| u64::from(*width)) + .collect(); + } + let implemented = rows + .iter() + .filter(|row| row["implemented"] == "yes") + .count(); + let verified = rows + .iter() + .filter(|row| { + array(&row["widths"]) + .iter() + .all(|width| width["result"] == "pass") + }) + .count(); + let mut problems = Vec::new(); + for row in &rows { + let mut by_kind = BTreeMap::<&str, Vec>::new(); + for width in array(&row["widths"]) { + if width["result"] != "fail" { + continue; + } + let reasons = array(&width["reasons"]) + .iter() + .map(text) + .collect::>(); + let kind = if reasons + .iter() + .any(|reason| reason.starts_with("horizontal overflow")) + { + "overflows" + } else if reasons.iter().any(|reason| { + reason.starts_with("page error") || reason.starts_with("screen not reached") + }) { + "fails to load" + } else { + "fails checks" + }; + by_kind + .entry(kind) + .or_default() + .push(width["width"].to_string()); + } + for (kind, at) in by_kind { + problems.push(format!("{} {kind} at {}", text(&row["url"]), at.join(", "))); + } + } + let mut summary = format!( + "{} URLs: {implemented} implemented, {verified} verified at all widths", + rows.len() + ); + for problem in &problems { + summary.push_str(&format!(", {problem}")); + } + if !audited { + summary.push_str("; no runtime audit report, so every width is not-run"); + } + let mut table = String::from( + "| URL | Figma screen | file | status | widths verified | screenshots |\n|---|---|---|---|---|---|\n", + ); + for row in &rows { + let row_widths = array(&row["widths"]); + let passed = row_widths + .iter() + .filter(|width| width["result"] == "pass") + .map(|width| width["width"].to_string()) + .collect::>(); + let failing = row_widths + .iter() + .filter(|width| width["result"] == "fail") + .map(|width| width["width"].to_string()) + .collect::>(); + let state = if row["implemented"] != "yes" { + "not implemented".to_owned() + } else if !failing.is_empty() { + format!("fails at {}", failing.join(", ")) + } else if passed.len() == row_widths.len() { + "verified".to_owned() + } else { + "implemented, not verified".to_owned() + }; + let shots = row_widths + .iter() + .flat_map(|width| array(&width["screenshots"]).to_vec()) + .collect::>(); + table.push_str(&format!( + "| {} | {} | {} | {state} | {} of {} | {} |\n", + cell(&row["url"]), + cell(&row["figmaScreen"]), + cell(&row["file"]), + if passed.is_empty() { + "none".to_owned() + } else { + passed.join(", ") + }, + row_widths + .iter() + .map(|width| width["width"].to_string()) + .collect::>() + .join("/"), + cell(&json!(shots)), + )); + } + json!({ + "status": "confirmed", + "planId": plan["planId"], + "summary": summary, + "rows": rows, + "states": states, + "widths": widths, + "audit": audit.map(|audit| json!({"verdict": audit["verdict"], "planId": audit["planId"], "reason": audit["reason"]})), + "markdownTable": table, + "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.", + }) +} + +/// What the export gate decided. +pub(super) enum Gate { + /// Not a page write, or no plan applies. + Open, + /// A confirmed plan covers the frame and names this route for pageScaffold. + Route(String), + /// Not written: the response to return instead. + Blocked(Value), +} + +/// The facts of one export call the gate decides on. +pub(super) struct ExportRequest<'a> { + pub project_root: &'a str, + pub url: Option<&'a str>, + /// Frame ids, or the one node the url or artifact names when there are none. + pub frames: Vec, + pub all_screens: bool, + pub scaffold_write: bool, + pub scaffold_route: Option<&'a str>, + pub output_paths: Vec<&'a str>, +} + +/// Whether a write lands in the App Router tree. Judged below the project +/// root, so a checkout that happens to sit in a folder named `app` is not one. +fn under_app(project_root: &str, path: &str) -> bool { + let path = Path::new(path); + path.strip_prefix(project_root) + .unwrap_or(path) + .components() + .any(|component| component.as_os_str() == "app") +} + +fn plan_required(request: &ExportRequest<'_>, reason: &str, details: Value) -> Gate { + let mut arguments = json!({"action": "draft", "projectRoot": request.project_root}); + if let Some(url) = request.url { + arguments["url"] = json!(url); + } + Gate::Blocked(json!({ + "status": "plan_required", + "written": false, + "planRequired": {"reason": reason, "details": details}, + "nextAction": {"tool": "devup_screen_plan", "arguments": arguments, + "how": "Draft the screen plan, ask every question in ONE question-tool call, confirm, then repeat this export. Nothing was collected or written."}, + })) +} + +/// The soft gate in front of `devup_figma_export`. A call that would write a +/// page - a pageScaffold write, an outputPath under app/, or several screens +/// with outputPaths - needs a confirmed plan covering its frames. A single +/// component, or a preview that writes nothing, is never blocked. +pub(super) fn export_gate(request: &ExportRequest<'_>) -> Result { + let several = request.all_screens || request.frames.len() > 1; + let writes = request.scaffold_write || !request.output_paths.is_empty(); + let gated = request.scaffold_write + || request + .output_paths + .iter() + .any(|path| under_app(request.project_root, path)) + || (several && writes); + if !gated && request.scaffold_route.is_none() { + return Ok(Gate::Open); + } + let plan = match read_plan(request.project_root)? { + Some(plan) if plan["status"] == "confirmed" => plan, + Some(plan) if gated => { + return Ok(plan_required( + request, + "The screen plan is a draft; its questions are unanswered.", + json!({"planId": plan["planId"], "askInstruction": ASK_INSTRUCTION}), + )); + } + None if gated => { + return Ok(plan_required( + request, + "This call writes a page and the project has no confirmed screen plan.", + json!({"planFile": PLAN_FILE}), + )); + } + _ => return Ok(Gate::Open), + }; + let routes = array(&plan["routes"]); + let row_of = |frame: &str| { + routes + .iter() + .find(|row| array(&row["nodeIds"]).iter().any(|id| id == frame)) + }; + if request.all_screens { + let section = request + .url + .and_then(|url| FigmaTarget::parse(url).ok()) + .and_then(|target| target.node_id); + let planned = plan["source"]["nodeId"].as_str(); + if section.is_some() && planned.is_some() && section.as_deref() != planned { + return Ok(plan_required( + request, + "allScreens names a Section the confirmed plan was not drafted from.", + json!({"requested": section, "planned": planned}), + )); + } + return Ok(Gate::Open); + } else if request.frames.is_empty() { + if gated { + return Ok(plan_required( + request, + "The frames this call writes are not named, so the plan cannot be checked. Pass frameIds or a frame url.", + json!({}), + )); + } + return Ok(Gate::Open); + } + let uncovered = request + .frames + .iter() + .filter(|frame| { + row_of(frame) + .is_none_or(|row| !matches!(text(&row["implementation"]), "page" | "state")) + }) + .cloned() + .collect::>(); + if !uncovered.is_empty() { + if !gated { + return Ok(Gate::Open); + } + return Ok(plan_required( + request, + "The confirmed screen plan does not place these frames on a page.", + json!({"uncoveredFrames": uncovered, "planId": plan["planId"]}), + )); + } + let Some(requested) = request.scaffold_route else { + return Ok(Gate::Open); + }; + let [frame] = request.frames.as_slice() else { + return Ok(Gate::Open); + }; + let Some(row) = row_of(frame) else { + return Ok(Gate::Open); + }; + let Some(planned) = row["url"].as_str() else { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "The confirmed plan builds {frame} as a state of another page, not as its own route; export it without pageScaffold." + ), + false, + json!({"planId": plan["planId"], "hostUrl": row["hostUrl"]}), + )); + }; + if !requested.is_empty() && normalize_route(requested).as_deref() != Some(planned) { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "pageScaffold.route {requested} does not match the confirmed plan, which serves {frame} at {planned}." + ), + false, + json!({"routeMismatch": {"requested": requested, "planned": planned, "frame": frame, "planId": plan["planId"]}, + "how": "Omit pageScaffold.route to take the plan's route, or redraft and confirm the plan if the URL should change."}), + )); + } + Ok(Gate::Route(planned.to_owned())) +} + +#[cfg(test)] +#[path = "screen_plan_tests.rs"] +mod tests; diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs new file mode 100644 index 00000000..d88cd8fc --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -0,0 +1,531 @@ +use super::*; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Fixture(std::path::PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "screen-plan-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + let fixture = Self(dunce::canonicalize(&root).unwrap()); + fixture.write("package.json", "{}"); + fixture.write( + "src/app/layout.tsx", + "import { Header } from '../components/Header';\nexport default function RootLayout({ children }) { return
{children}; }", + ); + fixture.write( + "src/components/Header.tsx", + "import Link from 'next/link';\nexport function Header() { return ; }", + ); + fixture.write( + "src/app/page.tsx", + "export default function Page() { return
; }", + ); + fixture.write( + "src/app/company/page.tsx", + "export default function Page() { return
; }", + ); + fixture + } + + fn write(&self, path: &str, text: &str) { + let target = self.0.join(path); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(target, text).unwrap(); + } + + fn root(&self) -> &str { + self.0.to_str().unwrap() + } + + fn policy(&self) -> OutputPolicy { + OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn screen(node_id: &str, name: &str, width: f64) -> PlanScreenInput { + PlanScreenInput { + node_id: node_id.into(), + name: name.into(), + width, + height: Some(900.0), + ..PlanScreenInput::default() + } +} + +fn evidence() -> ProjectEvidence { + ProjectEvidence { + routes: vec![ + ("/".into(), "src/app/page.tsx".into()), + ("/company".into(), "src/app/company/page.tsx".into()), + ("/news/[id]".into(), "src/app/news/[id]/page.tsx".into()), + ], + nav_links: vec![( + "회사소개".into(), + "/company".into(), + "src/components/Header.tsx:2".into(), + )], + layout_parts: vec!["Header".into()], + app_dir: "src/app".into(), + inventory_note: None, + } +} + +fn question_ids(plan: &Value) -> Vec { + array(&plan["questions"]) + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect() +} + +fn input(fixture: &Fixture, action: &str) -> ScreenPlanInput { + ScreenPlanInput { + action: action.into(), + project_root: Some(fixture.root().into()), + ..ScreenPlanInput::default() + } +} + +/// The route the user never saw being chosen is the failure this tool exists +/// for, so a screen nothing names gets no route and a question instead. +#[test] +fn an_unverified_route_is_null_and_becomes_a_question() { + let plan = draft_plan( + &[screen("1:2", "Pricing", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let row = &plan["screens"][0]; + assert_eq!(row["proposedRoute"], Value::Null); + assert_eq!(row["routeEvidence"], "UNVERIFIED"); + let question = array(&plan["questions"]) + .iter() + .find(|question| question["id"] == "route:1:2") + .expect("an unverified route is asked"); + let labels = array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert_eq!(labels[0], "/pricing (Recommended)"); + assert!(labels.contains(&SKIP)); +} + +/// Each kind of evidence proposes the route on its own, and a screen it +/// proposes is not asked about its route. +#[test] +fn an_evidence_backed_route_is_proposed_without_a_route_question() { + let targets = BTreeMap::from([("2:1".to_owned(), "/contact?from=nav".to_owned())]); + let plan = draft_plan( + &[ + screen("2:1", "Contact", 1440.0), + screen("2:2", "회사소개", 1440.0), + screen("2:3", "Home", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows[0]["proposedRoute"], "/contact"); + assert_eq!(rows[0]["routeEvidence"], "navigationTargets[2:1]"); + assert_eq!(rows[1]["proposedRoute"], "/company"); + assert!(text(&rows[1]["routeEvidence"]).starts_with("nav-label")); + assert_eq!(rows[2]["proposedRoute"], "/"); + let ids = question_ids(&plan); + assert!( + !ids.iter() + .any(|id| id == "route:2:1" || id.ends_with(":2:1")), + "{ids:?}" + ); + assert!(!ids.iter().any(|id| id.starts_with("route:")), "{ids:?}"); + // An existing page is a different question: what building over it does. + assert!(ids.iter().any(|id| id == "collision:2:2")); + assert_eq!(rows[1]["existingFile"], "src/app/company/page.tsx"); + assert_eq!(rows[0]["sharedParts"], json!(["Header"])); +} + +/// Two screens the evidence puts on one URL leave neither decided. +#[test] +fn two_screens_on_one_evidence_route_are_both_asked() { + let targets = BTreeMap::from([ + ("3:1".to_owned(), "/about".to_owned()), + ("3:2".to_owned(), "/about".to_owned()), + ]); + let plan = draft_plan( + &[ + screen("3:1", "About A", 1440.0), + screen("3:2", "About B", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let ids = question_ids(&plan); + assert!(ids.contains(&"route:3:1".to_owned()) && ids.contains(&"route:3:2".to_owned())); +} + +#[test] +fn widths_of_one_screen_are_grouped_and_modals_asked_as_state_or_route() { + let plan = draft_plan( + &[ + screen("4:1", "Main - 1440", 1440.0), + screen("4:2", "Main - 390", 390.0), + screen("4:3", "Login Modal", 480.0), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); + assert_eq!(rows[0]["proposedRoute"], "/"); + let ids = question_ids(&plan); + assert!(ids.contains(&"responsive".to_owned())); + let modal = array(&plan["questions"]) + .iter() + .find(|question| question["id"] == "modal:4:3") + .expect("a modal is asked"); + assert!(text(&modal["options"][0]["label"]).starts_with("State on Main")); +} + +/// Every question must be accepted as-is by a structured question tool. +#[test] +fn every_question_has_the_question_tool_shape() { + let mut linked = screen("5:1", "Gallery", 1440.0); + linked.links = vec![PlanLinkInput { + label: "More".into(), + destination_node_id: Some("9:9".into()), + url: None, + }]; + linked.parts = vec!["Footer".into()]; + let mut other = screen("5:2", "News", 1440.0); + other.parts = vec!["Footer".into()]; + let targets = BTreeMap::from([("5:3".to_owned(), "/news/[id]".to_owned())]); + let plan = draft_plan( + &[ + linked, + other, + screen("5:3", "News detail", 1440.0), + screen("5:4", "공지 팝업", 400.0), + screen("5:5", "Company - 1440", 1440.0), + screen("5:6", "Company - 390", 390.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let questions = ask_shape(array(&plan["questions"])); + let ids = questions + .iter() + .map(|question| text(&question["id"])) + .collect::>(); + assert_eq!(ids.len(), questions.len(), "question ids are unique"); + for kind in [ + "route:", + "link:", + "layout:", + "dynamic:", + "modal:", + "responsive", + "collision:", + ] { + assert!( + ids.iter().any(|id| id.starts_with(kind)), + "{kind} missing from {ids:?}" + ); + } + for question in &questions { + let header = text(&question["header"]); + assert!( + !header.is_empty() && header.chars().count() <= HEADER_LIMIT, + "{header}" + ); + assert!(!text(&question["question"]).is_empty()); + assert_eq!(question["multiple"], false); + assert!( + question.get("resolve").is_none(), + "resolution stays in the plan file" + ); + let options = array(&question["options"]); + assert!((2..=MAX_OPTIONS).contains(&options.len()), "{question}"); + assert!( + text(&options[0]["label"]).ends_with(RECOMMENDED), + "{question}" + ); + for option in &options[1..] { + assert!( + !text(&option["label"]).contains("(Recommended)"), + "{question}" + ); + } + assert!( + options + .iter() + .all(|option| !text(&option["description"]).is_empty()) + ); + } +} + +#[tokio::test] +async fn confirm_rejects_missing_answers_by_id_and_accepts_a_complete_set() { + let fixture = Fixture::new(); + let mut draft_input = input(&fixture, "draft"); + draft_input.navigation_targets = BTreeMap::from([("6:3".to_owned(), "/contact".to_owned())]); + let drafted = draft( + &draft_input, + &[ + screen("6:1", "Pricing", 1440.0), + screen("6:2", "Careers", 1440.0), + screen("6:3", "Contact", 1440.0), + ], + json!({"from": "caller"}), + &fixture.policy(), + ) + .await + .unwrap(); + assert_eq!(drafted["askInstruction"], ASK_INSTRUCTION); + assert!(fixture.0.join(".devup/screen-plan.json").is_file()); + assert!(fixture.0.join(".devup/screen-plan.md").is_file()); + let plan_id = text(&drafted["planId"]).to_owned(); + + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = Some(plan_id.clone()); + confirm_input.answers = BTreeMap::from([( + "route:6:1".to_owned(), + AnswerInput::One("/pricing (Recommended)".into()), + )]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["route:6:2"])); + assert!(error.message.contains("route:6:2")); + assert_eq!( + read_plan(fixture.root()).unwrap().unwrap()["status"], + "draft" + ); + + confirm_input + .answers + .insert("route:6:2".into(), AnswerInput::One("/jobs".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| row["url"].clone()) + .collect::>(); + assert_eq!(urls, [json!("/pricing"), json!("/jobs"), json!("/contact")]); + assert_eq!(confirmed["customAnswers"][0]["answer"], "/jobs"); + let saved = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(saved["status"], "confirmed"); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("| /jobs | Careers |"), "{markdown}"); +} + +#[tokio::test] +async fn a_custom_route_answer_must_be_a_route() { + let fixture = Fixture::new(); + let drafted = draft( + &input(&fixture, "draft"), + &[screen("7:1", "Pricing", 1440.0)], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + confirm_input.answers = BTreeMap::from([( + "route:7:1".to_owned(), + AnswerInput::One("the pricing page".into()), + )]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert!(error.message.contains("custom answer must be an app route")); +} + +fn confirmed_plan() -> Value { + json!({ + "kind": PLAN_KIND, "schemaVersion": SCHEMA_VERSION, "planId": "sp-test", "status": "confirmed", + "source": {"nodeId": "10:0"}, + "routes": [ + {"url": "/", "screen": "10:1", "name": "Main", "nodeIds": ["10:1", "10:2"], "drawnWidths": [1440, 390], "implementation": "page", "file": "src/app/page.tsx"}, + {"url": "/about", "screen": "10:3", "name": "About", "nodeIds": ["10:3"], "drawnWidths": [1440], "implementation": "page", "file": "src/app/about/page.tsx"}, + {"url": null, "screen": "10:4", "name": "Login modal", "nodeIds": ["10:4"], "drawnWidths": [480], "implementation": "state", "hostUrl": "/"}, + ], + }) +} + +/// A width the audit has no item for is not-run, and a URL with a failing +/// width is never counted as verified. +#[test] +fn status_marks_widths_without_an_audit_item_not_run() { + let audit = json!({ + "verdict": "fail", + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "screenshot": "1440x900@1-main.png"}, + {"screen": "about", "routePath": "/about", "width": 1440, "status": "pass", "reasons": []}, + {"screen": "about", "routePath": "/about/", "width": 390, "status": "fail", "reasons": ["horizontal overflow: 42px past the viewport"]}, + ], + }); + 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[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!( + report["summary"], + "2 URLs: 1 implemented, 0 verified at all widths, /about overflows at 390" + ); + assert_eq!(report["states"][0]["hostUrl"], "/"); + assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); + + let without = status_report(&confirmed_plan(), &evidence(), None); + assert!( + array(&without["rows"]) + .iter() + .flat_map(|row| array(&row["widths"])) + .all(|width| width["result"] == "not-run") + ); + assert!(text(&without["summary"]).contains("every width is not-run")); +} + +fn write_plan_file(fixture: &Fixture, plan: &Value) { + fixture.write(PLAN_FILE, &serde_json::to_string(plan).unwrap()); +} + +fn export<'a>(fixture: &'a Fixture, frames: &[&str]) -> ExportRequest<'a> { + ExportRequest { + project_root: fixture.root(), + url: None, + frames: frames.iter().map(|frame| (*frame).to_owned()).collect(), + all_screens: false, + scaffold_write: false, + scaffold_route: None, + output_paths: Vec::new(), + } +} + +#[test] +fn the_export_gate_blocks_page_writes_without_a_confirmed_plan() { + let fixture = Fixture::new(); + let mut scaffold = export(&fixture, &["10:3"]); + scaffold.scaffold_write = true; + scaffold.scaffold_route = Some("/about"); + let Gate::Blocked(response) = export_gate(&scaffold).unwrap() else { + panic!("a pageScaffold write without a plan is blocked"); + }; + assert_eq!(response["status"], "plan_required"); + assert_eq!(response["nextAction"]["tool"], "devup_screen_plan"); + assert_eq!(response["nextAction"]["arguments"]["action"], "draft"); + + let mut app_path = export(&fixture, &["10:3"]); + app_path.output_paths = vec!["src/app/about/page.tsx"]; + assert!(matches!(export_gate(&app_path).unwrap(), Gate::Blocked(_))); + + let mut several = export(&fixture, &["10:1", "10:3"]); + several.output_paths = vec!["src/components/Screens.tsx"]; + assert!(matches!(export_gate(&several).unwrap(), Gate::Blocked(_))); + + let mut draft_only = confirmed_plan(); + draft_only["status"] = json!("draft"); + write_plan_file(&fixture, &draft_only); + assert!(matches!(export_gate(&scaffold).unwrap(), Gate::Blocked(_))); +} + +#[test] +fn the_export_gate_never_blocks_a_component_or_a_preview() { + let fixture = Fixture::new(); + let mut component = export(&fixture, &["10:3"]); + component.output_paths = vec!["src/components/Card.tsx"]; + assert!(matches!(export_gate(&component).unwrap(), Gate::Open)); + let preview = export(&fixture, &["10:1", "10:3"]); + assert!(matches!(export_gate(&preview).unwrap(), Gate::Open)); + let mut scaffold_preview = export(&fixture, &["10:3"]); + scaffold_preview.scaffold_route = Some("/about"); + assert!(matches!( + export_gate(&scaffold_preview).unwrap(), + Gate::Open + )); +} + +#[test] +fn a_confirmed_plan_supplies_the_scaffold_route_and_refuses_another() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &confirmed_plan()); + let mut scaffold = export(&fixture, &["10:2"]); + scaffold.scaffold_write = true; + scaffold.scaffold_route = Some(""); + let Gate::Route(route) = export_gate(&scaffold).unwrap() else { + panic!("the plan covers 10:2"); + }; + assert_eq!(route, "/"); + scaffold.scaffold_route = Some("/home"); + let error = export_gate(&scaffold) + .err() + .expect("a different route is a mismatch"); + assert_eq!(error.details["routeMismatch"]["planned"], "/"); + + let mut uncovered = export(&fixture, &["99:1"]); + uncovered.scaffold_write = true; + uncovered.scaffold_route = Some("/x"); + let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { + panic!("a frame the plan does not place is blocked"); + }; + assert_eq!( + response["planRequired"]["details"]["uncoveredFrames"], + json!(["99:1"]) + ); + + let mut several = export(&fixture, &["10:1", "10:3"]); + several.output_paths = vec!["src/components/Screens.tsx"]; + assert!(matches!(export_gate(&several).unwrap(), Gate::Open)); +} + +#[tokio::test] +async fn project_evidence_reads_routes_layout_parts_and_nav_links() { + let fixture = Fixture::new(); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let routes = evidence + .routes + .iter() + .map(|(route, _)| route.as_str()) + .collect::>(); + assert!( + routes.contains(&"/") && routes.contains(&"/company"), + "{routes:?}" + ); + assert_eq!(evidence.app_dir, "src/app"); + assert!(evidence.layout_parts.contains(&"Header".to_owned())); + assert!( + evidence + .nav_links + .iter() + .any(|(label, href, _)| label == "회사소개" && href == "/company"), + "{:?}", + evidence.nav_links + ); +} diff --git a/crates/devup-mcp/tests/screen_plan_tool.rs b/crates/devup-mcp/tests/screen_plan_tool.rs new file mode 100644 index 00000000..fd0e5c30 --- /dev/null +++ b/crates/devup-mcp/tests/screen_plan_tool.rs @@ -0,0 +1,215 @@ +//! `devup_screen_plan` and the export gate through the real tool surface: a +//! draft hands back every open question at once, confirm refuses a partial set +//! of answers, and a page write without a confirmed plan is answered with the +//! plan to make before Figma is read. + +use std::{path::Path, sync::Arc}; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{ + AuthStatus, DevupError, ErrorCode, FigmaUpstream, ReadToolCall, UpstreamResult, +}; +use rmcp::{ServiceExt, model::CallToolRequestParams}; +use serde_json::{Map, Value, json}; + +struct Auth; + +#[async_trait] +impl DevupAuth for Auth { + async fn status(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +struct NoFigma; + +#[async_trait] +impl FigmaUpstream for NoFigma { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec![]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + Err(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "this test does not reach Figma", + false, + )) + } +} + +async fn call(root: &Path, tool: &'static str, arguments: Value) -> anyhow::Result<(bool, Value)> { + let server = DevupServer::with_output_roots( + Services::new(Arc::new(Auth), Arc::new(NoFigma)), + vec![root.to_path_buf()], + )?; + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments: Map = arguments.as_object().cloned().unwrap(); + let result = client + .call_tool(CallToolRequestParams::new(tool).with_arguments(arguments)) + .await?; + client.cancel().await?; + task.await??; + Ok(( + result.is_error == Some(true), + result.structured_content.unwrap_or_default(), + )) +} + +fn project(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("devup-screen-plan-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("src/app/about")).unwrap(); + std::fs::write(dir.join("package.json"), "{}").unwrap(); + std::fs::write( + dir.join("src/app/page.tsx"), + "export default function Page() { return
; }", + ) + .unwrap(); + std::fs::write( + dir.join("src/app/about/page.tsx"), + "export default function Page() { return
; }", + ) + .unwrap(); + dunce::canonicalize(dir).unwrap() +} + +const FILE: &str = "https://www.figma.com/design/FileKey123/Site"; + +#[tokio::test] +async fn draft_asks_everything_at_once_and_confirm_needs_every_answer() -> anyhow::Result<()> { + let root = project("flow"); + let root_text = root.to_string_lossy().into_owned(); + let (failed, draft) = call( + &root, + "devup_screen_plan", + json!({"action": "draft", "projectRoot": root_text, "url": format!("{FILE}?node-id=1-1"), + "screens": [ + {"nodeId": "1:2", "name": "Main - 1440", "width": 1440}, + {"nodeId": "1:3", "name": "Main - 390", "width": 390}, + {"nodeId": "1:4", "name": "About", "width": 1440}, + {"nodeId": "1:5", "name": "Pricing", "width": 1440} + ]}), + ) + .await?; + assert!(!failed, "{draft}"); + assert_eq!( + draft["askInstruction"], + "Ask all of these in ONE call to your question tool; do not ask in prose; do not implement before confirm." + ); + let ids = draft["questions"] + .as_array() + .unwrap() + .iter() + .map(|question| question["id"].as_str().unwrap().to_owned()) + .collect::>(); + assert_eq!( + ids, + ["collision:1:2", "collision:1:4", "route:1:5", "responsive"], + "{draft}" + ); + assert_eq!(draft["screens"][0]["proposedRoute"], "/"); + assert_eq!(draft["screens"][0]["drawnWidths"], json!([1440, 390])); + assert_eq!(draft["screens"][2]["routeEvidence"], "UNVERIFIED"); + let plan_id = draft["planId"].as_str().unwrap().to_owned(); + + let (failed, refused) = call( + &root, + "devup_screen_plan", + json!({"action": "confirm", "projectRoot": root_text, "planId": plan_id, + "answers": {"route:1:5": "/pricing (Recommended)"}}), + ) + .await?; + assert!(failed); + let refusal = refused.to_string(); + assert!( + refusal.contains("collision:1:4") && refusal.contains("responsive"), + "{refusal}" + ); + + let (failed, gated) = call( + &root, + "devup_figma_export", + json!({"url": format!("{FILE}?node-id=1-5"), "projectRoot": root_text, + "outputs": ["pageScaffold"], "pageScaffold": {"route": "/pricing", "write": true}, + "outputPaths": {"pageScaffold": root_text}}), + ) + .await?; + assert!(!failed, "{gated}"); + assert_eq!(gated["status"], "plan_required", "{gated}"); + + let (failed, confirmed) = call( + &root, + "devup_screen_plan", + json!({"action": "confirm", "projectRoot": root_text, "planId": plan_id, + "answers": {"route:1:5": "/pricing (Recommended)", + "collision:1:2": "Merge into the existing page (Recommended)", + "collision:1:4": "Merge into the existing page (Recommended)", + "responsive": "One responsive route (Recommended)"}}), + ) + .await?; + assert!(!failed, "{confirmed}"); + assert_eq!(confirmed["status"], "confirmed"); + assert_eq!(confirmed["routes"][2]["url"], "/pricing"); + assert_eq!(confirmed["routes"][2]["file"], "src/app/pricing/page.tsx"); + + let (failed, mismatch) = call( + &root, + "devup_figma_export", + json!({"url": format!("{FILE}?node-id=1-5"), "projectRoot": root_text, + "outputs": ["pageScaffold"], "pageScaffold": {"route": "/plans", "write": true}, + "outputPaths": {"pageScaffold": root_text}}), + ) + .await?; + assert!(failed, "{mismatch}"); + assert!(mismatch.to_string().contains("routeMismatch"), "{mismatch}"); + + let (failed, status) = call( + &root, + "devup_screen_plan", + json!({"action": "status", "projectRoot": root_text}), + ) + .await?; + 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" + ); + std::fs::remove_dir_all(root).ok(); + Ok(()) +} + +#[tokio::test] +async fn a_single_component_export_is_not_gated() -> anyhow::Result<()> { + let root = project("component"); + let (failed, result) = call( + &root, + "devup_figma_export", + json!({"url": format!("{FILE}?node-id=1-2"), "projectRoot": root.to_string_lossy(), + "outputs": ["tsx"]}), + ) + .await?; + // It goes on to read Figma, which this test refuses: the refusal is the + // proof that the gate let it through. + assert!(failed, "{result}"); + assert_ne!(result["status"], "plan_required"); + assert!( + result + .to_string() + .contains("this test does not reach Figma"), + "{result}" + ); + std::fs::remove_dir_all(root).ok(); + Ok(()) +} diff --git a/crates/devup-mcp/tests/self_description.rs b/crates/devup-mcp/tests/self_description.rs index 112b49be..369778d0 100644 --- a/crates/devup-mcp/tests/self_description.rs +++ b/crates/devup-mcp/tests/self_description.rs @@ -41,9 +41,10 @@ async fn initialize_instructions_are_small_and_name_the_guide() -> anyhow::Resul .expect("the server still publishes instructions"); // The ceiling moved once, for the connection order: it has to be read - // before the first Figma call, not fetched after the wrong one. + // before the first Figma call, not fetched after the wrong one. It moved + // again for the screen plan, which has to come before the first page. assert!( - instructions.len() < 1_400, + instructions.len() < 1_500, "instructions are {} bytes; every client pays this on every session", instructions.len() ); diff --git a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs index 829c1a15..e6994179 100644 --- a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs +++ b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs @@ -211,8 +211,8 @@ fn tools_list_over_raw_stdio_has_no_boolean_schemas_and_object_output_types() -> .expect("tools/list result must contain a tools array"); assert_eq!( tools.len(), - 12, - "expected all 12 devup-mcp tools (5 devup_figma_* + devup_skills + devup_project_context + devup_ui_validate + devup_stack_diff + devup_visual_compare + devup_feature_trace + devup_runtime_audit) to be listed: {tools:?}" + 13, + "expected all 13 devup-mcp tools (5 devup_figma_* + devup_skills + devup_project_context + devup_ui_validate + devup_stack_diff + devup_visual_compare + devup_feature_trace + devup_runtime_audit + devup_screen_plan) to be listed: {tools:?}" ); let mut boolean_schema_hits = Vec::new(); diff --git a/crates/devup-mcp/tests/stdio_smoke.rs b/crates/devup-mcp/tests/stdio_smoke.rs index 9822b546..73101372 100644 --- a/crates/devup-mcp/tests/stdio_smoke.rs +++ b/crates/devup-mcp/tests/stdio_smoke.rs @@ -82,7 +82,8 @@ async fn fresh_binary_initializes_lists_tools_and_reports_auth_status() -> anyho assert!(names.contains(&"devup_skills")); assert!(names.contains(&"devup_runtime_audit")); assert!(names.contains(&"devup_figma_measure")); - assert_eq!(names.len(), 12); + assert!(names.contains(&"devup_screen_plan")); + assert_eq!(names.len(), 13); send( &mut stdin, diff --git a/crates/devup-mcp/tests/stdio_tools.rs b/crates/devup-mcp/tests/stdio_tools.rs index 76fbf944..df7edb6d 100644 --- a/crates/devup-mcp/tests/stdio_tools.rs +++ b/crates/devup-mcp/tests/stdio_tools.rs @@ -175,6 +175,7 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { "devup_figma_search", "devup_project_context", "devup_runtime_audit", + "devup_screen_plan", "devup_skills", "devup_stack_diff", "devup_ui_validate", From e7bbe0079b864a8bbebdbb7aafffda4aab6ad57b Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 21:29:17 +0900 Subject: [PATCH 06/24] Ship the devup-implementation-plan skill and document the screen plan The own skill carries the workflow: list screens, draft, ask every question in one question-tool call, confirm, implement URL by URL, run the runtime audit, read status, and finish with the URL x width table. It is installable through devup_skills; the README describes the new tool and the export gate, and a changepack log marks devup-mcp as a minor change. Co-Authored-By: Claude Opus 5.5 --- .../changepack_log_screen_plan_first.json | 7 ++ README.md | 13 ++- crates/devup-mcp/src/server/skills.rs | 10 +- .../skills/devup-implementation-plan/SKILL.md | 100 ++++++++++++++++++ .../devup-mcp/src/server/skills/manifest.json | 18 ++++ crates/devup-mcp/tests/skills_install.rs | 5 +- 6 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 .changepacks/changepack_log_screen_plan_first.json create mode 100644 crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md diff --git a/.changepacks/changepack_log_screen_plan_first.json b/.changepacks/changepack_log_screen_plan_first.json new file mode 100644 index 00000000..4a839f21 --- /dev/null +++ b/.changepacks/changepack_log_screen_plan_first.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "Add devup_screen_plan (draft, confirm, status) so every Figma screen is mapped to an evidence-backed URL and confirmed in one batch of question-tool questions before pages are written, gate page-writing exports on a confirmed plan, report URL x width runtime status, and ship the devup-implementation-plan skill.", + "date": "2026-10-17T00:00:00+09:00" +} diff --git a/README.md b/README.md index e611d0a0..fc93ce5e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Rust-native MCP server that reads Figma designs and generates DevupUI artifacts. ## 도구 -Figma 쪽 4개, 프로젝트 쪽 6개, 스킬 1개, 모두 11개입니다. +Figma 쪽 5개, 프로젝트 쪽 7개, 스킬 1개, 모두 13개입니다. - `devup_figma_export`: Figma를 한 번 수집해 요청한 `outputs`만 투영합니다. TSX가 산출물이고, `componentTsx`·`responsiveTsx`·`devup.json`·source map·asset manifest·reference PNG를 같은 수집에서 함께 얻거나, `cache.artifactId`로 재수집 없이 추가 투영할 수 있습니다. raw snapshot·raw payload는 구현이 아니라 진단에 쓰는 것이라 `debug: true`로만 열립니다. - `devup_figma_search`: page, section, frame, component를 이름으로 탐색. URL에 `node-id`가 있으면 **그 노드와 그 아래로 범위를 좁히고**, 없으면 파일 전체를 검색합니다. 둘 중 무엇을 했는지는 응답의 `scope`가 알려줍니다 @@ -19,6 +19,7 @@ Figma 쪽 4개, 프로젝트 쪽 6개, 스킬 1개, 모두 11개입니다. - `devup_visual_compare`: 소비자가 만든 `actual.png`를 reference PNG 하나와 비교. **스스로 렌더하지 않고 브라우저를 띄우지도 명령을 실행하지도 않습니다** — 경로는 allowlist 안이어야 합니다. 기본 threshold는 0.005(0.5%). renderer 환경 manifest가 없거나 불완전하거나 유효하지 않으면 `visual.passed`가 true여도 판정은 `inconclusive`입니다 - `devup_feature_trace`: 명시적 anchor(`routePath`, `figmaNodeId`/`artifactId`, `operationId` 또는 `apiPath`+`method`, `componentPath`, `tableName`)에서 출발해 화면→API→라우트→모델→컬럼 슬라이스와 acceptance matrix를 읽어냄. **요구사항 산문만 주면 거절합니다.** 각 hop은 근거가 있거나 `UNVERIFIED`로 표시되고, 생성 산출물 소유권·UI 재사용 순위·잘림 상한을 함께 보고합니다 - `devup_runtime_audit`: 생성한 화면을 여러 viewport 폭에서 재 볼 **브라우저 실행 계획**을 검증해 쓰고(`plan`), 소비자 프로젝트의 실행기(`harness/runtime-audit`)가 쓴 보고서를 그 계획에 묶어 검증합니다(`ingest`). **서버는 브라우저를 띄우지도 명령을 실행하지도 않습니다** — 계획은 선언형 데이터뿐이고, 계획 id와 파일 해시에 묶이지 않은 보고서는 거절하며 모든 개수를 항목에서 다시 셉니다 +- `devup_screen_plan`: 페이지를 쓰기 **전에** Figma 화면마다 어느 URL이 될지 계획하고(`draft`), 열린 결정을 질문 도구 한 번 호출로 모두 물은 뒤 확정하고(`confirm`), 확정된 계획을 실제 라우트와 runtime audit 보고서에 대조해 URL × 폭 표로 보고(`status`). 라우트는 근거(기존 페이지, 기존 href에 묶인 nav 라벨, `navigationTargets`)로만 제안하고 나머지는 `UNVERIFIED` 질문이 됩니다 `devup_figma_to_ui`와 `devup_figma_to_json`은 각각 `devup_figma_export`에 `outputs: ["tsx"]`, `outputs: ["devupJson"]`을 넘긴 것과 같아서 제거했습니다. 도구가 셋이면 모든 클라이언트가 세 개의 스키마를 컨텍스트에 싣고도 어느 것을 부를지 매번 판단해야 했습니다. @@ -1047,6 +1048,16 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 360·768·1200·1440 폭 × 화면 2개 × 검사 8개 계획에서 `plan`은 4.5 ms(structured content 9.2 KB), 스크린샷 8장을 확인하는 `ingest`는 18.9 ms(12.5 KB)였습니다. 중단된 실행 이어가기(resume), 부수효과가 있는 단계, 데모 녹화·TTS·자막, 인계 패키지는 구현하지 않았습니다. +### `devup_screen_plan` — 화면 계획 먼저, 확인은 한 번, 구현은 그 다음 + +"이 디자인을 구현해줘"를 받은 에이전트가 곧바로 페이지를 쓰면, 어느 Figma 화면이 어느 URL이 됐는지 사용자는 끝내 모릅니다. 그래서 순서를 고정합니다. + +1. `draft` — Section/frame 링크(또는 브리지 선택, 또는 앞선 `devup_figma_explore`의 `screens`)와 `projectRoot`를 받아, 여러 폭으로 그린 프레임을 한 화면으로 묶고, 화면마다 `proposedRoute`·`routeEvidence`·`existingFile`·`sharedParts`·`openDecisions`를 담은 `.devup/screen-plan.json`과 같은 내용의 `.devup/screen-plan.md`를 씁니다. 근거 없는 라우트는 `proposedRoute: null`, `routeEvidence: "UNVERIFIED"`이고 질문이 됩니다. 응답의 `questions`는 질문 도구(OpenCode `question`, Claude Code `AskUserQuestion`)에 그대로 넣는 모양 — `id`, 30자 이하 `header`, `question`, 추천을 맨 앞에 두고 " (Recommended)"를 붙인 `options[{label, description}]`, `multiple` — 이고, `askInstruction`이 함께 옵니다. +2. `confirm` — 질문 id별 답을 **한 번에** 받습니다. 하나라도 빠지면 빠진 id를 이름으로 대며 거절하고, 아무것도 바꾸지 않습니다. +3. `status` — 확정된 계획을 `devup_project_context scope:"routes"`의 실제 페이지와, 선택적으로 `devup_runtime_audit`의 plan/report에 대조합니다. URL마다 구현 여부와 폭별 `pass`·`fail`(가로 넘침, 페이지 오류 등 이유)·`not-run`을 내고, 요약 한 줄(`7 URLs: 5 implemented, 3 verified at all widths, /about overflows at 390`)과 `URL | Figma screen | file | status | widths verified | screenshots` 표를 줍니다. audit 항목이 없는 폭은 `not-run`이지 `pass`가 아닙니다. + +`devup_figma_export`는 `projectRoot`가 있고 페이지를 쓰는 호출(`pageScaffold` write, `app/` 아래 `outputPaths`, `outputPaths`가 있는 여러 프레임·`allScreens`)인데 그 프레임을 덮는 확정 계획이 없으면 쓰지 않고 `status: "plan_required"`와 `devup_screen_plan draft`로 가는 `nextAction`을 돌려줍니다. 컴포넌트 하나, 쓰지 않는 미리보기는 막지 않습니다. 확정 계획이 있으면 `pageScaffold.route`는 계획에서 가져오고, 다른 라우트를 넘기면 `routeMismatch`로 거절합니다. 전체 흐름은 `devup_skills`로 설치하는 `devup-implementation-plan` 스킬에 있습니다. + ## 읽기 전용·개인정보 보호 - upstream 호출은 `get_metadata`, `get_variable_defs`, `get_design_context`, `get_code_connect_map`, `get_screenshot`과 내장된 read-only `use_figma` script로 닫혀 있습니다. diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs index 1f350aaa..fb84cd84 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -27,7 +27,8 @@ //! each one pins the commit it copied and `scripts/refresh-skills.mjs` is how //! that copy is moved forward. //! -//! `own` skills are written here, in this repository - devfive-frontend. There +//! `own` skills are written here, in this repository - devfive-frontend and +//! devup-implementation-plan. There //! is no upstream to vendor from and nothing for the refresh script to compare //! against, so they pin no commit; this repository's own history is their //! provenance. They are otherwise installed exactly like an embedded skill, @@ -136,6 +137,13 @@ const EMBEDDED: &[(&str, Documents)] = &[ ), ], ), + ( + "devup-implementation-plan", + &[( + "SKILL.md", + include_str!("skills/devup-implementation-plan/SKILL.md"), + )], + ), ( "changepacks", &[("SKILL.md", include_str!("skills/changepacks/SKILL.md"))], 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 new file mode 100644 index 00000000..f4083702 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md @@ -0,0 +1,100 @@ +--- +name: devup-implementation-plan +description: | + Plan which Figma screen becomes which URL, confirm it with the user in one + batch of questions, then implement URL by URL and finish with a URL x width + status table. Uses devup-mcp's devup_screen_plan, devup_figma_export and + devup_runtime_audit. + + TRIGGER WHEN: + - Asked to implement, build or convert a Figma design, Section or several + screens into pages + - Given a Figma Section link, or several frames, for a Next App Router project + - Asked what was built from a design, where it lives, or whether it works + + Trigger keywords: implement this design, Figma to pages, build the site, + screens, routes, URL, screen plan, devup_screen_plan +--- + +# Screen plan first, confirm once, then implement + +A design handed over as "implement this" is several screens, and each one has +to land on a URL. If you pick those URLs yourself and start writing pages, the +user cannot tell afterwards what was built, where it lives, or whether it +works. On one real site the pages were written, some widths showed "This page +couldn't load", narrow widths were cut off on the right, and nobody had a +URL-by-width table on which to notice. + +So the order is fixed, and none of it is optional. + +## Hard rules + +- **No implementation before confirm.** No page, layout or component file is + written until `devup_screen_plan` `confirm` has succeeded. +- **Ask every question in one batch.** Never one at a time, never a second + round of confirmations. Collect every open decision, then ask them all at + once. +- **Ask with the question tool, never in prose.** Pass the `questions` array + to your runtime's structured question tool in ONE call: `question` in + OpenCode, `AskUserQuestion` in Claude Code, whatever yours is called. The + array is already shaped for it - `id`, `header` (30 characters or fewer), + `question`, `options[{label, description}]` with the recommended option first + and marked " (Recommended)", and `multiple`. Custom answers are allowed. If + your tool takes fewer questions per call than the array holds, say so and + split only as far as the tool forces you to. +- **Never invent a route.** A route comes from an existing page, a nav link + already bound to an href, an explicit `navigationTargets` entry, or the + user's answer. `proposedRoute: null` with `routeEvidence: "UNVERIFIED"` stays + null until the user answers. + +## Steps + +1. **List the screens.** If you only have a Section link, `devup_figma_explore` + lists them; if you already hold the frame ids, you have the list. Then + `devup_project_context` with `scope: "routes"` shows the existing pages. +2. **Draft the plan.** Call `devup_screen_plan` with `action: "draft"`, + `projectRoot`, and the Section or frame `url` (or nothing, while one Devup + Bridge plugin is attached and the Section is selected). Pass `screens` from + step 1 to skip a second Figma read, and `navigationTargets` for any + Figma node -> route binding you were given explicitly. It writes + `.devup/screen-plan.json` and `.devup/screen-plan.md`. +3. **Show the plan, then ask.** Show the user the `screens` table (Figma + screen, widths, proposed route, evidence, existing file, shared parts, open + decisions), then pass `questions` to your question tool in ONE call, exactly + as returned. Do not reword the options; the labels are what confirm reads. +4. **Confirm.** Call `devup_screen_plan` with `action: "confirm"`, the + `planId`, and `answers` keyed by question id (the chosen label, with or + without " (Recommended)", or the user's custom text). A refusal names the + missing ids: that is your mistake, not a reason to ask again one by one - + you already have the answers, or you skipped a question in step 3. +5. **Implement URL by URL.** For each confirmed route, call + `devup_figma_export` with `projectRoot` and that screen's `frameIds`. With + `pageScaffold`, omit `route`: the export takes it from the plan and refuses + a different one. An export that would write a page without a confirmed plan + answers `status: "plan_required"` and writes nothing - go back to step 2. + Screens confirmed as a state of another page are built inside that page. +6. **Run the runtime audit.** `devup_runtime_audit` `plan` with every confirmed + URL as a screen (`routePath`) at the default widths (1440, 1100, 858, 390), + each with a `no-horizontal-overflow` check; run the executor in the + project, then `ingest` the report. +7. **Read the status.** Call `devup_screen_plan` with `action: "status"`, + `projectRoot`, `auditPlanPath` and `auditReportPath`. A width without an + audit item is `not-run`, never `pass`. +8. **Finish with the table.** End your reply with the `markdownTable` from + status - `URL | Figma screen | file | status | widths verified | + screenshots` - and its one-line `summary`, such as + `7 URLs: 5 implemented, 3 verified at all widths, /about overflows at 390`. + Report every `fail` and `not-run` as what it is. Do not call the work done + while a URL is not implemented or a width fails. + +## What the questions mean + +| id | asked when | resolves to | +| --- | --- | --- | +| `route:` | no evidence names a route, or evidence conflicts | the URL, skip, or not a page | +| `modal:` | the screen looks like a modal or overlay | a state on a host page, or its own URL | +| `collision:` | the route already has a page | merge, overwrite or skip | +| `dynamic:` | the route has a `[segment]` | sample data, or wire the API now | +| `link::` | a link's destination is not in the plan | a route, or unlinked | +| `layout:` | a part appears on several screens and no layout renders it | shared layout, or per page | +| `responsive` | screens are drawn at several widths | one responsive route, or one per width | diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index adb1e480..c2ae8ed5 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -60,6 +60,24 @@ "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend", "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend" }, + { + "name": "devup-implementation-plan", + "origin": "own", + "title": "Screen plan first, confirm once, then implement", + "description": "The order a Figma design becomes pages in: list the screens, draft a screen-to-URL plan with devup_screen_plan, ask every open question in one question-tool call, confirm, implement URL by URL, run the runtime audit, and finish with a URL x width status table.", + "usedFor": "Without it an agent handed a Figma Section picks URLs itself and writes pages straight away, so nobody can tell afterwards which screen became which URL or whether each one works at every width. It also keeps the questions in one structured batch instead of a trickle of prose confirmations.", + "repo": "dev-five-git/devup-mcp", + "path": "crates/devup-mcp/src/server/skills/devup-implementation-plan", + "documents": [ + { + "path": "SKILL.md", + "bytes": 5813, + "sha256": "aa1b1eb05c50381c9afcfac4a985763670fd949c43eab9028858ec2731659633" + } + ], + "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", + "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan" + }, { "name": "changepacks", "origin": "embedded", diff --git a/crates/devup-mcp/tests/skills_install.rs b/crates/devup-mcp/tests/skills_install.rs index 7d82ed48..4f1a57ad 100644 --- a/crates/devup-mcp/tests/skills_install.rs +++ b/crates/devup-mcp/tests/skills_install.rs @@ -137,7 +137,10 @@ async fn a_bare_workspace_reports_the_gap_and_one_call_closes_it() -> anyhow::Re assert_eq!(written.len(), carried, "{installed}"); for entry in written { assert_eq!(entry["source"], "embedded"); - if entry["name"] != "devfive-frontend" { + // Skills authored in this repository have no upstream to fetch from. + if !["devfive-frontend", "devup-implementation-plan"] + .contains(&entry["name"].as_str().unwrap()) + { assert!( entry["reason"] .as_str() From 2a27f4d847c1783befb76d584ed8e61c0cde558b Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 21:54:42 +0900 Subject: [PATCH 07/24] Ask screen plan questions in up-front rounds and per group, never after confirm draft now returns questionRounds next to the flat list: at most four questions of at most four options per round, every question exactly once, and a question with `when` always after the answer it depends on. confirm requires exactly the questions that apply and returns the missing ones as rounds. After confirm nothing is asked: the new assume action records what implementation decided on its own, and status and the plan's Markdown show every assumption; a frame outside the confirmed plan points at assume instead of a new draft. The invented / scheme is gone. Frames sharing a name are grouped across sections and asked about per group, recommending one responsive page for several widths in one section (or device-named sections) and separate pages across sections; separate pages bring a route question per frame in the same up-front batch, and a custom answer may list one route per frame. Layer-name guesses are never silent: a route matched by name is routeEvidence name-match and goes into one "Accept all / Review individually" question with per-item reviews behind it, and a modal detected by name says so. A data-figma-node-id already in a route's files is new evidence, and an optional .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping) counts as evidence and replaces the questions it answers. Explore fills each frame's section. Co-Authored-By: Claude Opus 5.5 --- crates/devup-mcp/src/server/mod.rs | 18 +- crates/devup-mcp/src/server/screen_plan.rs | 1434 +++++++++++++---- .../devup-mcp/src/server/screen_plan_tests.rs | 385 ++++- crates/devup-mcp/tests/screen_plan_tool.rs | 42 +- 4 files changed, 1508 insertions(+), 371 deletions(-) diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index dfb605a8..7438f262 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -938,6 +938,17 @@ impl DevupServer { name: node["name"].as_str().unwrap_or_default().to_owned(), width: node["bounds"]["width"].as_f64()?, height: node["bounds"]["height"].as_f64(), + // The parent is the Section the frame is drawn in; the + // breadcrumb ends with the frame, so its parent's name is + // the one before it. + section: node["parentId"].as_str().map(str::to_owned), + section_name: node["breadcrumb"].as_array().and_then(|names| { + names + .len() + .checked_sub(2) + .and_then(|index| names[index].as_str()) + .map(str::to_owned) + }), parts: Vec::new(), links: Vec::new(), }) @@ -1687,7 +1698,7 @@ impl DevupServer { } #[tool( - description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. 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 (skill devup-implementation-plan). \ + description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. 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 (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. \ @@ -2588,7 +2599,7 @@ impl DevupServer { Ok(tool_result(result)) } #[tool( - description = "Plan which Figma screen becomes which URL before any page is written, confirm it once, then report it URL by width (action: draft | confirm | status). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore), groups frames drawn at several widths, and proposes a route only from evidence: an existing App Router page, a nav link label bound to an existing href, or a navigationTargets entry. Anything else is proposedRoute null, routeEvidence UNVERIFIED, and a question. It writes .devup/screen-plan.json and .md and returns `questions`, already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed). Ask ALL of them in ONE call to your runtime's question tool (`question` in OpenCode, `AskUserQuestion` in Claude Code) - never one at a time and never as prose - and do not implement before confirm. confirm takes every answer keyed by question id and refuses the call, naming the missing ids, if any is unanswered. 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. The devup-implementation-plan skill (devup_skills) carries the whole workflow.", + description = "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). 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. Frames sharing a name are asked about per group (one responsive page or separate pages), recommended from their sections and widths. 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 4 per round. Ask every round now, in consecutive question-tool calls (`question` in OpenCode, `AskUserQuestion` in Claude Code) 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. The devup-implementation-plan skill (devup_skills) carries the whole workflow.", output_schema = permissive_object_output_schema() )] async fn devup_screen_plan( @@ -2601,10 +2612,11 @@ impl DevupServer { screen_plan::draft(&input, &screens, source, &self.output_policy).await } "confirm" => screen_plan::confirm(&input, &self.output_policy), + "assume" => screen_plan::assume(&input, &self.output_policy), "status" => screen_plan::status(&input, &self.output_policy, &self.artifacts).await, other => Err(DevupError::new( ErrorCode::DevupInvalidInput, - format!("action must be draft, confirm or status, not {other}."), + format!("action must be draft, confirm, status or assume, not {other}."), false, )), } diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index 87a9bcf4..b4d67fd0 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -9,11 +9,15 @@ //! which anyone could have noticed. //! //! So the order is fixed. `draft` maps every screen to a route from evidence -//! only - an existing App Router page, a nav label already bound to an href, -//! or an explicit `navigationTargets` entry - and turns everything it cannot -//! decide into one batch of questions shaped for the runtime's question tool. -//! `confirm` takes every answer at once and refuses to proceed with any -//! missing. `status` reads the confirmed plan against the routes on disk and a +//! only - an existing page built from the frame, a nav label already bound to +//! an href, an explicit `navigationTargets` entry or the project's config - +//! and turns everything it cannot decide into questions shaped for the +//! runtime's question tool, split into rounds that are all asked up front, back +//! to back. A guess from a layer name is never silent: it is marked +//! `name-match` and put to the user in the same rounds. `confirm` takes every +//! answer that applies at once and refuses to proceed with any missing. After +//! it nothing is asked: implementation records what it decided alone with +//! `assume`. `status` reads the confirmed plan against the routes on disk and a //! runtime audit report, one row per URL and width, where a width nobody ran is //! `not-run` and never `pass`. //! @@ -40,13 +44,20 @@ const SCHEMA_VERSION: u64 = 1; /// it from the same place, so the two cannot disagree about which plan holds. pub(super) const PLAN_FILE: &str = ".devup/screen-plan.json"; const PLAN_MARKDOWN: &str = ".devup/screen-plan.md"; -pub(super) const ASK_INSTRUCTION: &str = "Ask all of these in ONE call to your question tool; do not ask in prose; do not implement before confirm."; +pub(super) const ASK_INSTRUCTION: &str = "Ask every round in questionRounds now, before implementing, in consecutive question-tool calls with no other work in between; skip a question whose `when` was not met; never ask in prose. Then confirm. After confirm never ask the user anything: take the recommended or most conservative option and record it with action assume."; +/// What implementation does instead of asking. Someone may have walked away +/// from the keyboard three hours ago; a question then stops all the work. +pub(super) const IMPLEMENT_INSTRUCTION: &str = "Implement URL by URL without asking the user anything. devup_figma_export with projectRoot takes each page's route from this plan. Anything new: take the recommended or most conservative option, record it with devup_screen_plan action assume, and keep going. Then run devup_runtime_audit at every width and devup_screen_plan action status."; +/// The optional project file whose entries count as evidence. +pub(super) const CONFIG_FILE: &str = ".devup/screen-plan.config.json"; /// The question tools this is shaped for cut headers short. OpenCode's has no /// hard limit; 30 characters stays readable in all of them. pub(super) const HEADER_LIMIT: usize = 30; /// Claude Code's AskUserQuestion takes two to four options and adds its own /// "Other" for a custom answer. Four is the most every runtime accepts. const MAX_OPTIONS: usize = 4; +/// AskUserQuestion also takes at most four questions per call. +pub(super) const MAX_QUESTIONS_PER_ROUND: usize = 4; const RECOMMENDED: &str = " (Recommended)"; const MAX_SCREENS: usize = 100; const MAX_PLAN_BYTES: usize = 2 * 1024 * 1024; @@ -85,7 +96,9 @@ const ROOT_WORDS: &[&str] = &["home", "main", "index", "landing", "메인", "홈 pub struct ScreenPlanInput { /// draft: map screens to routes and return every open question. /// confirm: apply all answers at once. status: URL x width report. - #[schemars(extend("enum" = ["draft", "confirm", "status"]))] + /// assume: after confirm, record a decision implementation made without + /// asking. + #[schemars(extend("enum" = ["draft", "confirm", "status", "assume"]))] pub action: String, /// The Next App Router project. The plan is written to /// /.devup/screen-plan.json and .md, under an allowed root. @@ -121,6 +134,70 @@ pub struct ScreenPlanInput { /// draft: re-read Figma instead of reusing a cached exploration. #[serde(default)] pub refresh: bool, + /// assume: what was decided during implementation without asking. + #[serde(default)] + pub assumptions: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct AssumptionInput { + /// What it is about, e.g. a frame id or a URL. + pub about: String, + /// The option taken: the plan's recommended one or the most conservative. + pub decision: String, + #[serde(default)] + pub reason: Option, +} + +/// `.devup/screen-plan.config.json`: how this project's designers name +/// things. Each entry present counts as evidence and replaces the question +/// it would otherwise take. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct PlanConfig { + /// Replaces the built-in modal words (modal, popup, dialog, ...). + #[serde(default)] + pub modal_keywords: Option>, + /// Layer name -> route. + #[serde(default)] + pub route_aliases: BTreeMap, + /// responsive | separate | ask (the default). + #[serde(default)] + pub width_grouping: Option, +} + +fn read_config(project_root: &str) -> Result, DevupError> { + let path = Path::new(project_root).join(CONFIG_FILE); + let Ok(bytes) = std::fs::read(&path) else { + return Ok(None); + }; + let mut config: PlanConfig = serde_json::from_slice(&bytes).map_err(|error| { + invalid(format!( + "{} is not a valid screen plan config: {error}", + path.display() + )) + })?; + if let Some(mode) = config.width_grouping.as_deref() + && !matches!(mode, "responsive" | "separate" | "ask") + { + return Err(invalid(format!( + "{}: widthGrouping must be responsive, separate or ask, not {mode:?}.", + path.display() + ))); + } + for (alias, route) in &mut config.route_aliases { + *route = normalize_route(route) + .filter(|route| valid_route(route)) + .ok_or_else(|| { + invalid(format!( + "{}: routeAliases[{alias:?}] = {route:?} is not an app route.", + path.display() + )) + })?; + } + Ok(Some(config)) } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -148,6 +225,13 @@ pub struct PlanScreenInput { pub width: f64, #[serde(default)] pub height: Option, + /// The Figma Section (or other parent) the frame sits in, by id or name. + /// devup_figma_explore fills it from the frame's parent. + #[serde(default)] + pub section: Option, + /// The section's layer name, when `section` is an id. + #[serde(default)] + pub section_name: Option, /// Shared part names the caller saw in this screen, e.g. Header, Footer. #[serde(default)] pub parts: Vec, @@ -206,6 +290,30 @@ pub(super) struct ProjectEvidence { /// `src/app` or `app`, whichever the existing pages use. pub app_dir: String, pub inventory_note: Option, + /// Figma node id -> (route, file) for every `data-figma-node-id` already + /// in one route's sources: that page was built from that frame. + pub figma_nodes: BTreeMap, + pub config: PlanConfig, + /// The config file, when one was read. + pub config_file: Option, +} + +/// The `data-figma-node-id="..."` values in one source file. +fn figma_node_ids(source: &str) -> Vec { + let mut ids = Vec::new(); + let mut rest = source; + while let Some(start) = rest.find("data-figma-node-id=") { + rest = &rest[start + "data-figma-node-id=".len()..]; + let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { + continue; + }; + rest = &rest[1..]; + if let Some(end) = rest.find(quote) { + ids.push(rest[..end].to_owned()); + rest = &rest[end..]; + } + } + ids } /// Reads routes, layout components and static links through the same @@ -213,8 +321,11 @@ pub(super) struct ProjectEvidence { /// parser `devup_ui_validate` uses on each scanned file. pub(super) async fn project_evidence(project_root: &str) -> Result { let inventory = super::project_context::run("ui", Some(project_root), None).await?; + let config = read_config(project_root)?; let mut evidence = ProjectEvidence { app_dir: "src/app".into(), + config_file: config.is_some().then(|| CONFIG_FILE.to_owned()), + config: config.unwrap_or_default(), ..ProjectEvidence::default() }; if inventory["found"] != true { @@ -231,14 +342,23 @@ pub(super) async fn project_evidence(project_root: &str) -> Result>::new(); for route in array(&inventory["routes"]) { - evidence.routes.push(( - text(&route["route"]).to_owned(), - text(&route["page"]).to_owned(), - )); + let path = text(&route["route"]).to_owned(); + evidence + .routes + .push((path.clone(), text(&route["page"]).to_owned())); sources.insert(text(&route["page"]).to_owned()); + owner + .entry(text(&route["page"]).to_owned()) + .or_default() + .insert(path.clone()); for component in array(&route["components"]) { sources.insert(text(component).to_owned()); + owner + .entry(text(component).to_owned()) + .or_default() + .insert(path.clone()); } } if evidence @@ -282,6 +402,18 @@ pub(super) async fn project_evidence(project_root: &str) -> Result String { .collect() } -fn is_modal(name: &str) -> bool { - let lowered = name.to_lowercase(); - MODAL_WORDS.iter().any(|word| lowered.contains(word)) -} - /// The same rule `pageScaffold.route` is held to, so a confirmed route is one /// the export will accept. pub(super) fn valid_route(route: &str) -> bool { @@ -453,6 +580,13 @@ fn ask_shape(questions: &[Value]) -> Vec { if let Some(object) = shaped.as_object_mut() { object.remove("resolve"); object.remove("kind"); + if let Some(conditions) = object.get_mut("when").and_then(Value::as_array_mut) { + for condition in conditions { + if let Some(condition) = condition.as_object_mut() { + condition.remove("action"); + } + } + } } shaped }) @@ -465,6 +599,27 @@ struct Frame { name: String, width: f64, height: Option, + section: Option, + section_name: Option, +} + +impl Frame { + fn json(&self) -> Value { + json!({"nodeId": self.node_id, "name": self.name, "width": self.width, "height": self.height, + "section": self.section, "sectionName": self.section_name}) + } +} + +/// How the frames of one name are turned into pages. +#[derive(Debug, Clone, PartialEq)] +enum Grouping { + /// Decided: one page, from a single frame, project config, or evidence. + Fixed(&'static str), + /// Asked per group; the recommendation follows the evidence. + Ask { + recommend_single: bool, + reason: String, + }, } #[derive(Debug, Clone)] @@ -473,6 +628,7 @@ struct DraftScreen { words: Vec, parts: Vec, links: Vec, + grouping: Grouping, } impl DraftScreen { @@ -483,21 +639,59 @@ impl DraftScreen { &self.frames[0].name } fn widths(&self) -> Vec { - let mut widths = self - .frames - .iter() - .map(|frame| frame.width.round().max(0.0) as u32) - .collect::>(); - widths.sort_unstable_by(|a, b| b.cmp(a)); - widths.dedup(); - widths + widths_of(&self.frames) } } -/// Frames that share a base name and could be one screen at several widths -/// become one screen, widest first. The rule is the one `responsiveTsx` is -/// refused by, so a group here is a group the export can merge. -fn group_screens(screens: &[PlanScreenInput]) -> Vec { +fn widths_of(frames: &[Frame]) -> Vec { + let mut widths = frames + .iter() + .map(|frame| frame.width.round().max(0.0) as u32) + .collect::>(); + widths.sort_unstable_by(|a, b| b.cmp(a)); + widths.dedup(); + widths +} + +fn width_list(widths: &[u32]) -> String { + widths + .iter() + .map(u32::to_string) + .collect::>() + .join("/") +} + +/// Whether every section name says only which device it is for, e.g. `PC` +/// and `Mobile`: a designer splitting one page by device, not two pages. +fn device_sections(frames: &[Frame]) -> bool { + frames.iter().all(|frame| { + frame.section_name.as_deref().is_some_and(|name| { + let lowered = name.to_lowercase(); + let words = lowered + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect::>(); + !words.is_empty() + && words.iter().all(|word| { + DEVICE_WORDS.contains(word) || word.chars().all(|c| c.is_ascii_digit()) + }) + }) + }) +} + +/// Frames that share a base name are candidates for one page. Whether they +/// are one is not assumed: designers differ, so a group that could go either +/// way is asked about on its own, with the recommendation the evidence gives. +/// +/// - several widths in one section that can merge into one responsive tree: +/// one responsive page is recommended; +/// - the same name in different sections: separate pages are recommended, +/// unless the sections are named only for devices (`PC`, `Mobile`); +/// - the same name at the same width in one section is several screens, not a +/// width family, and is not grouped at all. +/// +/// `widthGrouping` in the project config decides instead of asking. +fn group_screens(screens: &[PlanScreenInput], config: &PlanConfig) -> Vec { let mut order = Vec::::new(); let mut groups = BTreeMap::>::new(); for screen in screens { @@ -507,106 +701,151 @@ fn group_screens(screens: &[PlanScreenInput]) -> Vec { } groups.entry(key).or_default().push(screen); } + let make = |members: &[&PlanScreenInput], grouping: Grouping| { + let mut frames = members + .iter() + .map(|screen| Frame { + node_id: screen.node_id.clone(), + name: screen.name.clone(), + width: screen.width, + height: screen.height, + section: screen.section.clone(), + section_name: screen.section_name.clone(), + }) + .collect::>(); + frames.sort_by(|a, b| b.width.total_cmp(&a.width)); + let mut parts = Vec::::new(); + let mut links = Vec::new(); + for screen in members { + for part in &screen.parts { + if !parts.contains(part) { + parts.push(part.clone()); + } + } + links.extend(screen.links.iter().cloned()); + } + DraftScreen { + frames, + words: base_words(&members[0].name), + parts, + links, + grouping, + } + }; let mut result = Vec::new(); for key in order { let members = &groups[&key]; + if members.len() == 1 { + result.push(make(members, Grouping::Fixed("single-frame"))); + continue; + } // Asked of the base name, not the layer name: `Main - 1440` and // `Main - 390` are one name across two widths once the width is gone. let named = members .iter() .map(|screen| (key.as_str(), screen.width)) .collect::>(); - let family = members.len() > 1 - && devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible(&named); - let make = |members: Vec<&PlanScreenInput>| { - let mut frames = members - .iter() - .map(|screen| Frame { - node_id: screen.node_id.clone(), - name: screen.name.clone(), - width: screen.width, - height: screen.height, - }) - .collect::>(); - frames.sort_by(|a, b| b.width.total_cmp(&a.width)); - let mut parts = Vec::::new(); - let mut links = Vec::new(); - for screen in &members { - for part in &screen.parts { - if !parts.contains(part) { - parts.push(part.clone()); - } - } - links.extend(screen.links.iter().cloned()); + let family = devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible(&named); + let sections = members + .iter() + .map(|screen| screen.section.as_deref()) + .collect::>(); + let same_section = sections.len() == 1; + if same_section && !family { + for member in members { + result.push(make(&[member], Grouping::Fixed("single-frame"))); } - DraftScreen { - frames, - words: base_words(&members[0].name), - parts, - links, + continue; + } + match config.width_grouping.as_deref() { + Some("responsive") if family => { + result.push(make(members, Grouping::Fixed("config:widthGrouping"))); } - }; - if family { - result.push(make(members.clone())); - } else { - for member in members { - result.push(make(vec![member])); + Some("separate") => { + for member in members { + result.push(make(&[member], Grouping::Fixed("config:widthGrouping"))); + } + } + _ => { + let probe = make(members, Grouping::Fixed("")); + let by_device = device_sections(&probe.frames); + let recommend_single = family && (same_section || by_device); + let reason = if same_section { + "drawn at several widths in one section with matching names".to_owned() + } else if recommend_single { + "split across sections named only for devices".to_owned() + } else { + format!("the same name in {} different sections", sections.len()) + }; + result.push(make( + members, + Grouping::Ask { + recommend_single, + reason, + }, + )); } } } result } -/// Every route the evidence supports for one screen, each with where it came -/// from. Several different routes are a conflict and are asked, not picked. -fn route_evidence( - screen: &DraftScreen, +/// One route the evidence names, what kind of evidence it is, and where. +#[derive(Debug, Clone)] +struct RouteHit { + route: String, + kind: &'static str, + detail: String, +} + +/// `name-match` is the only kind that is a guess: a layer name compared with +/// a route segment. Every other kind is something the project or the caller +/// already says. +const NAME_MATCH: &str = "name-match"; + +/// Every route the evidence supports for these frames. +fn route_hits( + frames: &[Frame], + words: &[String], targets: &BTreeMap, evidence: &ProjectEvidence, -) -> (Vec<(String, String)>, Vec) { - let mut notes = Vec::new(); - let explicit = screen - .frames - .iter() - .filter_map(|frame| { - let target = targets.get(&frame.node_id)?; + notes: &mut Vec, +) -> Vec { + let mut hits = Vec::new(); + for frame in frames { + if let Some(target) = targets.get(&frame.node_id) { match normalize_route(target) { - Some(route) => Some((route, format!("navigationTargets[{}]", frame.node_id))), - None => { - notes.push(format!( - "navigationTargets[{}] = {target:?} is not an app route and was not used.", - frame.node_id - )); - None - } + Some(route) => hits.push(RouteHit { + route, + kind: "navigationTargets", + detail: format!("navigationTargets[{}]", frame.node_id), + }), + None => notes.push(format!( + "navigationTargets[{}] = {target:?} is not an app route and was not used.", + frame.node_id + )), + } + } + if let Some((route, file)) = evidence.figma_nodes.get(&frame.node_id) { + hits.push(RouteHit { + route: route.clone(), + kind: "figma-node", + detail: format!("data-figma-node-id=\"{}\" in {file}", frame.node_id), + }); + } + for (alias, route) in &evidence.config.route_aliases { + if alias == &frame.name || compact(alias) == compact(&words.join("")) { + hits.push(RouteHit { + route: route.clone(), + kind: "config", + detail: format!("{CONFIG_FILE} routeAliases[{alias:?}]"), + }); } - }) - .collect::>(); - if !explicit.is_empty() { - return (explicit, notes); - } - let mut found = Vec::new(); - let screen_slug = slug(&screen.words); - let is_root = screen - .words - .iter() - .any(|word| ROOT_WORDS.contains(&word.as_str())); - for (route, page) in &evidence.routes { - // The last segment only: `/news/[id]` is a detail page, and a screen - // named News is the list, not that page. - let last = route - .trim_matches('/') - .rsplit('/') - .next() - .unwrap_or_default(); - if (!screen_slug.is_empty() && last == screen_slug) || (route == "/" && is_root) { - found.push((route.clone(), format!("existing-route: {page}"))); } } - let name = compact(&screen.words.join("")); + let name = compact(&words.join("")); for (label, href, location) in &evidence.nav_links { - let label_key = compact(label); - if label_key.is_empty() || label_key != name { + if compact(label).is_empty() || compact(label) != name { continue; } if let Some((route, _)) = evidence @@ -614,13 +853,43 @@ fn route_evidence( .iter() .find(|(route, _)| super::navigation::matches_route(route, href)) { - found.push(( - route.clone(), - format!("nav-label: {label:?} -> {href} ({location})"), - )); + hits.push(RouteHit { + route: route.clone(), + kind: "nav-label", + detail: format!("nav-label: {label:?} -> {href} ({location})"), + }); } } - (found, notes) + if hits.is_empty() { + let screen_slug = slug(words); + let is_root = words.iter().any(|word| ROOT_WORDS.contains(&word.as_str())); + for (route, page) in &evidence.routes { + // The last segment only: `/news/[id]` is a detail page, and a + // screen named News is the list, not that page. + let last = route + .trim_matches('/') + .rsplit('/') + .next() + .unwrap_or_default(); + if (!screen_slug.is_empty() && last == screen_slug) || (route == "/" && is_root) { + hits.push(RouteHit { + route: route.clone(), + kind: NAME_MATCH, + detail: format!("layer name matches the existing route {route} ({page})"), + }); + } + } + } + hits +} + +/// The route the hits agree on, if they agree. +fn agreed(hits: &[RouteHit]) -> Option<&RouteHit> { + let distinct = hits + .iter() + .map(|hit| hit.route.as_str()) + .collect::>(); + (distinct.len() == 1).then(|| &hits[0]) } fn existing_page<'a>(route: &str, evidence: &'a ProjectEvidence) -> Option<&'a str> { @@ -640,6 +909,17 @@ fn planned_page(route: &str, app_dir: &str) -> String { } } +/// A condition on another question's answer: this question is asked only +/// when that one resolved to `action`. The caller sees the label. +fn when(question: &str, label: &str, action: Value) -> Value { + json!({"question": question, "answer": label, "action": action}) +} + +const SINGLE: &str = "One responsive page"; +const SEPARATE: &str = "Separate pages"; +const ACCEPT_ALL: &str = "Accept all"; +const REVIEW: &str = "Review individually"; + /// Builds the draft plan. Pure: every input is passed in, so the rules are /// tested without Figma or a project on disk. pub(super) fn draft_plan( @@ -659,38 +939,59 @@ pub(super) fn draft_plan( "draft plans at most {MAX_SCREENS} screens; link one Section at a time." ))); } - let drafts = group_screens(screens); - let mut rows = Vec::new(); - let mut questions = Vec::new(); - let mut route_owner = BTreeMap::>::new(); - let mut resolved_routes = Vec::new(); - for (index, screen) in drafts.iter().enumerate() { - let (found, evidence_notes) = route_evidence(screen, targets, evidence); - let distinct = found - .iter() - .map(|(route, _)| route.clone()) - .collect::>(); - let proposed = (distinct.len() == 1).then(|| found[0].0.clone()); - if let Some(route) = &proposed { - route_owner.entry(route.clone()).or_default().push(index); + let drafts = group_screens(screens, &evidence.config); + let mut screen_notes = vec![Vec::new(); drafts.len()]; + let mut decided = drafts + .iter() + .enumerate() + .map(|(index, screen)| { + let hits = route_hits( + &screen.frames, + &screen.words, + targets, + evidence, + &mut screen_notes[index], + ); + let proposed = agreed(&hits).map(|hit| (hit.route.clone(), hit.kind)); + (hits, proposed) + }) + .collect::>(); + // Two screens on one URL is a decision, not a fact: neither keeps it unasked. + let mut owners = BTreeMap::>::new(); + for (index, (_, proposed)) in decided.iter().enumerate() { + if let Some((route, _)) = proposed { + owners.entry(route.clone()).or_default().push(index); } - resolved_routes.push((proposed, found, evidence_notes)); } - // Two screens on one URL is a decision, not a fact: neither keeps it unasked. - for owners in route_owner.values().filter(|owners| owners.len() > 1) { - for index in owners { - let entry = &mut resolved_routes[*index]; - if let Some(route) = entry.0.take() { - entry.2.push(format!( - "{route} is also the evidence-backed route of another screen in this plan." + for indices in owners.values().filter(|indices| indices.len() > 1) { + for index in indices { + if let Some((route, _)) = decided[*index].1.take() { + screen_notes[*index].push(format!( + "{route} is also the route of another screen in this plan." )); } } } - let claimed = resolved_routes + let claimed = decided .iter() - .filter_map(|(route, _, _)| route.clone()) + .filter_map(|(_, proposed)| proposed.as_ref().map(|(route, _)| route.clone())) .collect::>(); + let modal_words = evidence + .config + .modal_keywords + .clone() + .unwrap_or_else(|| MODAL_WORDS.iter().map(|word| (*word).to_owned()).collect()); + let kind_evidence = if evidence.config.modal_keywords.is_some() { + "config" + } else { + NAME_MATCH + }; + let is_modal = |name: &str| { + let lowered = name.to_lowercase(); + modal_words + .iter() + .any(|word| lowered.contains(&word.to_lowercase())) + }; let page_screens = drafts .iter() .enumerate() @@ -707,44 +1008,200 @@ pub(super) fn draft_plan( .map(move |frame| (frame.node_id.clone(), index)) }) .collect::>(); + let route_choice = |route: &str, why: &str, extra: Value| { + let description = match existing_page(route, evidence) { + Some(page) => format!("{why} Exists at {page}; the screen is merged into it."), + None => format!( + "{why} New page at {}.", + planned_page(route, &evidence.app_dir) + ), + }; + let mut action = json!({"route": route}); + if let (Some(object), Some(extra)) = (action.as_object_mut(), extra.as_object()) { + object.extend(extra.clone()); + } + choice(route, description, action) + }; + let unclaimed_for = |words: &[String], exclude: &BTreeSet| { + let mut unclaimed = evidence + .routes + .iter() + .filter(|(route, _)| !claimed.contains(route) && !exclude.contains(route)) + .map(|(route, _)| { + let score = route + .trim_matches('/') + .split(['/', '-']) + .filter(|segment| words.iter().any(|word| word == segment)) + .count(); + (score, route.clone()) + }) + .collect::>(); + unclaimed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + unclaimed + }; + let mut questions = Vec::new(); + let mut rows = Vec::new(); + let mut heuristics = Vec::new(); let mut part_screens = BTreeMap::>::new(); - let mut grouped = Vec::new(); for (index, screen) in drafts.iter().enumerate() { - let (proposed, found, evidence_notes) = &resolved_routes[index]; + let (hits, proposed) = &decided[index]; let modal = is_modal(screen.name()); let id = screen.id().to_owned(); let name = screen.name().to_owned(); let widths = screen.widths(); let mut decisions = Vec::new(); - let candidates = found + let candidates = hits .iter() - .map(|(route, _)| route.clone()) + .map(|hit| hit.route.clone()) .collect::>(); let screen_slug = slug(&screen.words); let new_route = (!screen_slug.is_empty()).then(|| format!("/{screen_slug}")); - let similar = |route: &str| { - route - .trim_matches('/') - .split(['/', '-']) - .filter(|segment| screen.words.iter().any(|word| word == segment)) - .count() + // Questions about this screen as one page apply only if the group is + // one page; when that is still being asked, they carry the condition. + let group_id = format!("group:{id}"); + let single_when = match &screen.grouping { + Grouping::Ask { .. } => vec![when(&group_id, SINGLE, json!({"group": "single"}))], + Grouping::Fixed(_) => Vec::new(), }; - let mut unclaimed = evidence - .routes - .iter() - .filter(|(route, _)| !claimed.contains(route) && !candidates.contains(route)) - .map(|(route, page)| (similar(route), route.clone(), page.clone())) - .collect::>(); - unclaimed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); - let route_choice = |route: &str, why: &str| { - let description = match existing_page(route, evidence) { - Some(page) => format!("{why} Exists at {page}; the screen is merged into it."), - None => format!( - "{why} New page at {}.", - planned_page(route, &evidence.app_dir) + let push = |questions: &mut Vec, mut question: Value, extra: &[Value]| { + let conditions = single_when.iter().chain(extra).cloned().collect::>(); + if !conditions.is_empty() { + question["when"] = json!(conditions); + } + questions.push(question); + }; + if let Grouping::Ask { + recommend_single, + reason, + } = &screen.grouping + { + decisions.push("one-page-or-separate"); + let single = choice( + SINGLE, + "One URL; the widths become devup-ui responsive arrays (responsiveTsx).", + json!({"group": "single"}), + ); + let separate = choice( + SEPARATE, + "Each frame is its own page and gets its own route, asked next.", + json!({"group": "separate"}), + ); + let frames = screen + .frames + .iter() + .map(|frame| { + format!( + "{} ({}, {}px{})", + frame.name, + frame.node_id, + frame.width.round(), + frame + .section_name + .as_deref() + .or(frame.section.as_deref()) + .map(|section| format!(", section {section}")) + .unwrap_or_default() + ) + }) + .collect::>() + .join("; "); + questions.push(question( + group_id.clone(), + header("Pages", &name), + format!( + "These frames share the name \"{name}\" and are {reason}: {frames}. One responsive page, or separate pages? A custom answer may list one route per frame, e.g. \"/a, /b\"." ), - }; - choice(route, description, json!({"route": route})) + if *recommend_single { + vec![single, separate] + } else { + vec![separate, single] + }, + "group", + )); + for frame in &screen.frames { + let mut frame_notes = Vec::new(); + let frame_hits = route_hits( + std::slice::from_ref(frame), + &screen.words, + targets, + evidence, + &mut frame_notes, + ); + let mut choices = Vec::new(); + for hit in frame_hits.iter().filter(|hit| hit.kind != NAME_MATCH) { + choices.push(route_choice( + &hit.route, + &format!("From {}.", hit.detail), + json!({}), + )); + } + for hit in frame_hits.iter().filter(|hit| hit.kind == NAME_MATCH) { + choices.push(route_choice( + &hit.route, + "Inferred from the layer name; only one frame can take it.", + json!({}), + )); + } + let section_slug = frame + .section_name + .as_deref() + .map(|section| slug(&base_words(section))) + .unwrap_or_default(); + if !section_slug.is_empty() && !screen_slug.is_empty() { + choices.push(route_choice( + &format!("/{section_slug}/{screen_slug}"), + "Named after its Figma section and layer.", + json!({}), + )); + } + for (_, route) in unclaimed_for(&screen.words, &candidates) + .iter() + .filter(|(score, _)| *score > 0) + { + choices.push(route_choice( + route, + "Existing route with a similar name.", + json!({}), + )); + } + choices.truncate(MAX_OPTIONS - 1); + choices.push(choice( + SKIP, + "Leave this frame out of this build.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let mut asked = question( + format!("split-route:{}", frame.node_id), + header("Route", &format!("{} {}", frame.name, frame.width.round())), + format!( + "As a separate page, which URL should \"{}\" ({}, {}px) be served at?", + frame.name, + frame.node_id, + frame.width.round() + ), + choices, + "route", + ); + asked["when"] = json!([when(&group_id, SEPARATE, json!({"group": "separate"}))]); + questions.push(asked); + } + } + let route_evidence = match proposed { + Some((_, kind)) if *kind == NAME_MATCH => json!(NAME_MATCH), + Some(_) => json!( + hits.iter() + .map(|hit| hit.detail.clone()) + .collect::>() + .join("; ") + ), + None => json!("UNVERIFIED"), }; if modal { decisions.push("modal-or-route"); @@ -779,14 +1236,21 @@ pub(super) fn draft_plan( .collect::>(); let own_route = proposed .as_ref() - .map(|route| route_choice(route, "Its own URL, from route evidence.")) + .map(|(route, _)| route_choice(route, "A page with its own URL.", json!({}))) .or_else(|| { - new_route - .as_ref() - .map(|route| route_choice(route, "Its own URL, named after the layer.")) + new_route.as_ref().map(|route| { + route_choice( + route, + "A page with its own URL, named after the layer.", + json!({}), + ) + }) }); let mut choices = Vec::new(); - if proposed.is_some() { + if proposed + .as_ref() + .is_some_and(|(_, kind)| *kind != NAME_MATCH) + { choices.extend(own_route); choices.extend(state_choices); } else { @@ -798,15 +1262,70 @@ pub(super) fn draft_plan( "Leave it out of this build.", json!({"skip": true}), )); - questions.push(question( - format!("modal:{id}"), - header("Modal", &name), - format!( - "\"{name}\" ({id}) looks like a modal or overlay. Is it a state of another screen, or a page with its own URL?" + let why = if kind_evidence == NAME_MATCH { + "Its layer name suggests a modal or overlay (name-match)" + } else { + "Its layer name matches the project's modalKeywords" + }; + push( + &mut questions, + question( + format!("modal:{id}"), + header("Modal", &name), + format!( + "\"{name}\" ({id}): {why}. Is it a state of another screen, or a page with its own URL?" + ), + choices, + "modal", + ), + &[], + ); + if kind_evidence == NAME_MATCH { + heuristics.push(json!({"screen": id, "name": name, "decision": "kind: modal", "evidence": NAME_MATCH, "askedIn": format!("modal:{id}")})); + } + } else if let Some((route, NAME_MATCH)) = proposed { + decisions.push("route-from-name-match"); + heuristics.push(json!({"screen": id, "name": name, "decision": format!("route: {route}"), "evidence": NAME_MATCH, "askedIn": format!("review:{id}")})); + let mut choices = vec![ + route_choice( + route, + "Keep the inferred route.", + json!({"collision": "merge"}), + ), + choice( + format!("Overwrite {route}"), + "Keep the inferred route, but replace the existing page file outright.", + json!({"route": route, "collision": "overwrite"}), ), - choices, - "modal", + ]; + if let Some(alternative) = new_route + .as_ref() + .filter(|candidate| *candidate != route && !claimed.contains(*candidate)) + { + choices.push(route_choice( + alternative, + "Named after the layer.", + json!({}), + )); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), )); + push( + &mut questions, + question( + format!("review:{id}"), + header("Check route", &name), + format!( + "\"{name}\" ({id}) was matched to {route} by its layer name only. Keep it?" + ), + choices, + "route", + ), + &[when("heuristics", REVIEW, json!({"heuristics": "review"}))], + ); } else if proposed.is_none() { decisions.push(if candidates.len() > 1 { "conflicting-route-evidence" @@ -815,18 +1334,28 @@ pub(super) fn draft_plan( }); let mut choices = Vec::new(); for route in &candidates { - choices.push(route_choice(route, "Named by conflicting evidence.")); + choices.push(route_choice( + route, + "Named by conflicting evidence.", + json!({}), + )); } - for (_, route, _) in unclaimed.iter().filter(|(score, _, _)| *score > 0) { - choices.push(route_choice(route, "Existing route with a similar name.")); + let unclaimed = unclaimed_for(&screen.words, &candidates); + for (_, route) in unclaimed.iter().filter(|(score, _)| *score > 0) { + choices.push(route_choice( + route, + "Existing route with a similar name.", + json!({}), + )); } if let Some(route) = new_route.as_ref().filter(|route| !claimed.contains(*route)) { - choices.push(route_choice(route, "Named after the layer.")); + choices.push(route_choice(route, "Named after the layer.", json!({}))); } - for (_, route, _) in unclaimed.iter().filter(|(score, _, _)| *score == 0).take(1) { + for (_, route) in unclaimed.iter().filter(|(score, _)| *score == 0).take(1) { choices.push(route_choice( route, "Existing route not claimed by another screen.", + json!({}), )); } choices.truncate(MAX_OPTIONS - 1); @@ -848,79 +1377,93 @@ pub(super) fn draft_plan( candidates.iter().cloned().collect::>().join(", ") ) } else { - "No existing route, nav link or navigationTargets entry names it.".to_owned() + "No existing page, nav link, navigationTargets entry or routeAliases entry names it." + .to_owned() }; - questions.push(question( - format!("route:{id}"), - header("Route", &name), - format!( - "Which URL should the Figma screen \"{name}\" ({id}, drawn at {}) be served at? {reason}", - widths.iter().map(u32::to_string).collect::>().join("/") + push( + &mut questions, + question( + format!("route:{id}"), + header("Route", &name), + format!( + "Which URL should the Figma screen \"{name}\" ({id}, drawn at {}) be served at? {reason}", + width_list(&widths) + ), + choices, + "route", ), - choices, - "route", - )); + &[], + ); } - let existing = proposed - .as_deref() - .and_then(|route| existing_page(route, evidence)); - if !modal && let (Some(route), Some(page)) = (proposed, existing) { + let route = proposed.as_ref().map(|(route, _)| route.as_str()); + let existing = route.and_then(|route| existing_page(route, evidence)); + let name_matched = proposed + .as_ref() + .is_some_and(|(_, kind)| *kind == NAME_MATCH); + if !modal + && !name_matched + && let (Some(route), Some(page)) = (route, existing) + { decisions.push("collides-with-existing-page"); - questions.push(question( - format!("collision:{id}"), - header("Existing page", route), - format!( - "{route} already has a page ({page}). What should building \"{name}\" ({id}) do to it?" - ), - vec![ - choice( - "Merge into the existing page", - "Keep the page file and its data wiring; replace its markup with the screen.", - json!({"collision": "merge"}), - ), - choice( - "Overwrite the existing page", - "Replace the page file outright; existing wiring is lost.", - json!({"collision": "overwrite"}), + push( + &mut questions, + question( + format!("collision:{id}"), + header("Existing page", route), + format!( + "{route} already has a page ({page}). What should building \"{name}\" ({id}) do to it?" ), - choice(SKIP, "Leave the existing page as it is.", json!({"collision": "skip"})), - ], - "collision", - )); + vec![ + choice( + "Merge into the existing page", + "Keep the page file and its data wiring; replace its markup with the screen.", + json!({"collision": "merge"}), + ), + choice( + "Overwrite the existing page", + "Replace the page file outright; existing wiring is lost.", + json!({"collision": "overwrite"}), + ), + choice( + SKIP, + "Leave the existing page as it is.", + json!({"collision": "skip"}), + ), + ], + "collision", + ), + &[], + ); } - if let Some(route) = proposed.as_ref().filter(|route| route.contains('[')) { + if let Some(route) = route.filter(|route| route.contains('[')) { decisions.push("dynamic-segment"); - questions.push(question( - format!("dynamic:{id}"), - header("Dynamic route", route), - format!( - "{route} has a dynamic segment. Where does \"{name}\" get the record it shows?" - ), - vec![ - choice( - "Sample data from the design", - "Render the Figma content as fixture data for one id; wire real data later.", - json!({"data": "sample"}), - ), - choice( - "Wire to the API now", - "Fetch the record by the segment value; needs the endpoint from devup_project_context scope api.", - json!({"data": "api"}), + push( + &mut questions, + question( + format!("dynamic:{id}"), + header("Dynamic route", route), + format!( + "{route} has a dynamic segment. Where does \"{name}\" get the record it shows?" ), - ], - "dynamic", - )); + vec![ + choice( + "Sample data from the design", + "Render the Figma content as fixture data for one id; wire real data later.", + json!({"data": "sample"}), + ), + choice( + "Wire to the API now", + "Fetch the record by the segment value; needs the endpoint from devup_project_context scope api.", + json!({"data": "api"}), + ), + ], + "dynamic", + ), + &[], + ); } if widths.len() > 1 { decisions.push("several-widths"); - grouped.push(format!( - "{name} ({})", - widths - .iter() - .map(u32::to_string) - .collect::>() - .join("/") - )); } let mut links = Vec::new(); for (link_index, link) in screen.links.iter().enumerate() { @@ -991,39 +1534,53 @@ pub(super) fn draft_plan( "id": id, "name": name, "kind": if modal { "modal" } else { "page" }, - "frames": screen.frames.iter().map(|frame| json!({"nodeId": frame.node_id, "name": frame.name, "width": frame.width, "height": frame.height})).collect::>(), + "kindEvidence": if modal { json!(kind_evidence) } else { Value::Null }, + "frames": screen.frames.iter().map(Frame::json).collect::>(), "drawnWidths": widths, - "proposedRoute": proposed, - "routeEvidence": if proposed.is_some() { json!(found.iter().map(|(_, why)| why.clone()).collect::>().join("; ")) } else { json!("UNVERIFIED") }, + "grouping": match &screen.grouping { + Grouping::Fixed(evidence) => json!({"mode": "decided", "evidence": evidence}), + Grouping::Ask { recommend_single, reason } => json!({"mode": "ask", "question": group_id, + "recommended": if *recommend_single { "single" } else { "separate" }, "reason": reason}), + }, + "proposedRoute": route, + "routeEvidence": route_evidence, "routeCandidates": candidates, "existingFile": existing, "sharedParts": shared, "links": links, "openDecisions": decisions, - "notes": evidence_notes, + "notes": screen_notes[index], })); } - if !grouped.is_empty() { + let route_heuristics = heuristics + .iter() + .filter(|item| text(&item["askedIn"]).starts_with("review:")) + .collect::>(); + if !route_heuristics.is_empty() { + let listed = route_heuristics + .iter() + .map(|item| format!("{} -> {}", text(&item["name"]), text(&item["decision"]))) + .collect::>() + .join(", "); questions.push(question( - "responsive".into(), - "Widths: one URL or several".into(), + "heuristics".into(), + "Inferred from layer names".into(), format!( - "These screens are drawn at several widths: {}. Should each be one responsive route, or a separate route per width?", - grouped.join(", ") + "These were inferred from layer names only: {listed}. Accept all (merged into the existing pages), or review each one?" ), vec![ choice( - "One responsive route", - "Each screen keeps one URL; its widths become devup-ui responsive arrays (responsiveTsx).", - json!({"responsive": "single"}), + ACCEPT_ALL, + "Use every inferred route; existing pages are merged into, not overwritten.", + json!({"heuristics": "accept"}), ), choice( - "Separate route per width", - "The widest frame keeps the URL; each narrower width gets /.", - json!({"responsive": "separate"}), + REVIEW, + "Ask one question per inferred route, in the next round.", + json!({"heuristics": "review"}), ), ], - "responsive", + "heuristics", )); } for (part, owners) in part_screens.iter().filter(|(part, owners)| { @@ -1069,13 +1626,58 @@ pub(super) fn draft_plan( "notes": notes, "appDir": evidence.app_dir, "inventoryNote": evidence.inventory_note, + "config": evidence.config_file, "screens": body["screens"], + "heuristics": heuristics, "questions": body["questions"], "answers": {}, "routes": [], + "assumptions": [], })) } +/// How deep a question sits behind the answers it depends on. A question is +/// asked in a round after every question it depends on. +fn depth(question: &Value, questions: &[Value]) -> usize { + array(&question["when"]) + .iter() + .filter_map(|condition| { + questions + .iter() + .find(|other| other["id"] == condition["question"]) + }) + .map(|parent| depth(parent, questions) + 1) + .max() + .unwrap_or(0) +} + +/// The questions split into rounds a question tool takes in one call each: +/// at most four questions of at most four options, every question exactly +/// once, and a conditional question always in a later round than the one it +/// depends on. +pub(super) fn question_rounds(questions: &[Value]) -> Vec { + let mut by_depth = BTreeMap::>::new(); + for question in questions { + by_depth + .entry(depth(question, questions)) + .or_default() + .push(question); + } + let mut rounds = Vec::new(); + for level in by_depth.values() { + for chunk in level.chunks(MAX_QUESTIONS_PER_ROUND) { + let shaped = ask_shape( + &chunk + .iter() + .map(|question| (*question).clone()) + .collect::>(), + ); + rounds.push(json!({"round": rounds.len() + 1, "questions": shaped})); + } + } + rounds +} + fn plan_path(project_root: &str, relative: &str) -> String { Path::new(project_root) .join(relative) @@ -1167,6 +1769,7 @@ pub(super) async fn draft( .map(|previous| previous["planId"].clone()); let written = write_plan(policy, root, &plan)?; let questions = ask_shape(array(&plan["questions"])); + let rounds = question_rounds(array(&plan["questions"])); let unverified = array(&plan["screens"]) .iter() .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") @@ -1175,9 +1778,12 @@ pub(super) async fn draft( "status": "draft", "planId": plan["planId"], "screens": screen_table(&plan), + "questionRounds": rounds, "questions": questions, "questionCount": questions.len(), "askInstruction": ASK_INSTRUCTION, + "heuristics": plan["heuristics"], + "config": plan["config"], "unverifiedRoutes": unverified, "inventoryNote": plan["inventoryNote"], "writtenTo": written, @@ -1186,7 +1792,7 @@ pub(super) async fn draft( json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": {}}, "how": "Nothing is open. Show the user the screens table, then confirm with empty answers."}) } else { - json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": " chosen label>"}, + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": " chosen label, for every question that applies>"}, "how": ASK_INSTRUCTION}) }, })) @@ -1218,15 +1824,40 @@ fn resolve_answer( { return Ok((found["action"].clone(), None)); } + let route_of = |value: &str| normalize_route(value).filter(|route| valid_route(route)); match text(&question["kind"]) { - "route" | "modal" => normalize_route(label) - .filter(|route| valid_route(route)) + "route" | "modal" => route_of(label) .map(|route| (json!({"route": route}), Some(label.to_owned()))) .ok_or_else(|| { invalid(format!( "{id}: a custom answer must be an app route such as /about, not {label:?}." )) }), + // One route is one page at that URL; one route per frame, in frame + // order, is separate pages at those URLs. + "group" => { + let routes = label + .split([',', ';', '\n']) + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| route_of(part.rsplit('=').next().unwrap_or(part).trim())) + .collect::>>() + .ok_or_else(|| { + invalid(format!( + "{id}: a custom answer must be one route, or one route per frame such as \"/a, /b\"; not {label:?}." + )) + })?; + match routes.as_slice() { + [route] => Ok(( + json!({"group": "single", "route": route}), + Some(label.to_owned()), + )), + _ => Ok(( + json!({"group": "separate", "routes": routes}), + Some(label.to_owned()), + )), + } + } "link" => Ok(( json!({"link": normalize_route(label)}), Some(label.to_owned()), @@ -1241,7 +1872,15 @@ fn resolve_answer( } } -/// `confirm`: every question answered, in one call, or nothing changes. +/// Whether a question has to be answered, given the answers before it. +fn required(question: &Value, actions: &BTreeMap) -> bool { + array(&question["when"]) + .iter() + .all(|condition| actions.get(text(&condition["question"])) == Some(&condition["action"])) +} + +/// `confirm`: every question that applies answered, in one call, or nothing +/// changes. pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { let root = require_root(input)?; let mut plan = read_plan(root)?.ok_or_else(|| { @@ -1259,27 +1898,47 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< json!({"planId": plan_id, "currentPlanId": plan["planId"]}), )); } - let questions = array(&plan["questions"]).to_vec(); + let mut questions = array(&plan["questions"]).to_vec(); + let ordered = questions.clone(); + questions.sort_by_key(|question| depth(question, &ordered)); let known = questions .iter() .map(|question| text(&question["id"]).to_owned()) .collect::>(); - let missing = known - .iter() - .filter(|id| !input.answers.contains_key(*id)) - .cloned() - .collect::>(); let unknown = input .answers .keys() .filter(|id| !known.contains(*id)) .cloned() .collect::>(); + let mut actions = BTreeMap::new(); + let mut custom = Vec::new(); + let mut missing = Vec::new(); + for question in &questions { + let id = text(&question["id"]).to_owned(); + if !required(question, &actions) { + continue; + } + let Some(answer) = input.answers.get(&id) else { + missing.push(id); + continue; + }; + let (action, note) = resolve_answer(question, answer)?; + if let Some(note) = note { + custom.push(json!({"question": id, "answer": note})); + } + actions.insert(id, action); + } if !missing.is_empty() || !unknown.is_empty() { + let pending = questions + .iter() + .filter(|question| missing.iter().any(|id| question["id"] == id.as_str())) + .cloned() + .collect::>(); return Err(DevupError::with_details( ErrorCode::DevupInvalidInput, format!( - "confirm needs an answer to every question in one call. Missing: {}.", + "confirm needs an answer to every question that applies. Missing: {}.", if missing.is_empty() { "none".to_owned() } else { @@ -1288,105 +1947,101 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< ), false, json!({"missing": missing, "unknown": unknown, "askInstruction": ASK_INSTRUCTION, - "questions": ask_shape(&questions).into_iter().filter(|question| missing.iter().any(|id| question["id"] == id.as_str())).collect::>()}), + "questionRounds": question_rounds(&pending)}), )); } - let mut actions = BTreeMap::new(); - let mut custom = Vec::new(); - for question in &questions { - let id = text(&question["id"]).to_owned(); - let (action, note) = resolve_answer(question, &input.answers[&id])?; - if let Some(note) = note { - custom.push(json!({"question": id, "answer": note})); - } - actions.insert(id, action); - } - let separate = actions - .get("responsive") - .is_some_and(|action| action["responsive"] == "separate"); + let accepted = actions + .get("heuristics") + .is_none_or(|action| action["heuristics"] == "accept"); let app_dir = text(&plan["appDir"]).to_owned(); let mut routes = Vec::new(); let mut screen_routes = BTreeMap::new(); let screens = array(&plan["screens"]).to_vec(); for screen in &screens { let id = text(&screen["id"]).to_owned(); - let placed = actions - .get(&format!("route:{id}")) - .or_else(|| actions.get(&format!("modal:{id}"))); - let (implementation, route, host) = match placed { - Some(action) if action["skip"] == true => ("skip", None, None), - Some(action) if action["notPage"] == true => ("not-a-page", None, None), - Some(action) if action["state"].is_string() => { - ("state", None, Some(text(&action["state"]).to_owned())) - } - Some(action) => ("page", action["route"].as_str().map(str::to_owned), None), - None => ( - "page", - screen["proposedRoute"].as_str().map(str::to_owned), - None, - ), - }; - let collision = actions - .get(&format!("collision:{id}")) - .map(|action| text(&action["collision"]).to_owned()); - let (implementation, route) = match collision.as_deref() { - Some("skip") => ("skip", None), - _ => (implementation, route), - }; - if implementation == "page" && route.is_none() { - return Err(invalid(format!( - "{id} has no route after the answers; answer route:{id} with a route." - ))); - } + let frames = array(&screen["frames"]).to_vec(); + let group = actions.get(&format!("group:{id}")); + let separate = group.is_some_and(|action| action["group"] == "separate"); let data = actions .get(&format!("dynamic:{id}")) .map(|action| action["data"].clone()); - let frames = array(&screen["frames"]).to_vec(); - let split = separate && implementation == "page" && frames.len() > 1; - let groups = if split { - frames - .iter() - .map(|frame| vec![frame.clone()]) - .collect::>() + // Each placement is (frames, implementation, route, host, collision). + let mut placements = Vec::new(); + if separate { + let explicit = group.and_then(|action| action["routes"].as_array()); + if explicit.is_some_and(|routes| routes.len() != frames.len()) { + return Err(invalid(format!( + "group:{id}: list exactly one route per frame ({} frames).", + frames.len() + ))); + } + for (index, frame) in frames.iter().enumerate() { + let node = text(&frame["nodeId"]); + let action = explicit + .map(|routes| json!({"route": routes[index]})) + .or_else(|| actions.get(&format!("split-route:{node}")).cloned()) + .unwrap_or(Value::Null); + placements.push((vec![frame.clone()], action, None)); + } } else { - vec![frames.clone()] - }; - for (index, group) in groups.into_iter().enumerate() { - let url = route.as_ref().map(|route| { - if index == 0 { - route.clone() - } else { - format!( - "{}/{}", - route.trim_end_matches('/'), - group[0]["width"] - .as_f64() - .map_or(0, |width| width.round() as i64) - ) + let placed = group + .filter(|action| action["route"].is_string()) + .or_else(|| actions.get(&format!("route:{id}"))) + .or_else(|| actions.get(&format!("modal:{id}"))) + .or_else(|| actions.get(&format!("review:{id}"))) + .cloned(); + let action = match placed { + Some(action) => action, + None if screen["routeEvidence"] == NAME_MATCH && accepted => { + json!({"route": screen["proposedRoute"], "collision": "merge", "acceptedHeuristic": true}) } - }); - if let Some(url) = &url { + None => json!({"route": screen["proposedRoute"]}), + }; + let collision = actions + .get(&format!("collision:{id}")) + .map(|action| text(&action["collision"]).to_owned()); + placements.push((frames.clone(), action, collision)); + } + for (group_frames, action, collision) in placements { + let collision = collision.or_else(|| action["collision"].as_str().map(str::to_owned)); + let (implementation, route, host) = + if action["skip"] == true || collision.as_deref() == Some("skip") { + ("skip", None, None) + } else if action["notPage"] == true { + ("not-a-page", None, None) + } else if let Some(host) = action["state"].as_str() { + ("state", None, Some(host.to_owned())) + } else { + ("page", action["route"].as_str().map(str::to_owned), None) + }; + let first = text(&group_frames[0]["nodeId"]).to_owned(); + if implementation == "page" && route.is_none() { + return Err(invalid(format!( + "{first} has no route after the answers; answer its route question with a route." + ))); + } + if let Some(url) = &route { screen_routes .entry(id.clone()) .or_insert_with(|| url.clone()); } - let existing = url.as_deref().and_then(|url| { + let existing = route.as_deref().and_then(|url| { (screen["proposedRoute"] == url && !screen["existingFile"].is_null()) .then(|| screen["existingFile"].clone()) }); routes.push(json!({ - "url": url, - "screen": id, - "name": screen["name"], - "nodeIds": group.iter().map(|frame| frame["nodeId"].clone()).collect::>(), - "drawnWidths": group.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), + "url": route, + "screen": if separate { first.clone() } else { id.clone() }, + "name": group_frames[0]["name"], + "nodeIds": group_frames.iter().map(|frame| frame["nodeId"].clone()).collect::>(), + "drawnWidths": group_frames.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), "implementation": implementation, "hostScreen": host, - "file": existing.clone().unwrap_or_else(|| url.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &app_dir)))), + "file": existing.clone().unwrap_or_else(|| route.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &app_dir)))), "fileExists": existing.is_some(), "collisionPolicy": collision, "data": data, - "routeEvidence": screen["routeEvidence"], + "routeEvidence": if action["acceptedHeuristic"] == true { json!("name-match (accepted)") } else { screen["routeEvidence"].clone() }, })); } } @@ -1427,6 +2082,7 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< } plan["answers"] = json!(input.answers); plan["customAnswers"] = json!(custom); + plan["heuristicsAccepted"] = json!(accepted); plan["layout"] = json!( actions .iter() @@ -1434,10 +2090,12 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< .map(|(id, action)| (id.clone(), action["layout"].clone())) .collect::>() ); - plan["responsive"] = json!(if separate { "separate" } else { "single" }); plan["routes"] = json!(routes); plan["status"] = json!("confirmed"); plan["confirmedAt"] = json!(now()); + if !plan["assumptions"].is_array() { + plan["assumptions"] = json!([]); + } let written = write_plan(policy, root, &plan)?; Ok(json!({ "status": "confirmed", @@ -1445,8 +2103,46 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< "routes": routes, "markdownTable": route_markdown(&routes), "customAnswers": custom, + "rule": IMPLEMENT_INSTRUCTION, + "writtenTo": written, + "nextAction": {"how": IMPLEMENT_INSTRUCTION}, + })) +} + +/// `assume`: what implementation decided without asking, appended to the +/// confirmed plan so status and the final report carry it. +pub(super) fn assume(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Err(invalid( + "Assumptions belong to implementation, after confirm. Before confirm, put the decision to the user in the question rounds instead.", + )); + } + if input.assumptions.is_empty() { + return Err(invalid("action assume needs assumptions.")); + } + let recorded_at = now(); + let mut list = array(&plan["assumptions"]).to_vec(); + for assumption in &input.assumptions { + if assumption.about.trim().is_empty() || assumption.decision.trim().is_empty() { + return Err(invalid("Every assumption needs about and decision.")); + } + list.push( + json!({"about": assumption.about, "decision": assumption.decision, + "reason": assumption.reason, "recordedAt": recorded_at}), + ); + } + plan["assumptions"] = json!(list); + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "planId": plan["planId"], + "assumptions": plan["assumptions"], "writtenTo": written, - "nextAction": {"how": "Implement URL by URL. devup_figma_export with projectRoot now takes each page's route from this plan; run devup_runtime_audit at every width, then devup_screen_plan action status."}, + "how": "Keep implementing. Every assumption appears in status and must be in your final report.", })) } @@ -1557,6 +2253,21 @@ fn markdown(plan: &Value) -> String { out.push_str("\n## Confirmed routes\n\n"); out.push_str(&route_markdown(routes)); } + let assumptions = array(&plan["assumptions"]); + if !assumptions.is_empty() { + out.push_str("\n## Assumptions made during implementation\n\n"); + for assumption in assumptions { + out.push_str(&format!( + "- {}: {}{}\n", + text(&assumption["about"]), + text(&assumption["decision"]), + assumption["reason"] + .as_str() + .map(|reason| format!(" ({reason})")) + .unwrap_or_default() + )); + } + } out } @@ -1590,6 +2301,7 @@ pub(super) async fn status( return Ok(json!({ "status": "not-confirmed", "planId": plan["planId"], + "questionRounds": question_rounds(array(&plan["questions"])), "questions": ask_shape(array(&plan["questions"])), "askInstruction": ASK_INSTRUCTION, "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"]}, "how": ASK_INSTRUCTION}, @@ -1756,6 +2468,14 @@ pub(super) fn status_report( if !audited { summary.push_str("; no runtime audit report, so every width is not-run"); } + let assumptions = array(&plan["assumptions"]); + if !assumptions.is_empty() { + summary.push_str(&format!( + "; {} assumption{} made during implementation", + assumptions.len(), + if assumptions.len() == 1 { "" } else { "s" } + )); + } let mut table = String::from( "| URL | Figma screen | file | status | widths verified | screenshots |\n|---|---|---|---|---|---|\n", ); @@ -1810,7 +2530,22 @@ pub(super) fn status_report( "states": states, "widths": widths, "audit": audit.map(|audit| json!({"verdict": audit["verdict"], "planId": audit["planId"], "reason": audit["reason"]})), - "markdownTable": table, + "markdownTable": if assumptions.is_empty() { + table + } else { + format!( + "{table} +Assumptions made during implementation: +{}", + assumptions + .iter() + .map(|assumption| format!("- {}: {}", text(&assumption["about"]), text(&assumption["decision"]))) + .collect::>() + .join(" + ") + ) + }, + "assumptions": assumptions, "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.", }) @@ -1858,7 +2593,7 @@ fn plan_required(request: &ExportRequest<'_>, reason: &str, details: Value) -> G "written": false, "planRequired": {"reason": reason, "details": details}, "nextAction": {"tool": "devup_screen_plan", "arguments": arguments, - "how": "Draft the screen plan, ask every question in ONE question-tool call, confirm, then repeat this export. Nothing was collected or written."}, + "how": "Draft the screen plan, ask every round of its questions back to back before implementing, confirm, then repeat this export. Nothing was collected or written."}, })) } @@ -1939,11 +2674,18 @@ pub(super) fn export_gate(request: &ExportRequest<'_>) -> Result ProjectEvidence { )], layout_parts: vec!["Header".into()], app_dir: "src/app".into(), - inventory_note: None, + ..ProjectEvidence::default() } } +fn sectioned(node_id: &str, name: &str, width: f64, section: &str) -> PlanScreenInput { + PlanScreenInput { + section: Some(format!("section-{section}")), + section_name: Some(section.into()), + ..screen(node_id, name, width) + } +} + +fn find<'a>(plan: &'a Value, id: &str) -> &'a Value { + array(&plan["questions"]) + .iter() + .find(|question| question["id"] == id) + .unwrap_or_else(|| panic!("{id} is asked: {}", plan["questions"])) +} + +fn first_label(question: &Value) -> &str { + text(&question["options"][0]["label"]) +} + fn question_ids(plan: &Value) -> Vec { array(&plan["questions"]) .iter() @@ -148,7 +167,17 @@ fn an_evidence_backed_route_is_proposed_without_a_route_question() { assert_eq!(rows[1]["proposedRoute"], "/company"); assert!(text(&rows[1]["routeEvidence"]).starts_with("nav-label")); assert_eq!(rows[2]["proposedRoute"], "/"); + // Home -> / is a guess from the layer name, so it is shown and put to + // the user in one compact question rather than decided silently. + assert_eq!(rows[2]["routeEvidence"], NAME_MATCH); + assert_eq!(plan["heuristics"][0]["screen"], "2:3"); let ids = question_ids(&plan); + assert!(ids.contains(&"heuristics".to_owned()), "{ids:?}"); + assert!(ids.contains(&"review:2:3".to_owned()), "{ids:?}"); + assert_eq!( + first_label(find(&plan, "heuristics")), + "Accept all (Recommended)" + ); assert!( !ids.iter() .any(|id| id == "route:2:1" || id.ends_with(":2:1")), @@ -184,7 +213,7 @@ fn two_screens_on_one_evidence_route_are_both_asked() { } #[test] -fn widths_of_one_screen_are_grouped_and_modals_asked_as_state_or_route() { +fn widths_in_one_section_recommend_one_page_and_modals_are_asked() { let plan = draft_plan( &[ screen("4:1", "Main - 1440", 1440.0), @@ -201,13 +230,19 @@ fn widths_of_one_screen_are_grouped_and_modals_asked_as_state_or_route() { assert_eq!(rows.len(), 2); assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); assert_eq!(rows[0]["proposedRoute"], "/"); - let ids = question_ids(&plan); - assert!(ids.contains(&"responsive".to_owned())); - let modal = array(&plan["questions"]) - .iter() - .find(|question| question["id"] == "modal:4:3") - .expect("a modal is asked"); - assert!(text(&modal["options"][0]["label"]).starts_with("State on Main")); + assert_eq!(rows[0]["grouping"]["recommended"], "single"); + let group = find(&plan, "group:4:1"); + assert_eq!(first_label(group), "One responsive page (Recommended)"); + // Separate pages need their own routes, asked in the same up-front batch + // and only if separate is chosen. No URL is made up for the 390 frame. + let split = find(&plan, "split-route:4:2"); + assert_eq!(split["when"][0]["question"], "group:4:1"); + assert_eq!(split["when"][0]["answer"], "Separate pages"); + assert!(!plan.to_string().contains("/390")); + let modal = find(&plan, "modal:4:3"); + assert!(first_label(modal).starts_with("State on Main")); + assert_eq!(rows[1]["kindEvidence"], NAME_MATCH); + assert!(text(&modal["question"]).contains("name-match")); } /// Every question must be accepted as-is by a structured question tool. @@ -222,7 +257,10 @@ fn every_question_has_the_question_tool_shape() { linked.parts = vec!["Footer".into()]; let mut other = screen("5:2", "News", 1440.0); other.parts = vec!["Footer".into()]; - let targets = BTreeMap::from([("5:3".to_owned(), "/news/[id]".to_owned())]); + let targets = BTreeMap::from([ + ("5:3".to_owned(), "/news/[id]".to_owned()), + ("5:7".to_owned(), "/".to_owned()), + ]); let plan = draft_plan( &[ linked, @@ -231,6 +269,7 @@ fn every_question_has_the_question_tool_shape() { screen("5:4", "공지 팝업", 400.0), screen("5:5", "Company - 1440", 1440.0), screen("5:6", "Company - 390", 390.0), + screen("5:7", "Landing", 1440.0), ], &targets, &evidence(), @@ -250,7 +289,10 @@ fn every_question_has_the_question_tool_shape() { "layout:", "dynamic:", "modal:", - "responsive", + "group:", + "split-route:", + "heuristics", + "review:", "collision:", ] { assert!( @@ -287,9 +329,164 @@ fn every_question_has_the_question_tool_shape() { .iter() .all(|option| !text(&option["description"]).is_empty()) ); + for condition in array(&question["when"]) { + assert!(condition.get("action").is_none(), "{question}"); + assert!(ids.contains(text(&condition["question"])), "{question}"); + } + } + assert_rounds_cover(&plan); +} + +/// Every question in exactly one round, rounds within the tool's limits, and +/// a conditional question always after the question it depends on. +fn assert_rounds_cover(plan: &Value) { + let questions = array(&plan["questions"]); + let rounds = question_rounds(questions); + let mut seen = Vec::new(); + let mut round_of = BTreeMap::new(); + for (index, round) in rounds.iter().enumerate() { + assert_eq!(round["round"], index + 1); + let asked = array(&round["questions"]); + assert!( + (1..=MAX_QUESTIONS_PER_ROUND).contains(&asked.len()), + "{round}" + ); + for question in asked { + assert!(array(&question["options"]).len() <= MAX_OPTIONS); + let id = text(&question["id"]).to_owned(); + round_of.insert(id.clone(), index); + seen.push(id); + } + } + let mut expected = questions + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect::>(); + let mut got = seen.clone(); + expected.sort(); + got.sort(); + assert_eq!(got, expected, "every question exactly once"); + for question in questions { + for condition in array(&question["when"]) { + assert!( + round_of[text(&condition["question"])] < round_of[text(&question["id"])], + "{} must come after {}", + question["id"], + condition["question"] + ); + } } } +#[test] +fn rounds_split_many_questions_into_fours() { + let screens = (0..11) + .map(|index| screen(&format!("8:{index}"), &format!("Page{index}"), 1440.0)) + .collect::>(); + let plan = draft_plan(&screens, &BTreeMap::new(), &evidence(), json!({}), None).unwrap(); + let rounds = question_rounds(array(&plan["questions"])); + assert_eq!(rounds.len(), 3); + assert_rounds_cover(&plan); +} + +/// Designers differ: the same name in two sections is recommended as two +/// pages, and in sections named only for devices as one. +#[test] +fn sections_drive_the_recommendation_for_each_group() { + let plan = draft_plan( + &[ + sectioned("9:1", "About", 1440.0, "Company"), + sectioned("9:2", "About", 390.0, "Recruit"), + sectioned("9:3", "Pricing", 1440.0, "PC"), + sectioned("9:4", "Pricing", 390.0, "Mobile"), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + assert_eq!( + first_label(find(&plan, "group:9:1")), + "Separate pages (Recommended)" + ); + assert_eq!( + first_label(find(&plan, "group:9:3")), + "One responsive page (Recommended)" + ); + let split = find(&plan, "split-route:9:2"); + assert!( + array(&split["options"]) + .iter() + .any(|option| text(&option["label"]).starts_with("/recruit/about")), + "{split}" + ); + assert_rounds_cover(&plan); +} + +#[test] +fn project_config_counts_as_evidence_and_suppresses_its_questions() { + let mut evidence = evidence(); + evidence.config = PlanConfig { + modal_keywords: Some(vec!["sheet".into()]), + route_aliases: BTreeMap::from([("요금제".to_owned(), "/pricing".to_owned())]), + width_grouping: Some("responsive".into()), + }; + let plan = draft_plan( + &[ + screen("10:1", "요금제 - 1440", 1440.0), + screen("10:2", "요금제 - 390", 390.0), + screen("10:3", "Share sheet", 400.0), + screen("10:4", "Login popup", 400.0), + ], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); + assert_eq!(rows[0]["grouping"]["evidence"], "config:widthGrouping"); + assert_eq!(rows[0]["proposedRoute"], "/pricing"); + assert!(text(&rows[0]["routeEvidence"]).contains("routeAliases")); + assert_eq!(rows[1]["kind"], "modal"); + assert_eq!(rows[1]["kindEvidence"], "config"); + // The project's keywords replace the built-in ones. + assert_eq!(rows[2]["kind"], "page"); + let ids = question_ids(&plan); + assert!( + !ids.iter().any(|id| id.starts_with("group:") + || id.starts_with("route:10:1") + || id == "heuristics"), + "{ids:?}" + ); +} + +#[test] +fn a_frame_id_already_in_a_page_is_evidence() { + let mut evidence = evidence(); + evidence.figma_nodes.insert( + "11:1".into(), + ("/company".into(), "src/app/company/page.tsx".into()), + ); + let plan = draft_plan( + &[screen("11:1", "Intro", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + assert_eq!(plan["screens"][0]["proposedRoute"], "/company"); + assert!(text(&plan["screens"][0]["routeEvidence"]).contains("data-figma-node-id")); + assert_eq!(question_ids(&plan), ["collision:11:1"]); + assert_eq!( + figma_node_ids(""), + ["1:2", "3:4"] + ); +} + #[tokio::test] async fn confirm_rejects_missing_answers_by_id_and_accepts_a_complete_set() { let fixture = Fixture::new(); @@ -529,3 +726,169 @@ async fn project_evidence_reads_routes_layout_parts_and_nav_links() { evidence.nav_links ); } + +fn answers(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(id, answer)| ((*id).to_owned(), AnswerInput::One((*answer).to_owned()))) + .collect() +} + +async fn drafted(fixture: &Fixture, screens: &[PlanScreenInput]) -> ScreenPlanInput { + let plan = draft( + &input(fixture, "draft"), + screens, + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(fixture, "confirm"); + confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); + confirm_input +} + +fn main_widths() -> [PlanScreenInput; 2] { + [ + screen("12:1", "Main - 1440", 1440.0), + screen("12:2", "Main - 390", 390.0), + ] +} + +/// Separate pages bring their own route questions; the answers decide the +/// URLs, and nothing is derived from a width. +#[tokio::test] +async fn separate_pages_need_a_route_per_frame_and_get_exactly_those() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "Separate pages"), + ("split-route:12:1", "/"), + ("heuristics", "Accept all (Recommended)"), + ]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["split-route:12:2"])); + assert!(error.details["questionRounds"][0]["questions"][0]["id"] == "split-route:12:2"); + + confirm_input + .answers + .insert("split-route:12:2".into(), AnswerInput::One("/m".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| (row["url"].clone(), row["nodeIds"].clone())) + .collect::>(); + assert_eq!( + urls, + [ + (json!("/"), json!(["12:1"])), + (json!("/m"), json!(["12:2"])) + ] + ); +} + +#[tokio::test] +async fn one_responsive_page_skips_the_split_routes_and_accepts_the_inferred_route() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "One responsive page (Recommended)"), + ("heuristics", "Accept all (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let route = &confirmed["routes"][0]; + assert_eq!(route["url"], "/"); + assert_eq!(route["nodeIds"], json!(["12:1", "12:2"])); + assert_eq!(route["collisionPolicy"], "merge"); + assert_eq!(route["routeEvidence"], "name-match (accepted)"); +} + +#[tokio::test] +async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "One responsive page (Recommended)"), + ("heuristics", "Review individually"), + ]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["review:12:1"])); + confirm_input + .answers + .insert("review:12:1".into(), AnswerInput::One("/home".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(confirmed["routes"][0]["url"], "/home"); +} + +#[tokio::test] +async fn a_custom_group_answer_can_name_one_route_per_frame() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "/desktop, /mobile"), + ("heuristics", "Accept all (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| row["url"].clone()) + .collect::>(); + assert_eq!(urls, [json!("/desktop"), json!("/mobile")]); +} + +/// After confirm nothing is asked: what implementation decides on its own is +/// recorded, and status and the final table carry it. +#[tokio::test] +async fn assumptions_are_recorded_after_confirm_and_reported_by_status() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &[screen("13:1", "Pricing", 1440.0)]).await; + let mut assume_input = input(&fixture, "assume"); + assume_input.assumptions = vec![AssumptionInput { + about: "13:9".into(), + decision: "Not built: outside the confirmed screen plan.".into(), + reason: Some("found during implementation".into()), + }]; + let early = assume(&assume_input, &fixture.policy()).unwrap_err(); + assert!(early.message.contains("after confirm")); + + confirm_input.answers = answers(&[("route:13:1", "/pricing (Recommended)")]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert!(text(&confirmed["rule"]).contains("without asking")); + let recorded = assume(&assume_input, &fixture.policy()).unwrap(); + assert_eq!(recorded["assumptions"][0]["about"], "13:9"); + + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + assert_eq!( + report["assumptions"][0]["decision"], + "Not built: outside the confirmed screen plan." + ); + assert!(text(&report["summary"]).ends_with("; 1 assumption made during implementation")); + assert!(text(&report["markdownTable"]).contains("- 13:9: Not built")); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("## Assumptions made during implementation")); + + let mut uncovered = export(&fixture, &["13:9"]); + uncovered.scaffold_write = true; + uncovered.scaffold_route = Some("/x"); + let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { + panic!("a frame outside the confirmed plan is not written"); + }; + assert_eq!(response["nextAction"]["arguments"]["action"], "assume"); + assert!(text(&response["nextAction"]["how"]).contains("Do not ask the user")); +} + +#[tokio::test] +async fn an_invalid_config_is_refused_rather_than_ignored() { + let fixture = Fixture::new(); + fixture.write(CONFIG_FILE, r#"{"widthGrouping": "sometimes"}"#); + let error = project_evidence(fixture.root()).await.unwrap_err(); + assert!(error.message.contains("widthGrouping")); + fixture.write( + CONFIG_FILE, + r#"{"routeAliases": {"메인": "/"}, "widthGrouping": "ask"}"#, + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + assert_eq!(evidence.config_file.as_deref(), Some(CONFIG_FILE)); + assert_eq!(evidence.config.route_aliases["메인"], "/"); +} diff --git a/crates/devup-mcp/tests/screen_plan_tool.rs b/crates/devup-mcp/tests/screen_plan_tool.rs index fd0e5c30..d29cad1b 100644 --- a/crates/devup-mcp/tests/screen_plan_tool.rs +++ b/crates/devup-mcp/tests/screen_plan_tool.rs @@ -104,22 +104,42 @@ async fn draft_asks_everything_at_once_and_confirm_needs_every_answer() -> anyho ) .await?; assert!(!failed, "{draft}"); - assert_eq!( - draft["askInstruction"], - "Ask all of these in ONE call to your question tool; do not ask in prose; do not implement before confirm." + let instruction = draft["askInstruction"].as_str().unwrap(); + assert!(instruction.contains("before implementing"), "{instruction}"); + assert!( + instruction.contains("After confirm never ask"), + "{instruction}" ); - let ids = draft["questions"] + // Rounds go back to back before any work: what does not depend on an + // answer first, then what does. + let rounds = draft["questionRounds"] .as_array() .unwrap() .iter() - .map(|question| question["id"].as_str().unwrap().to_owned()) + .map(|round| { + round["questions"] + .as_array() + .unwrap() + .iter() + .map(|question| question["id"].as_str().unwrap().to_owned()) + .collect::>() + }) .collect::>(); assert_eq!( - ids, - ["collision:1:2", "collision:1:4", "route:1:5", "responsive"], + rounds, + [ + vec!["group:1:2", "route:1:5", "heuristics"], + vec![ + "split-route:1:2", + "split-route:1:3", + "review:1:2", + "review:1:4" + ], + ], "{draft}" ); assert_eq!(draft["screens"][0]["proposedRoute"], "/"); + assert_eq!(draft["screens"][0]["routeEvidence"], "name-match"); assert_eq!(draft["screens"][0]["drawnWidths"], json!([1440, 390])); assert_eq!(draft["screens"][2]["routeEvidence"], "UNVERIFIED"); let plan_id = draft["planId"].as_str().unwrap().to_owned(); @@ -134,9 +154,10 @@ async fn draft_asks_everything_at_once_and_confirm_needs_every_answer() -> anyho assert!(failed); let refusal = refused.to_string(); assert!( - refusal.contains("collision:1:4") && refusal.contains("responsive"), + refusal.contains("group:1:2") && refusal.contains("heuristics"), "{refusal}" ); + assert!(!refusal.contains("split-route"), "{refusal}"); let (failed, gated) = call( &root, @@ -154,9 +175,8 @@ async fn draft_asks_everything_at_once_and_confirm_needs_every_answer() -> anyho "devup_screen_plan", json!({"action": "confirm", "projectRoot": root_text, "planId": plan_id, "answers": {"route:1:5": "/pricing (Recommended)", - "collision:1:2": "Merge into the existing page (Recommended)", - "collision:1:4": "Merge into the existing page (Recommended)", - "responsive": "One responsive route (Recommended)"}}), + "group:1:2": "One responsive page (Recommended)", + "heuristics": "Accept all (Recommended)"}}), ) .await?; assert!(!failed, "{confirmed}"); From c017bf933174822d7b974e0a04c0810890cb5c5f Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 21:54:42 +0900 Subject: [PATCH 08/24] Teach the up-front rounds and project config in the skill, guide and README The devup-implementation-plan skill drops "split only as far as the tool forces" and says to ask every round back to back before implementing, never during it, and to record assumptions instead; it documents the new group, split-route, heuristics and review questions and the optional .devup/screen-plan.config.json. Guide rule 2c, the export description pointer, the manifest entry, the README and the changepack note match. Co-Authored-By: Claude Opus 5.5 --- .../changepack_log_screen_plan_first.json | 2 +- README.md | 8 +- crates/devup-mcp/src/server/guide.rs | 4 +- .../skills/devup-implementation-plan/SKILL.md | 110 ++++++++++++------ .../devup-mcp/src/server/skills/manifest.json | 8 +- 5 files changed, 88 insertions(+), 44 deletions(-) diff --git a/.changepacks/changepack_log_screen_plan_first.json b/.changepacks/changepack_log_screen_plan_first.json index 4a839f21..6010e5f3 100644 --- a/.changepacks/changepack_log_screen_plan_first.json +++ b/.changepacks/changepack_log_screen_plan_first.json @@ -2,6 +2,6 @@ "changes": { "crates/devup-mcp/Cargo.toml": "Minor" }, - "note": "Add devup_screen_plan (draft, confirm, status) so every Figma screen is mapped to an evidence-backed URL and confirmed in one batch of question-tool questions before pages are written, gate page-writing exports on a confirmed plan, report URL x width runtime status, and ship the devup-implementation-plan skill.", + "note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen is mapped to an evidence-backed URL and confirmed in question rounds asked up front before pages are written, never asking during implementation; ask per group whether same-named frames are one responsive page or separate pages; surface layer-name guesses for confirmation; honour an optional .devup/screen-plan.config.json; gate page-writing exports on a confirmed plan; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill.", "date": "2026-10-17T00:00:00+09:00" } diff --git a/README.md b/README.md index fc93ce5e..d72bbf1a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Figma 쪽 5개, 프로젝트 쪽 7개, 스킬 1개, 모두 13개입니다. - `devup_visual_compare`: 소비자가 만든 `actual.png`를 reference PNG 하나와 비교. **스스로 렌더하지 않고 브라우저를 띄우지도 명령을 실행하지도 않습니다** — 경로는 allowlist 안이어야 합니다. 기본 threshold는 0.005(0.5%). renderer 환경 manifest가 없거나 불완전하거나 유효하지 않으면 `visual.passed`가 true여도 판정은 `inconclusive`입니다 - `devup_feature_trace`: 명시적 anchor(`routePath`, `figmaNodeId`/`artifactId`, `operationId` 또는 `apiPath`+`method`, `componentPath`, `tableName`)에서 출발해 화면→API→라우트→모델→컬럼 슬라이스와 acceptance matrix를 읽어냄. **요구사항 산문만 주면 거절합니다.** 각 hop은 근거가 있거나 `UNVERIFIED`로 표시되고, 생성 산출물 소유권·UI 재사용 순위·잘림 상한을 함께 보고합니다 - `devup_runtime_audit`: 생성한 화면을 여러 viewport 폭에서 재 볼 **브라우저 실행 계획**을 검증해 쓰고(`plan`), 소비자 프로젝트의 실행기(`harness/runtime-audit`)가 쓴 보고서를 그 계획에 묶어 검증합니다(`ingest`). **서버는 브라우저를 띄우지도 명령을 실행하지도 않습니다** — 계획은 선언형 데이터뿐이고, 계획 id와 파일 해시에 묶이지 않은 보고서는 거절하며 모든 개수를 항목에서 다시 셉니다 -- `devup_screen_plan`: 페이지를 쓰기 **전에** Figma 화면마다 어느 URL이 될지 계획하고(`draft`), 열린 결정을 질문 도구 한 번 호출로 모두 물은 뒤 확정하고(`confirm`), 확정된 계획을 실제 라우트와 runtime audit 보고서에 대조해 URL × 폭 표로 보고(`status`). 라우트는 근거(기존 페이지, 기존 href에 묶인 nav 라벨, `navigationTargets`)로만 제안하고 나머지는 `UNVERIFIED` 질문이 됩니다 +- `devup_screen_plan`: 페이지를 쓰기 **전에** Figma 화면마다 어느 URL이 될지 계획하고(`draft`), 열린 결정을 구현 전에 질문 라운드로 연달아 모두 물은 뒤 확정하고(`confirm`), 확정된 계획을 실제 라우트와 runtime audit 보고서에 대조해 URL × 폭 표로 보고(`status`). 라우트는 근거(그 프레임으로 만든 기존 페이지, 기존 href에 묶인 nav 라벨, `navigationTargets`, 프로젝트 설정)로만 제안하고, 레이어 이름으로 추정한 것은 `name-match`로 드러내 묻고, 나머지는 `UNVERIFIED` 질문이 됩니다. 확정 뒤에는 묻지 않고 가정(`assume`)으로 기록합니다 `devup_figma_to_ui`와 `devup_figma_to_json`은 각각 `devup_figma_export`에 `outputs: ["tsx"]`, `outputs: ["devupJson"]`을 넘긴 것과 같아서 제거했습니다. 도구가 셋이면 모든 클라이언트가 세 개의 스키마를 컨텍스트에 싣고도 어느 것을 부를지 매번 판단해야 했습니다. @@ -1052,12 +1052,14 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 "이 디자인을 구현해줘"를 받은 에이전트가 곧바로 페이지를 쓰면, 어느 Figma 화면이 어느 URL이 됐는지 사용자는 끝내 모릅니다. 그래서 순서를 고정합니다. -1. `draft` — Section/frame 링크(또는 브리지 선택, 또는 앞선 `devup_figma_explore`의 `screens`)와 `projectRoot`를 받아, 여러 폭으로 그린 프레임을 한 화면으로 묶고, 화면마다 `proposedRoute`·`routeEvidence`·`existingFile`·`sharedParts`·`openDecisions`를 담은 `.devup/screen-plan.json`과 같은 내용의 `.devup/screen-plan.md`를 씁니다. 근거 없는 라우트는 `proposedRoute: null`, `routeEvidence: "UNVERIFIED"`이고 질문이 됩니다. 응답의 `questions`는 질문 도구(OpenCode `question`, Claude Code `AskUserQuestion`)에 그대로 넣는 모양 — `id`, 30자 이하 `header`, `question`, 추천을 맨 앞에 두고 " (Recommended)"를 붙인 `options[{label, description}]`, `multiple` — 이고, `askInstruction`이 함께 옵니다. -2. `confirm` — 질문 id별 답을 **한 번에** 받습니다. 하나라도 빠지면 빠진 id를 이름으로 대며 거절하고, 아무것도 바꾸지 않습니다. +1. `draft` — Section/frame 링크(또는 브리지 선택, 또는 앞선 `devup_figma_explore`의 `screens`)와 `projectRoot`를 받아, 이름이 같은 프레임을 그룹으로 모으고(한 페이지인지 여러 페이지인지는 그룹마다 묻되, 같은 Section 안의 여러 폭이면 "One responsive page", 다른 Section이면 "Separate pages"를 추천), 화면마다 `proposedRoute`·`routeEvidence`·`existingFile`·`sharedParts`·`openDecisions`를 담은 `.devup/screen-plan.json`과 같은 내용의 `.devup/screen-plan.md`를 씁니다. 근거 없는 라우트는 `proposedRoute: null`, `routeEvidence: "UNVERIFIED"`이고 질문이 됩니다. 응답의 `questions`는 질문 도구(OpenCode `question`, Claude Code `AskUserQuestion`)에 그대로 넣는 모양 — `id`, 30자 이하 `header`, `question`, 추천을 맨 앞에 두고 " (Recommended)"를 붙인 `options[{label, description}]`, `multiple` — 이고, 한 번에 최대 4개씩 나눈 `questionRounds`와 `askInstruction`이 함께 옵니다. 라운드는 구현 전에 연달아 모두 묻습니다. `when`이 붙은 질문(예: "Separate pages"를 고른 그룹의 프레임별 라우트, "Review individually"를 고른 이름 추정 항목)은 그 답일 때만 묻습니다. 레이어 이름으로 추정한 라우트는 "Accept all (Recommended)" / "Review individually" 한 질문으로 묶어 확인합니다. +2. `confirm` — 해당하는 질문 id별 답을 **한 번에** 받습니다. 하나라도 빠지면 빠진 id를 이름으로 대며 거절하고, 아무것도 바꾸지 않습니다. 확정 뒤 구현 중에는 사용자에게 아무것도 묻지 않습니다. 새로 생긴 결정은 추천안이나 가장 보수적인 선택을 따르고 `assume`으로 계획의 `assumptions`에 남기며, `status`와 최종 보고에 함께 나옵니다. 3. `status` — 확정된 계획을 `devup_project_context scope:"routes"`의 실제 페이지와, 선택적으로 `devup_runtime_audit`의 plan/report에 대조합니다. URL마다 구현 여부와 폭별 `pass`·`fail`(가로 넘침, 페이지 오류 등 이유)·`not-run`을 내고, 요약 한 줄(`7 URLs: 5 implemented, 3 verified at all widths, /about overflows at 390`)과 `URL | Figma screen | file | status | widths verified | screenshots` 표를 줍니다. audit 항목이 없는 폭은 `not-run`이지 `pass`가 아닙니다. `devup_figma_export`는 `projectRoot`가 있고 페이지를 쓰는 호출(`pageScaffold` write, `app/` 아래 `outputPaths`, `outputPaths`가 있는 여러 프레임·`allScreens`)인데 그 프레임을 덮는 확정 계획이 없으면 쓰지 않고 `status: "plan_required"`와 `devup_screen_plan draft`로 가는 `nextAction`을 돌려줍니다. 컴포넌트 하나, 쓰지 않는 미리보기는 막지 않습니다. 확정 계획이 있으면 `pageScaffold.route`는 계획에서 가져오고, 다른 라우트를 넘기면 `routeMismatch`로 거절합니다. 전체 흐름은 `devup_skills`로 설치하는 `devup-implementation-plan` 스킬에 있습니다. +디자이너마다 이름 짓는 방식이 달라서, 프로젝트는 선택적으로 `.devup/screen-plan.config.json`에 `modalKeywords`(모달로 볼 레이어 이름 단어, 기본 목록을 대체), `routeAliases`(레이어 이름 → 라우트), `widthGrouping`(`responsive` | `separate` | `ask`, 기본 `ask`)을 둘 수 있습니다. 있는 항목은 근거로 쓰이고 그에 해당하는 질문을 대신합니다. + ## 읽기 전용·개인정보 보호 - upstream 호출은 `get_metadata`, `get_variable_defs`, `get_design_context`, `get_code_connect_map`, `get_screenshot`과 내장된 read-only `use_figma` script로 닫혀 있습니다. diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index b5ba6e9b..0492646a 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -39,8 +39,8 @@ pub const INSTRUCTIONS: &str = concat!( "run devup_ui_validate and report wired/tested counts before functional completion.\n", "2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ", "workspace lacks and installs the ones it carries.\n", - "2c. Several pages: devup_screen_plan first; ask ALL its questions in ONE question-tool call, not prose ", - "(skill devup-implementation-plan).\n", + "2c. Several pages: devup_screen_plan first; ask every questionRound back to back before implementing, ", + "never after confirm (skill devup-implementation-plan).\n", "3. Request only outputs you will read. Before your second call, resources/read \"devup://guide/usage\" ", "for output sizing, verification boundaries, SECTION batching, assets and delivery." ); 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 f4083702..63fbe10b 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 @@ -31,21 +31,33 @@ So the order is fixed, and none of it is optional. - **No implementation before confirm.** No page, layout or component file is written until `devup_screen_plan` `confirm` has succeeded. -- **Ask every question in one batch.** Never one at a time, never a second - round of confirmations. Collect every open decision, then ask them all at - once. -- **Ask with the question tool, never in prose.** Pass the `questions` array - to your runtime's structured question tool in ONE call: `question` in - OpenCode, `AskUserQuestion` in Claude Code, whatever yours is called. The - array is already shaped for it - `id`, `header` (30 characters or fewer), +- **All questions up front, back to back.** Ask every round in + `questionRounds` before any implementation starts, in consecutive turns with + no other work in between. Several rounds are fine - AskUserQuestion takes at + most four questions per call, and the server has already split them - but + never one decision at a time spread through the work. +- **Never ask during implementation.** After `confirm`, ask the user nothing: + someone may have walked away from the keyboard, and a question three hours + in stops everything. When something new comes up, take the plan's + recommended or the most conservative option (leaving a frame unbuilt is + conservative; inventing a URL is not), record it with `devup_screen_plan` + `action: "assume"`, and keep going. +- **Ask with the question tool, never in prose.** Pass each round's + `questions` to your runtime's structured question tool (`question` in + OpenCode, `AskUserQuestion` in Claude Code, whatever yours is called). They + are already shaped for it - `id`, `header` (30 characters or fewer), `question`, `options[{label, description}]` with the recommended option first - and marked " (Recommended)", and `multiple`. Custom answers are allowed. If - your tool takes fewer questions per call than the array holds, say so and - split only as far as the tool forces you to. -- **Never invent a route.** A route comes from an existing page, a nav link - already bound to an href, an explicit `navigationTargets` entry, or the - user's answer. `proposedRoute: null` with `routeEvidence: "UNVERIFIED"` stays - null until the user answers. + and marked " (Recommended)", and `multiple`. Custom answers are allowed. +- **Skip what does not apply.** A question with `when` is asked only if the + named question got that answer, e.g. a per-frame route only after "Separate + pages". Rounds are ordered so the answer it depends on is always already in + hand. +- **Never invent a route.** A route comes from an existing page built from the + frame, a nav link already bound to an href, an explicit `navigationTargets` + entry, the project's `routeAliases`, or the user's answer. A route inferred + from a layer name is `routeEvidence: "name-match"` and is put to the user. + `proposedRoute: null` with `routeEvidence: "UNVERIFIED"` stays null until the + user answers. ## Steps @@ -55,24 +67,26 @@ So the order is fixed, and none of it is optional. 2. **Draft the plan.** Call `devup_screen_plan` with `action: "draft"`, `projectRoot`, and the Section or frame `url` (or nothing, while one Devup Bridge plugin is attached and the Section is selected). Pass `screens` from - step 1 to skip a second Figma read, and `navigationTargets` for any - Figma node -> route binding you were given explicitly. It writes - `.devup/screen-plan.json` and `.devup/screen-plan.md`. -3. **Show the plan, then ask.** Show the user the `screens` table (Figma - screen, widths, proposed route, evidence, existing file, shared parts, open - decisions), then pass `questions` to your question tool in ONE call, exactly - as returned. Do not reword the options; the labels are what confirm reads. + step 1 (with each frame's `section`) to skip a second Figma read, and + `navigationTargets` for any Figma node -> route binding you were given + explicitly. It writes `.devup/screen-plan.json` and `.devup/screen-plan.md`. +3. **Show the plan, then ask every round.** Show the user the `screens` table + (Figma screen, widths, proposed route, evidence, existing file, shared + parts, open decisions) and the `heuristics` list, then ask round 1, round + 2, ... of `questionRounds` back to back, exactly as returned. Do not reword + the options; the labels are what confirm reads. 4. **Confirm.** Call `devup_screen_plan` with `action: "confirm"`, the `planId`, and `answers` keyed by question id (the chosen label, with or - without " (Recommended)", or the user's custom text). A refusal names the - missing ids: that is your mistake, not a reason to ask again one by one - - you already have the answers, or you skipped a question in step 3. -5. **Implement URL by URL.** For each confirmed route, call + without " (Recommended)", or the user's custom text) for every question + that applies. A refusal names the missing ids and returns them as rounds: + ask those right away, still before any implementation. +5. **Implement URL by URL, without asking.** For each confirmed route, call `devup_figma_export` with `projectRoot` and that screen's `frameIds`. With `pageScaffold`, omit `route`: the export takes it from the plan and refuses - a different one. An export that would write a page without a confirmed plan - answers `status: "plan_required"` and writes nothing - go back to step 2. - Screens confirmed as a state of another page are built inside that page. + a different one. Screens confirmed as a state of another page are built + inside that page. An export of a frame the confirmed plan does not place + answers `plan_required` and writes nothing: leave it unbuilt and record an + assumption - do not ask. 6. **Run the runtime audit.** `devup_runtime_audit` `plan` with every confirmed URL as a screen (`routePath`) at the default widths (1440, 1100, 858, 390), each with a `no-horizontal-overflow` check; run the executor in the @@ -82,19 +96,47 @@ So the order is fixed, and none of it is optional. audit item is `not-run`, never `pass`. 8. **Finish with the table.** End your reply with the `markdownTable` from status - `URL | Figma screen | file | status | widths verified | - screenshots` - and its one-line `summary`, such as + screenshots`, followed by the assumptions made during implementation - and + its one-line `summary`, such as `7 URLs: 5 implemented, 3 verified at all widths, /about overflows at 390`. - Report every `fail` and `not-run` as what it is. Do not call the work done - while a URL is not implemented or a width fails. + Report every `fail`, `not-run` and assumption as what it is. Do not call the + work done while a URL is not implemented or a width fails. ## What the questions mean | id | asked when | resolves to | | --- | --- | --- | +| `group:` | frames share a name across widths or sections | one responsive page, separate pages, or one route per frame (custom `/a, /b`) | +| `split-route:` | `when` its group is separate pages | that frame's URL, skip, or not a page | | `route:` | no evidence names a route, or evidence conflicts | the URL, skip, or not a page | -| `modal:` | the screen looks like a modal or overlay | a state on a host page, or its own URL | -| `collision:` | the route already has a page | merge, overwrite or skip | +| `heuristics` | a route was inferred from a layer name | accept all, or review individually | +| `review:` | `when` heuristics is review | keep (merge or overwrite), another URL, or skip | +| `modal:` | the layer name looks like a modal or overlay | a state on a host page, or its own URL | +| `collision:` | an evidence-backed route already has a page | merge, overwrite or skip | | `dynamic:` | the route has a `[segment]` | sample data, or wire the API now | | `link::` | a link's destination is not in the plan | a route, or unlinked | | `layout:` | a part appears on several screens and no layout renders it | shared layout, or per page | -| `responsive` | screens are drawn at several widths | one responsive route, or one per width | + +Which way a `group` question recommends follows the evidence: several widths +in one section with matching names recommend one responsive page; the same +name in different sections recommends separate pages, unless the sections are +named only for devices (`PC`, `Mobile`). + +## Project config + +Designers name things differently, so a project can say how its own do it in +an optional `.devup/screen-plan.config.json`. Each entry present counts as +evidence and replaces the question it would otherwise take: + +```json +{ + "modalKeywords": ["popup", "sheet"], + "routeAliases": { "메인": "/", "회사소개": "/company" }, + "widthGrouping": "responsive" +} +``` + +- `modalKeywords` replaces the built-in modal words. +- `routeAliases` maps a layer name to a route. +- `widthGrouping` is `responsive` (merge same-named widths into one page), + `separate` (never merge), or `ask` (the default: ask per group). diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index c2ae8ed5..d62fb743 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -64,15 +64,15 @@ "name": "devup-implementation-plan", "origin": "own", "title": "Screen plan first, confirm once, then implement", - "description": "The order a Figma design becomes pages in: list the screens, draft a screen-to-URL plan with devup_screen_plan, ask every open question in one question-tool call, confirm, implement URL by URL, run the runtime audit, and finish with a URL x width status table.", - "usedFor": "Without it an agent handed a Figma Section picks URLs itself and writes pages straight away, so nobody can tell afterwards which screen became which URL or whether each one works at every width. It also keeps the questions in one structured batch instead of a trickle of prose confirmations.", + "description": "The order a Figma design becomes pages in: list the screens, draft a screen-to-URL plan with devup_screen_plan, ask every question round back to back before any work, confirm, implement URL by URL without asking anything more (recording assumptions instead), run the runtime audit, and finish with a URL x width status table.", + "usedFor": "Without it an agent handed a Figma Section picks URLs itself and writes pages straight away, so nobody can tell afterwards which screen became which URL or whether each one works at every width. It also keeps the questions in structured rounds asked up front, instead of a trickle of prose confirmations - or a question hours into the work while nobody is at the keyboard.", "repo": "dev-five-git/devup-mcp", "path": "crates/devup-mcp/src/server/skills/devup-implementation-plan", "documents": [ { "path": "SKILL.md", - "bytes": 5813, - "sha256": "aa1b1eb05c50381c9afcfac4a985763670fd949c43eab9028858ec2731659633" + "bytes": 8117, + "sha256": "478effcc7b0aa69f3a0603f38679f44c2f0d434e144fd85f0d9bbeef240ca1d5" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", From 3f85e586286d5916718108b49493fd0f26e78700 Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 22:12:32 +0900 Subject: [PATCH 09/24] Keep the screen plan in scope, ask cross-section ambiguities, report design fixes Scope: only frames under the linked node are candidates; anything whose ancestry does not reach it is listed as outOfScope and never planned. Frames in trash, archive, old, deprecated, backup, draft, test, sandbox, playground or copy areas, and hidden or zero-opacity frames, are excluded with their reason, shown in the plan and its Markdown, and confirmed in one "accept the exclusions / review" question; excludeKeywords and includeKeywords in the project config change the words. A link that itself points into such an area is asked about once instead of excluded. Cross-section: the same name in different sections - device-named ones included - no longer gets a silent recommendation. A sections: question states the section names, frames, widths and node ids and why they look related, recommends separate pages unless every section names a device (saying so), and offers "The Figma structure is wrong: I will fix it". Duplicates in one section, one screen split across sections and a frame outside any section get structure: questions with the same option. A fix answer leaves those frames out of the route table and lists them under "Design fixes requested" with what to move or rename; status and the plan file report them, and the export gate refuses them and excluded frames whatever assumption is recorded. Rounds: maxQuestionsPerRound (default 4) sets the split, and askInstruction says a question tool with no cap takes the flat list in one call, where each conditional question now says in its text when it applies. The assumptions list in status' markdownTable no longer carries stray newlines. Co-Authored-By: Claude Opus 5.5 --- crates/devup-mcp/src/server/mod.rs | 28 +- crates/devup-mcp/src/server/screen_plan.rs | 1176 +++++++++++++++-- .../devup-mcp/src/server/screen_plan_tests.rs | 305 ++++- 3 files changed, 1331 insertions(+), 178 deletions(-) diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 7438f262..916937ea 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -932,27 +932,7 @@ impl DevupServer { .iter() .map(|candidate| &candidate["node"]) .filter(|node| !screens_only || node["kind"] == "screen") - .filter_map(|node| { - Some(screen_plan::PlanScreenInput { - node_id: node["nodeId"].as_str()?.to_owned(), - name: node["name"].as_str().unwrap_or_default().to_owned(), - width: node["bounds"]["width"].as_f64()?, - height: node["bounds"]["height"].as_f64(), - // The parent is the Section the frame is drawn in; the - // breadcrumb ends with the frame, so its parent's name is - // the one before it. - section: node["parentId"].as_str().map(str::to_owned), - section_name: node["breadcrumb"].as_array().and_then(|names| { - names - .len() - .checked_sub(2) - .and_then(|index| names[index].as_str()) - .map(str::to_owned) - }), - parts: Vec::new(), - links: Vec::new(), - }) - }) + .filter_map(screen_plan::screen_from_explore) .collect(); Ok(( screens, @@ -960,6 +940,8 @@ impl DevupServer { "from": "devup_figma_explore", "url": input.url, "nodeId": explored["anchor"]["nodeId"], + "anchorName": explored["anchor"]["name"], + "anchorBreadcrumb": explored["anchor"]["breadcrumb"], "targetKind": explored["targetKind"], "fileKey": explored["source"]["fileKey"], "truncation": explored["truncation"], @@ -1698,7 +1680,7 @@ impl DevupServer { } #[tool( - description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. 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 (skill devup-implementation-plan). \ + description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. 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. \ @@ -2599,7 +2581,7 @@ impl DevupServer { Ok(tool_result(result)) } #[tool( - description = "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). 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. Frames sharing a name are asked about per group (one responsive page or separate pages), recommended from their sections and widths. 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 4 per round. Ask every round now, in consecutive question-tool calls (`question` in OpenCode, `AskUserQuestion` in Claude Code) 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. The devup-implementation-plan skill (devup_skills) carries the whole workflow.", + description = "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). 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. The devup-implementation-plan skill (devup_skills) carries the whole workflow.", output_schema = permissive_object_output_schema() )] async fn devup_screen_plan( diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index b4d67fd0..f042830a 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -44,7 +44,7 @@ const SCHEMA_VERSION: u64 = 1; /// it from the same place, so the two cannot disagree about which plan holds. pub(super) const PLAN_FILE: &str = ".devup/screen-plan.json"; const PLAN_MARKDOWN: &str = ".devup/screen-plan.md"; -pub(super) const ASK_INSTRUCTION: &str = "Ask every round in questionRounds now, before implementing, in consecutive question-tool calls with no other work in between; skip a question whose `when` was not met; never ask in prose. Then confirm. After confirm never ask the user anything: take the recommended or most conservative option and record it with action assume."; +pub(super) const ASK_INSTRUCTION: &str = "Ask every question now, before implementing, with your question tool and never in prose. If the tool has no per-call cap (OpenCode `question`), ask the flat `questions` list in one call; each conditional question says when it applies. If it has one (Claude Code AskUserQuestion: 4), ask the questionRounds back to back in consecutive calls with no other work in between, skipping a question whose `when` was not met. Then confirm. After confirm never ask the user anything: take the recommended or most conservative option and record it with action assume."; /// What implementation does instead of asking. Someone may have walked away /// from the keyboard three hours ago; a question then stops all the work. pub(super) const IMPLEMENT_INSTRUCTION: &str = "Implement URL by URL without asking the user anything. devup_figma_export with projectRoot takes each page's route from this plan. Anything new: take the recommended or most conservative option, record it with devup_screen_plan action assume, and keep going. Then run devup_runtime_audit at every width and devup_screen_plan action status."; @@ -88,6 +88,8 @@ const MODAL_WORDS: &[&str] = &[ const DEVICE_WORDS: &[&str] = &[ "pc", "mobile", "mo", "mob", "m", "tablet", "tab", "desktop", "web", "dt", ]; +/// Device words that name a whole section rather than a frame suffix. +const SECTION_DEVICE_WORDS: &[&str] = &["phone", "모바일", "태블릿", "데스크톱", "데스크탑", "웹"]; const ROOT_WORDS: &[&str] = &["home", "main", "index", "landing", "메인", "홈"]; #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -137,6 +139,12 @@ pub struct ScreenPlanInput { /// assume: what was decided during implementation without asking. #[serde(default)] pub assumptions: Vec, + /// draft: how many questions one question-tool call takes; default 4 + /// (Claude Code AskUserQuestion). A tool with no cap asks the flat + /// `questions` list in one call instead. + #[serde(default)] + #[schemars(range(min = 1, max = 50))] + pub max_questions_per_round: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -163,9 +171,17 @@ pub(super) struct PlanConfig { /// Layer name -> route. #[serde(default)] pub route_aliases: BTreeMap, - /// responsive | separate | ask (the default). + /// responsive | separate | ask (the default). Applies to several widths + /// in one section; frames in different sections are always asked. #[serde(default)] pub width_grouping: Option, + /// Replaces the built-in words that mark pages, sections and frames as + /// not for implementation (trash, archive, draft, copy, ...). + #[serde(default)] + pub exclude_keywords: Option>, + /// Names containing one of these are never excluded by keyword. + #[serde(default)] + pub include_keywords: Option>, } fn read_config(project_root: &str) -> Result, DevupError> { @@ -232,6 +248,19 @@ pub struct PlanScreenInput { /// The section's layer name, when `section` is an id. #[serde(default)] pub section_name: Option, + /// Layer names from the page down to this frame, as devup_figma_explore + /// reports them. Used to keep the plan inside the linked node and out of + /// trash, archive and draft areas. + #[serde(default)] + pub breadcrumb: Vec, + /// Ancestor node ids, nearest first, when known. + #[serde(default)] + pub ancestors: Vec, + /// false when the frame is hidden in Figma. + #[serde(default)] + pub visible: Option, + #[serde(default)] + pub opacity: Option, /// Shared part names the caller saw in this screen, e.g. Header, Footer. #[serde(default)] pub parts: Vec, @@ -573,13 +602,41 @@ fn question(id: String, header: String, prompt: String, choices: Vec, ki /// The questions as they go to the question tool: no resolution table. fn ask_shape(questions: &[Value]) -> Vec { + ask_shape_with(questions, questions) +} + +/// As [`ask_shape`], with `all` the whole plan's questions so a conditional +/// question can say in its own text when it applies - the flat list may be +/// asked in one call, where the answer it depends on is not yet known. +fn ask_shape_with(all: &[Value], questions: &[Value]) -> Vec { questions .iter() .map(|question| { let mut shaped = question.clone(); + let conditions = array(&question["when"]) + .iter() + .map(|condition| { + let parent = all + .iter() + .find(|other| other["id"] == condition["question"]) + .map_or_else( + || text(&condition["question"]).to_owned(), + |other| text(&other["header"]).to_owned(), + ); + format!("\"{parent}\" is \"{}\"", text(&condition["answer"])) + }) + .collect::>(); + if !conditions.is_empty() { + shaped["question"] = json!(format!( + "{} (Only if {}.)", + text(&question["question"]), + conditions.join(" and ") + )); + } if let Some(object) = shaped.as_object_mut() { object.remove("resolve"); object.remove("kind"); + object.remove("fix"); if let Some(conditions) = object.get_mut("when").and_then(Value::as_array_mut) { for condition in conditions { if let Some(condition) = condition.as_object_mut() { @@ -615,11 +672,19 @@ impl Frame { enum Grouping { /// Decided: one page, from a single frame, project config, or evidence. Fixed(&'static str), - /// Asked per group; the recommendation follows the evidence. + /// Several widths in one section: asked, one responsive page recommended. Ask { recommend_single: bool, reason: String, }, + /// The same screen name in different sections. A designer usually does + /// that for a reason, so this is never resolved here: the question names + /// the sections and frames and offers to have the Figma fixed instead. + Sections { + all_device: bool, + sections: Vec, + why: String, + }, } #[derive(Debug, Clone)] @@ -629,6 +694,13 @@ struct DraftScreen { parts: Vec, links: Vec, grouping: Grouping, + /// Conditions every question about this screen carries: a structure + /// question about it must have been answered with "keep" for any of them + /// to apply. + extra_when: Vec, + /// The structure questions this screen is part of, so confirm can tell + /// whether the user asked for a Figma fix instead. + structure: Vec, } impl DraftScreen { @@ -641,6 +713,14 @@ impl DraftScreen { fn widths(&self) -> Vec { widths_of(&self.frames) } + /// The question that decides how this screen's frames become pages. + fn grouping_question(&self) -> Option { + match self.grouping { + Grouping::Fixed(_) => None, + Grouping::Ask { .. } => Some(format!("group:{}", self.id())), + Grouping::Sections { .. } => Some(format!("sections:{}", self.id())), + } + } } fn widths_of(frames: &[Frame]) -> Vec { @@ -661,36 +741,57 @@ fn width_list(widths: &[u32]) -> String { .join("/") } -/// Whether every section name says only which device it is for, e.g. `PC` -/// and `Mobile`: a designer splitting one page by device, not two pages. -fn device_sections(frames: &[Frame]) -> bool { - frames.iter().all(|frame| { - frame.section_name.as_deref().is_some_and(|name| { - let lowered = name.to_lowercase(); - let words = lowered - .split(|c: char| !c.is_alphanumeric()) - .filter(|word| !word.is_empty()) - .collect::>(); - !words.is_empty() - && words.iter().all(|word| { - DEVICE_WORDS.contains(word) || word.chars().all(|c| c.is_ascii_digit()) - }) +/// Whether a section name says only which device it is for: `PC`, +/// `Mobile`, `Tablet`, `모바일`. +fn device_name(name: &str) -> bool { + let lowered = name.to_lowercase(); + let words = lowered + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect::>(); + !words.is_empty() + && words.iter().all(|word| { + DEVICE_WORDS.contains(word) + || SECTION_DEVICE_WORDS.contains(word) + || word.chars().all(|c| c.is_ascii_digit()) }) - }) +} + +fn section_label(frame: &Frame) -> String { + frame + .section_name + .clone() + .or_else(|| frame.section.clone()) + .unwrap_or_else(|| "(no section)".to_owned()) +} + +fn describe_frames(frames: &[Frame]) -> String { + frames + .iter() + .map(|frame| { + format!( + "\"{}\" ({}, {}px, section {})", + frame.name, + frame.node_id, + frame.width.round(), + section_label(frame) + ) + }) + .collect::>() + .join("; ") } /// Frames that share a base name are candidates for one page. Whether they -/// are one is not assumed: designers differ, so a group that could go either -/// way is asked about on its own, with the recommendation the evidence gives. +/// are one is not assumed: /// -/// - several widths in one section that can merge into one responsive tree: -/// one responsive page is recommended; -/// - the same name in different sections: separate pages are recommended, -/// unless the sections are named only for devices (`PC`, `Mobile`); -/// - the same name at the same width in one section is several screens, not a -/// width family, and is not grouped at all. -/// -/// `widthGrouping` in the project config decides instead of asking. +/// - several widths in one section that can merge into one responsive tree +/// are asked, recommending one responsive page; `widthGrouping` in the +/// project config decides this case instead of asking; +/// - the same name in different sections - device-named sections included - +/// is always asked, with the facts and a "fix the Figma" option, because +/// that is a designer's structure and not this server's to correct; +/// - the same name at the same width in one section is not grouped; it is +/// flagged as a possible duplicate by [`structure_questions`]. fn group_screens(screens: &[PlanScreenInput], config: &PlanConfig) -> Vec { let mut order = Vec::::new(); let mut groups = BTreeMap::>::new(); @@ -730,6 +831,8 @@ fn group_screens(screens: &[PlanScreenInput], config: &PlanConfig) -> Vec Vec>(); - let same_section = sections.len() == 1; - if same_section && !family { + if sections.len() > 1 { + let probe = make(members, Grouping::Fixed("")); + let mut names = Vec::::new(); + for frame in &probe.frames { + let label = section_label(frame); + if !names.contains(&label) { + names.push(label); + } + } + let all_device = probe + .frames + .iter() + .all(|frame| frame.section_name.as_deref().is_some_and(device_name)); + let mut why = vec!["the same name once widths and device words are removed".to_owned()]; + if family { + why.push(format!( + "their widths ({}) form a desktop-to-mobile pattern", + width_list(&probe.widths()) + )); + } + result.push(make( + members, + Grouping::Sections { + all_device, + sections: names, + why: why.join(", and "), + }, + )); + continue; + } + if !family { for member in members { result.push(make(&[member], Grouping::Fixed("single-frame"))); } continue; } match config.width_grouping.as_deref() { - Some("responsive") if family => { + Some("responsive") => { result.push(make(members, Grouping::Fixed("config:widthGrouping"))); } Some("separate") => { @@ -766,30 +898,326 @@ fn group_screens(screens: &[PlanScreenInput], config: &PlanConfig) -> Vec { - let probe = make(members, Grouping::Fixed("")); - let by_device = device_sections(&probe.frames); - let recommend_single = family && (same_section || by_device); - let reason = if same_section { - "drawn at several widths in one section with matching names".to_owned() - } else if recommend_single { - "split across sections named only for devices".to_owned() - } else { - format!("the same name in {} different sections", sections.len()) - }; - result.push(make( - members, - Grouping::Ask { - recommend_single, - reason, - }, - )); - } + _ => result.push(make( + members, + Grouping::Ask { + recommend_single: true, + reason: "drawn at several widths in one section with matching names".into(), + }, + )), } } result } +/// The base name without a trailing part number: `Main 1` and `Main (2)` +/// are parts of one screen if anything is. +fn part_key(words: &[String]) -> Option { + let (last, rest) = words.split_last()?; + let numbered = + last.chars().all(|c| c.is_ascii_digit()) && last.parse::().is_ok_and(|n| n < 100); + let rest = rest + .iter() + .filter(|word| !matches!(word.as_str(), "part" | "p")) + .cloned() + .collect::>(); + (numbered && !rest.is_empty()).then(|| rest.join(" ")) +} + +const KEEP_SEPARATE: &str = "They are different screens"; +const KEEP_OUTSIDE: &str = "It is its own screen"; +pub(super) const FIX_FIGMA: &str = "The Figma structure is wrong: I will fix it"; + +/// One structural oddity: the frames involved, what looks wrong, and the fix +/// the designer would make. +struct Oddity { + kind: &'static str, + screens: Vec, + facts: String, + keep: &'static str, + keep_description: String, + fix: String, +} + +/// Figma structure that a designer may not have meant, flagged in the same +/// batch with a "fix it" option. Nothing here decides which frame is right. +fn structure_questions(drafts: &[DraftScreen]) -> Vec { + let mut found = Vec::new(); + let singles = drafts + .iter() + .enumerate() + .filter(|(_, screen)| screen.grouping == Grouping::Fixed("single-frame")) + .collect::>(); + // The same name and width in one section. + let mut duplicates = BTreeMap::<(String, Option, u32), Vec>::new(); + for (index, screen) in &singles { + let frame = &screen.frames[0]; + duplicates + .entry(( + screen.words.join(" "), + frame.section.clone(), + widths_of(&screen.frames)[0], + )) + .or_default() + .push(*index); + } + for indices in duplicates.values().filter(|indices| indices.len() > 1) { + let frames = indices + .iter() + .map(|index| drafts[*index].frames[0].clone()) + .collect::>(); + found.push(Oddity { + kind: "duplicate", + screens: indices.clone(), + facts: format!( + "{} frames share one name and one width in one section: {}.", + frames.len(), + describe_frames(&frames) + ), + keep: KEEP_SEPARATE, + keep_description: "Each frame is its own screen and gets its own route.".into(), + fix: format!( + "Delete the stale copy or rename the frames so each screen has one name: {}.", + describe_frames(&frames) + ), + }); + } + // One screen split into numbered parts across sections. + let mut parts = BTreeMap::>::new(); + for (index, screen) in drafts.iter().enumerate() { + if let Some(key) = part_key(&screen.words) { + parts.entry(key).or_default().push(index); + } + } + for indices in parts.values().filter(|indices| indices.len() > 1) { + let frames = indices + .iter() + .flat_map(|index| drafts[*index].frames.clone()) + .collect::>(); + let sections = frames + .iter() + .map(|frame| frame.section.clone()) + .collect::>(); + if sections.len() < 2 { + continue; + } + found.push(Oddity { + kind: "split", + screens: indices.clone(), + facts: format!( + "These numbered frames look like parts of one screen spread across sections: {}.", + describe_frames(&frames) + ), + keep: KEEP_SEPARATE, + keep_description: "Each part is its own screen and gets its own route.".into(), + fix: format!( + "Put the parts of one screen into one frame in one section: {}.", + describe_frames(&frames) + ), + }); + } + // A frame outside any section beside frames that are in sections. + let sectioned = drafts + .iter() + .flat_map(|screen| &screen.frames) + .filter_map(|frame| frame.section.as_ref().map(|_| section_label(frame))) + .collect::>(); + if !sectioned.is_empty() { + for (index, screen) in &singles { + let frame = &screen.frames[0]; + if frame.section.is_some() { + continue; + } + found.push(Oddity { + kind: "loose", + screens: vec![*index], + facts: format!( + "\"{}\" ({}, {}px) sits outside any section, while the other frames are in {}.", + frame.name, + frame.node_id, + frame.width.round(), + sectioned.iter().cloned().collect::>().join(", ") + ), + keep: KEEP_OUTSIDE, + keep_description: "Plan it as a screen of its own, outside the sections.".into(), + fix: format!( + "Move \"{}\" ({}) into the section it belongs to, or out of this area.", + frame.name, frame.node_id + ), + }); + } + } + found +} + +/// Names that mark a page, section or frame as not for implementation. Words +/// in ASCII match whole words; the rest match anywhere in the name. +const EXCLUDE_WORDS: &[&str] = &[ + "trash", + "휴지통", + "archive", + "archived", + "아카이브", + "old", + "구버전", + "이전", + "deprecated", + "사용안함", + "미사용", + "backup", + "백업", + "draft", + "wip", + "작업중", + "test", + "테스트", + "sandbox", + "playground", + "copy", + "사본", +]; + +fn keyword_in(name: &str, keyword: &str) -> bool { + let name = name.to_lowercase(); + let keyword = keyword.trim().to_lowercase(); + if keyword.is_empty() { + return false; + } + if keyword.chars().all(|c| c.is_ascii_alphanumeric()) { + name.split(|c: char| !c.is_alphanumeric()) + .any(|word| word == keyword) + } else { + name.contains(&keyword) + } +} + +/// The first exclude keyword in any of `names`, unless an include keyword +/// forces them in. +fn excluded_by<'a>(names: &[(&'a str, &'a str)], config: &PlanConfig) -> Option { + let include = config.include_keywords.as_deref().unwrap_or_default(); + if names + .iter() + .any(|(_, name)| include.iter().any(|keyword| keyword_in(name, keyword))) + { + return None; + } + let defaults = EXCLUDE_WORDS + .iter() + .map(|word| (*word).to_owned()) + .collect::>(); + let keywords = config.exclude_keywords.as_ref().unwrap_or(&defaults); + names.iter().find_map(|(place, name)| { + keywords + .iter() + .find(|keyword| keyword_in(name, keyword)) + .map(|keyword| format!("{place} \"{name}\" contains \"{keyword}\"")) + }) +} + +/// Why a frame is not for implementation, or `None`. The names of the +/// linked node and its ancestors are left out: the user pointed there, which +/// is asked once rather than excluded (see the `scope` question). +fn exclusion_reason( + screen: &PlanScreenInput, + linked: &[String], + config: &PlanConfig, +) -> Option { + if screen.visible == Some(false) { + return Some("hidden in Figma".into()); + } + if screen.opacity.is_some_and(|opacity| opacity <= 0.0) { + return Some("zero opacity in Figma".into()); + } + let mut names = vec![("frame", screen.name.as_str())]; + if let Some(section) = screen.section_name.as_deref() { + names.push(("section", section)); + } + for ancestor in screen + .breadcrumb + .split_last() + .map(|(_, ancestors)| ancestors) + .unwrap_or_default() + { + names.push(("parent", ancestor.as_str())); + } + names.retain(|(place, name)| *place == "frame" || !linked.iter().any(|link| link == name)); + let mut seen = BTreeSet::new(); + names.retain(|(_, name)| seen.insert(*name)); + excluded_by(&names, config) +} + +/// One devup_figma_explore candidate node as a screen to plan. Its parent +/// counts as its section only when the breadcrumb shows one between the page +/// and the frame; a frame drawn straight on the page is in no section. +pub(super) fn screen_from_explore(node: &Value) -> Option { + let breadcrumb = array(&node["breadcrumb"]) + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>(); + let parent = node["parentId"].as_str().map(str::to_owned); + let in_section = breadcrumb.len() >= 3; + Some(PlanScreenInput { + node_id: node["nodeId"].as_str()?.to_owned(), + name: text(&node["name"]).to_owned(), + width: node["bounds"]["width"].as_f64()?, + height: node["bounds"]["height"].as_f64(), + section: parent.clone().filter(|_| in_section), + section_name: in_section.then(|| breadcrumb[breadcrumb.len() - 2].clone()), + ancestors: parent.into_iter().collect(), + visible: node["visible"].as_bool(), + opacity: node["opacity"].as_f64(), + breadcrumb, + ..PlanScreenInput::default() + }) +} + +/// Whether a frame is under the node the user linked. A frame that carries +/// no ancestry at all came from the caller's own list and is taken as given; +/// one whose ancestry is known and does not reach the link is never a +/// candidate. +pub(super) fn in_scope( + screen: &PlanScreenInput, + anchor_id: Option<&str>, + anchor_name: Option<&str>, +) -> bool { + let Some(anchor) = anchor_id else { + return true; + }; + if screen.node_id == anchor + || screen.section.as_deref() == Some(anchor) + || screen.ancestors.iter().any(|id| id == anchor) + { + return true; + } + let ancestor_names = screen + .breadcrumb + .split_last() + .map(|(_, ancestors)| ancestors) + .unwrap_or_default(); + if anchor_name.is_some_and(|name| ancestor_names.iter().any(|ancestor| ancestor == name)) { + return true; + } + screen.section.is_none() && screen.ancestors.is_empty() && screen.breadcrumb.is_empty() +} + +fn with_when(mut question: Value, conditions: &[Value]) -> Value { + if !conditions.is_empty() { + let mut all = array(&question["when"]).to_vec(); + all.extend(conditions.iter().cloned()); + question["when"] = json!(all); + } + question +} + +const SECTIONS_SEPARATE: &str = "Separate pages: the sections are different pages"; +const SECTIONS_SINGLE: &str = "One responsive page: the sections only split by device"; +const KEEP_EXCLUDED: &str = "Keep it excluded"; +const BUILD_IT: &str = "Build it"; +const ACCEPT_EXCLUSIONS: &str = "Accept the exclusions"; +const PLAN_ANYWAY: &str = "Plan them: this is what I linked"; +const NOTHING_HERE: &str = "Nothing here is for implementation"; + /// One route the evidence names, what kind of evidence it is, and where. #[derive(Debug, Clone)] struct RouteHit { @@ -939,7 +1367,220 @@ pub(super) fn draft_plan( "draft plans at most {MAX_SCREENS} screens; link one Section at a time." ))); } - let drafts = group_screens(screens, &evidence.config); + // Only what the user pointed at, and never what is marked as not for + // implementation. Both are listed, not dropped. + let anchor_id = source["nodeId"].as_str(); + let anchor_name = source["anchorName"].as_str(); + let linked = array(&source["anchorBreadcrumb"]) + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .chain(anchor_name.map(str::to_owned)) + .collect::>(); + let mut out_of_scope = Vec::new(); + let mut excluded = Vec::new(); + let mut kept = Vec::new(); + for screen in screens { + if !in_scope(screen, anchor_id, anchor_name) { + out_of_scope.push(json!({"nodeId": screen.node_id, "name": screen.name, + "reason": "not under the linked node; never a candidate"})); + continue; + } + match exclusion_reason(screen, &linked, &evidence.config) { + Some(reason) => excluded.push((screen, reason)), + None => kept.push(screen.clone()), + } + } + if kept.is_empty() && excluded.is_empty() { + return Err(invalid( + "No screen is under the linked node. Link the Section or frame to plan; the plan never widens past it.", + )); + } + let mut questions = Vec::new(); + let mut base_when = Vec::new(); + let linked_names = linked + .iter() + .map(|name| ("linked node", name.as_str())) + .collect::>(); + if let Some(reason) = excluded_by(&linked_names, &evidence.config) { + questions.push(question( + "scope".into(), + "Linked area: build it?".into(), + format!( + "The link you gave points inside an area whose name marks it as not for implementation: {reason}. Plan the screens under it anyway?" + ), + vec![ + choice( + PLAN_ANYWAY, + "You linked this area on purpose; plan its screens.", + json!({"scope": "use"}), + ), + choice( + NOTHING_HERE, + "Build nothing from this link.", + json!({"scope": "none"}), + ), + ], + "scope", + )); + base_when.push(when("scope", PLAN_ANYWAY, json!({"scope": "use"}))); + } + if !excluded.is_empty() { + let listed = excluded + .iter() + .take(8) + .map(|(screen, reason)| format!("\"{}\" ({}): {reason}", screen.name, screen.node_id)) + .collect::>() + .join("; "); + let more = if excluded.len() > 8 { + format!("; and {} more listed in the plan", excluded.len() - 8) + } else { + String::new() + }; + questions.push(with_when( + question( + "exclusions".into(), + "Excluded areas".into(), + format!( + "These {} frames were excluded as trash, archive, draft, copy or hidden, and will not be built: {listed}{more}. Accept the exclusions?", + excluded.len() + ), + vec![ + choice( + ACCEPT_EXCLUSIONS, + "None of them is built; they stay listed in the plan.", + json!({"exclusions": "accept"}), + ), + choice( + REVIEW, + "Ask about each one in the next round.", + json!({"exclusions": "review"}), + ), + ], + "exclusions", + ), + &base_when, + )); + let review = when("exclusions", REVIEW, json!({"exclusions": "review"})); + for (screen, reason) in &excluded { + let id = &screen.node_id; + let conditions = [base_when.clone(), vec![review.clone()]].concat(); + questions.push(with_when( + question( + format!("exclusion:{id}"), + header("Excluded", &screen.name), + format!( + "\"{}\" ({id}) was excluded: {reason}. Keep it out of the build?", + screen.name + ), + vec![ + choice( + KEEP_EXCLUDED, + "Not built; listed as excluded.", + json!({"build": false}), + ), + choice( + BUILD_IT, + "Build it; its URL is asked next.", + json!({"build": true}), + ), + ], + "exclusion", + ), + &conditions, + )); + let screen_slug = slug(&base_words(&screen.name)); + let mut choices = Vec::new(); + if !screen_slug.is_empty() { + choices.push(choice( + format!("/{screen_slug}"), + "Named after the layer.", + json!({"route": format!("/{screen_slug}")}), + )); + } + choices.push(choice( + SKIP, + "Leave it out after all.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let built = [ + conditions, + vec![when( + &format!("exclusion:{id}"), + BUILD_IT, + json!({"build": true}), + )], + ] + .concat(); + questions.push(with_when( + question( + format!("exclusion-route:{id}"), + header("Route", &screen.name), + format!("Which URL should \"{}\" ({id}) be served at?", screen.name), + choices, + "route", + ), + &built, + )); + } + } + let excluded_rows = excluded + .iter() + .map(|(screen, reason)| { + json!({"nodeId": screen.node_id, "name": screen.name, "width": screen.width, + "section": screen.section_name.clone().or_else(|| screen.section.clone()), + "reason": reason}) + }) + .collect::>(); + let mut drafts = group_screens(&kept, &evidence.config); + for draft in &mut drafts { + draft.extra_when.extend(base_when.iter().cloned()); + } + for oddity in structure_questions(&drafts) { + let first = drafts[oddity.screens[0]].id().to_owned(); + let id = format!("structure:{}:{first}", oddity.kind); + let frames = oddity + .screens + .iter() + .flat_map(|index| drafts[*index].frames.iter().map(Frame::json)) + .collect::>(); + let mut asked = question( + id.clone(), + header("Figma structure", drafts[oddity.screens[0]].name()), + format!( + "{} Is that intended, or should the Figma file be fixed? This plan does not decide which frame is right.", + oddity.facts + ), + vec![ + choice( + oddity.keep, + oddity.keep_description.clone(), + json!({"structure": "keep"}), + ), + choice( + FIX_FIGMA, + "These frames are not built. They are listed under Design fixes requested; everything else goes on.", + json!({"designFix": true}), + ), + ], + "structure", + ); + asked["fix"] = json!({"frames": frames, "text": oddity.fix}); + questions.push(with_when(asked, &base_when)); + for index in &oddity.screens { + drafts[*index] + .extra_when + .push(when(&id, oddity.keep, json!({"structure": "keep"}))); + drafts[*index].structure.push(id.clone()); + } + } let mut screen_notes = vec![Vec::new(); drafts.len()]; let mut decided = drafts .iter() @@ -1039,7 +1680,6 @@ pub(super) fn draft_plan( unclaimed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); unclaimed }; - let mut questions = Vec::new(); let mut rows = Vec::new(); let mut heuristics = Vec::new(); let mut part_screens = BTreeMap::>::new(); @@ -1058,11 +1698,15 @@ pub(super) fn draft_plan( let new_route = (!screen_slug.is_empty()).then(|| format!("/{screen_slug}")); // Questions about this screen as one page apply only if the group is // one page; when that is still being asked, they carry the condition. - let group_id = format!("group:{id}"); - let single_when = match &screen.grouping { - Grouping::Ask { .. } => vec![when(&group_id, SINGLE, json!({"group": "single"}))], - Grouping::Fixed(_) => Vec::new(), + let group_id = screen.grouping_question().unwrap_or_default(); + let (single_label, separate_label) = match screen.grouping { + Grouping::Sections { .. } => (SECTIONS_SINGLE, SECTIONS_SEPARATE), + _ => (SINGLE, SEPARATE), }; + let mut single_when = screen.extra_when.clone(); + if !matches!(screen.grouping, Grouping::Fixed(_)) { + single_when.push(when(&group_id, single_label, json!({"group": "single"}))); + } let push = |questions: &mut Vec, mut question: Value, extra: &[Value]| { let conditions = single_when.iter().chain(extra).cloned().collect::>(); if !conditions.is_empty() { @@ -1070,54 +1714,93 @@ pub(super) fn draft_plan( } questions.push(question); }; - if let Grouping::Ask { - recommend_single, - reason, - } = &screen.grouping - { - decisions.push("one-page-or-separate"); - let single = choice( - SINGLE, - "One URL; the widths become devup-ui responsive arrays (responsiveTsx).", - json!({"group": "single"}), - ); - let separate = choice( - SEPARATE, - "Each frame is its own page and gets its own route, asked next.", - json!({"group": "separate"}), - ); - let frames = screen - .frames - .iter() - .map(|frame| { + let grouping_question = match &screen.grouping { + Grouping::Fixed(_) => None, + Grouping::Ask { + recommend_single, + reason, + } => { + let single = choice( + SINGLE, + "One URL; the widths become devup-ui responsive arrays (responsiveTsx).", + json!({"group": "single"}), + ); + let separate = choice( + SEPARATE, + "Each frame is its own page and gets its own route, asked next.", + json!({"group": "separate"}), + ); + Some(question( + group_id.clone(), + header("Pages", &name), format!( - "{} ({}, {}px{})", - frame.name, - frame.node_id, - frame.width.round(), - frame - .section_name - .as_deref() - .or(frame.section.as_deref()) - .map(|section| format!(", section {section}")) - .unwrap_or_default() - ) - }) - .collect::>() - .join("; "); - questions.push(question( - group_id.clone(), - header("Pages", &name), - format!( - "These frames share the name \"{name}\" and are {reason}: {frames}. One responsive page, or separate pages? A custom answer may list one route per frame, e.g. \"/a, /b\"." - ), - if *recommend_single { - vec![single, separate] - } else { - vec![separate, single] - }, - "group", - )); + "These frames share the name \"{name}\" and are {reason}: {}. One responsive page, or separate pages? A custom answer may list one route per frame, e.g. \"/a, /b\".", + describe_frames(&screen.frames) + ), + if *recommend_single { + vec![single, separate] + } else { + vec![separate, single] + }, + "group", + )) + } + Grouping::Sections { + all_device, + sections, + why, + } => { + let separate = choice( + SECTIONS_SEPARATE, + "Each frame is its own page; its URL is asked next.", + json!({"group": "separate"}), + ); + let single = choice( + SECTIONS_SINGLE, + if *all_device { + format!( + "Inferred from the section names ({}), which name only devices.", + sections.join(", ") + ) + } else { + "The sections would only split one page by device.".to_owned() + }, + json!({"group": "single"}), + ); + let fix = choice( + FIX_FIGMA, + "These frames are not built. They are listed under Design fixes requested; everything else goes on.", + json!({"designFix": true}), + ); + let mut asked = question( + group_id.clone(), + header("Sections", &name), + format!( + "Frames that look like the same screen sit in different sections ({}): {}. They look related because of {why}. Which is it? A custom answer may list one route per frame, e.g. \"/a, /b\".", + sections.join(", "), + describe_frames(&screen.frames) + ), + if *all_device { + vec![single, separate, fix] + } else { + vec![separate, single, fix] + }, + "group", + ); + asked["fix"] = json!({ + "frames": screen.frames.iter().map(Frame::json).collect::>(), + "text": format!( + "Frames {} share the name \"{name}\" but sit in sections {}. Move them into one section if they are one page, or rename them if they are different pages.", + describe_frames(&screen.frames), + sections.join(", ") + ), + }); + Some(asked) + } + }; + if let Some(asked) = grouping_question { + decisions.push("one-page-or-separate"); + questions.push(with_when(asked, &screen.extra_when)); for frame in &screen.frames { let mut frame_notes = Vec::new(); let frame_hits = route_hits( @@ -1177,7 +1860,7 @@ pub(super) fn draft_plan( json!({"notPage": true}), )); } - let mut asked = question( + let asked = question( format!("split-route:{}", frame.node_id), header("Route", &format!("{} {}", frame.name, frame.width.round())), format!( @@ -1189,8 +1872,16 @@ pub(super) fn draft_plan( choices, "route", ); - asked["when"] = json!([when(&group_id, SEPARATE, json!({"group": "separate"}))]); - questions.push(asked); + let separate_when = [ + screen.extra_when.clone(), + vec![when( + &group_id, + separate_label, + json!({"group": "separate"}), + )], + ] + .concat(); + questions.push(with_when(asked, &separate_when)); } } let route_evidence = match proposed { @@ -1503,7 +2194,7 @@ pub(super) fn draft_plan( json!({"link": "remove"}), )); } - questions.push(question( + questions.push(with_when(question( format!("link:{id}:{link_index}"), header("Link", &link.label), format!( @@ -1516,7 +2207,7 @@ pub(super) fn draft_plan( ), choices, "link", - )); + ), &screen.extra_when)); } links.push(json!({"label": link.label, "destinationNodeId": link.destination_node_id, "resolved": resolved})); } @@ -1537,8 +2228,12 @@ pub(super) fn draft_plan( "kindEvidence": if modal { json!(kind_evidence) } else { Value::Null }, "frames": screen.frames.iter().map(Frame::json).collect::>(), "drawnWidths": widths, + "groupingQuestion": screen.grouping_question(), + "structureQuestions": screen.structure, "grouping": match &screen.grouping { Grouping::Fixed(evidence) => json!({"mode": "decided", "evidence": evidence}), + Grouping::Sections { all_device, sections, why } => json!({"mode": "ask-sections", "question": group_id, + "sections": sections, "allDeviceNames": all_device, "why": why}), Grouping::Ask { recommend_single, reason } => json!({"mode": "ask", "question": group_id, "recommended": if *recommend_single { "single" } else { "separate" }, "reason": reason}), }, @@ -1612,6 +2307,13 @@ pub(super) fn draft_plan( "layout", )); } + // Questions about the whole plan wait on the linked area being planned. + for asked in questions + .iter_mut() + .filter(|asked| asked["id"] == "heuristics" || text(&asked["id"]).starts_with("layout:")) + { + *asked = with_when(asked.clone(), &base_when); + } let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes}); let plan_id = format!( "sp-{}", @@ -1629,6 +2331,8 @@ pub(super) fn draft_plan( "config": evidence.config_file, "screens": body["screens"], "heuristics": heuristics, + "excluded": excluded_rows, + "outOfScope": out_of_scope, "questions": body["questions"], "answers": {}, "routes": [], @@ -1655,7 +2359,7 @@ fn depth(question: &Value, questions: &[Value]) -> usize { /// at most four questions of at most four options, every question exactly /// once, and a conditional question always in a later round than the one it /// depends on. -pub(super) fn question_rounds(questions: &[Value]) -> Vec { +pub(super) fn question_rounds(questions: &[Value], per_round: usize) -> Vec { let mut by_depth = BTreeMap::>::new(); for question in questions { by_depth @@ -1665,8 +2369,9 @@ pub(super) fn question_rounds(questions: &[Value]) -> Vec { } let mut rounds = Vec::new(); for level in by_depth.values() { - for chunk in level.chunks(MAX_QUESTIONS_PER_ROUND) { - let shaped = ask_shape( + for chunk in level.chunks(per_round.max(1)) { + let shaped = ask_shape_with( + questions, &chunk .iter() .map(|question| (*question).clone()) @@ -1761,6 +2466,13 @@ pub(super) async fn draft( source, input.notes.as_deref(), )?; + let per_round = input + .max_questions_per_round + .unwrap_or(MAX_QUESTIONS_PER_ROUND); + if !(1..=50).contains(&per_round) { + return Err(invalid("maxQuestionsPerRound must be between 1 and 50.")); + } + plan["maxQuestionsPerRound"] = json!(per_round); plan["createdAt"] = json!(now()); let replaced = read_plan(root) .ok() @@ -1769,7 +2481,7 @@ pub(super) async fn draft( .map(|previous| previous["planId"].clone()); let written = write_plan(policy, root, &plan)?; let questions = ask_shape(array(&plan["questions"])); - let rounds = question_rounds(array(&plan["questions"])); + let rounds = question_rounds(array(&plan["questions"]), per_round); let unverified = array(&plan["screens"]) .iter() .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") @@ -1782,7 +2494,10 @@ pub(super) async fn draft( "questions": questions, "questionCount": questions.len(), "askInstruction": ASK_INSTRUCTION, + "maxQuestionsPerRound": per_round, "heuristics": plan["heuristics"], + "excluded": plan["excluded"], + "outOfScope": plan["outOfScope"], "config": plan["config"], "unverifiedRoutes": unverified, "inventoryNote": plan["inventoryNote"], @@ -1872,6 +2587,13 @@ fn resolve_answer( } } +fn per_round(plan: &Value) -> usize { + plan["maxQuestionsPerRound"] + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(MAX_QUESTIONS_PER_ROUND) +} + /// Whether a question has to be answered, given the answers before it. fn required(question: &Value, actions: &BTreeMap) -> bool { array(&question["when"]) @@ -1947,12 +2669,36 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< ), false, json!({"missing": missing, "unknown": unknown, "askInstruction": ASK_INSTRUCTION, - "questionRounds": question_rounds(&pending)}), + "questionRounds": question_rounds(&pending, per_round(&plan))}), )); } let accepted = actions .get("heuristics") .is_none_or(|action| action["heuristics"] == "accept"); + let nothing_here = actions + .get("scope") + .is_some_and(|action| action["scope"] == "none"); + // Every "the Figma structure is wrong" answer: its frames are not built, + // and the fix is reported. The work goes on without them. + let design_fixes = questions + .iter() + .filter(|question| { + actions + .get(text(&question["id"])) + .is_some_and(|action| action["designFix"] == true) + }) + .map(|question| { + json!({"question": question["id"], "frames": question["fix"]["frames"], + "fix": question["fix"]["text"]}) + }) + .collect::>(); + let fixed = |ids: &[&str]| { + ids.iter().any(|id| { + actions + .get(*id) + .is_some_and(|action| action["designFix"] == true) + }) + }; let app_dir = text(&plan["appDir"]).to_owned(); let mut routes = Vec::new(); let mut screen_routes = BTreeMap::new(); @@ -1960,7 +2706,17 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< for screen in &screens { let id = text(&screen["id"]).to_owned(); let frames = array(&screen["frames"]).to_vec(); - let group = actions.get(&format!("group:{id}")); + let mut deciding = array(&screen["structureQuestions"]) + .iter() + .map(text) + .collect::>(); + deciding.extend(screen["groupingQuestion"].as_str()); + if nothing_here || fixed(&deciding) { + continue; + } + let group = screen["groupingQuestion"] + .as_str() + .and_then(|question| actions.get(question)); let separate = group.is_some_and(|action| action["group"] == "separate"); let data = actions .get(&format!("dynamic:{id}")) @@ -1971,7 +2727,8 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< let explicit = group.and_then(|action| action["routes"].as_array()); if explicit.is_some_and(|routes| routes.len() != frames.len()) { return Err(invalid(format!( - "group:{id}: list exactly one route per frame ({} frames).", + "{}: list exactly one route per frame ({} frames).", + text(&screen["groupingQuestion"]), frames.len() ))); } @@ -2045,6 +2802,39 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< })); } } + let mut excluded = Vec::new(); + for row in array(&plan["excluded"]) { + let node = text(&row["nodeId"]).to_owned(); + let built = !nothing_here + && actions + .get(&format!("exclusion:{node}")) + .is_some_and(|action| action["build"] == true); + let action = actions.get(&format!("exclusion-route:{node}")); + match action.and_then(|action| action["route"].as_str()) { + Some(route) if built => routes.push(json!({ + "url": route, "screen": node, "name": row["name"], "nodeIds": [node], + "drawnWidths": [row["width"].as_f64().map(|width| width.round() as i64)], + "implementation": "page", "hostScreen": null, + "file": planned_page(route, &app_dir), "fileExists": false, + "collisionPolicy": null, "data": null, + "routeEvidence": "confirmed answer (was excluded)", + })), + _ => { + let mut row = row.clone(); + row["decision"] = json!("excluded"); + excluded.push(row); + } + } + } + if nothing_here { + for screen in &screens { + for frame in array(&screen["frames"]) { + excluded.push(json!({"nodeId": frame["nodeId"], "name": frame["name"], "width": frame["width"], + "section": frame["sectionName"], "reason": "the linked area is not for implementation (answered)", + "decision": "excluded"})); + } + } + } let mut seen = BTreeMap::::new(); for row in &routes { if let Some(url) = row["url"].as_str() @@ -2091,6 +2881,8 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< .collect::>() ); plan["routes"] = json!(routes); + plan["excluded"] = json!(excluded); + plan["designFixes"] = json!(design_fixes); plan["status"] = json!("confirmed"); plan["confirmedAt"] = json!(now()); if !plan["assumptions"].is_array() { @@ -2102,6 +2894,8 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< "planId": plan["planId"], "routes": routes, "markdownTable": route_markdown(&routes), + "excluded": excluded, + "designFixes": design_fixes, "customAnswers": custom, "rule": IMPLEMENT_INSTRUCTION, "writtenTo": written, @@ -2136,10 +2930,31 @@ pub(super) fn assume(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result>() + })) + .filter(|id| { + !id.is_empty() + && input + .assumptions + .iter() + .any(|assumption| assumption.about.contains(id.as_str())) + }) + .collect::>(); let written = write_plan(policy, root, &plan)?; Ok(json!({ "status": "confirmed", "planId": plan["planId"], + "stillNotBuilt": blocked.iter().map(|id| json!({"frame": id, + "why": "Excluded or sent back for a design fix; an assumption cannot build it, and the export gate refuses it."})).collect::>(), "assumptions": plan["assumptions"], "writtenTo": written, "how": "Keep implementing. Every assumption appears in status and must be in your final report.", @@ -2253,20 +3068,10 @@ fn markdown(plan: &Value) -> String { out.push_str("\n## Confirmed routes\n\n"); out.push_str(&route_markdown(routes)); } - let assumptions = array(&plan["assumptions"]); - if !assumptions.is_empty() { - out.push_str("\n## Assumptions made during implementation\n\n"); - for assumption in assumptions { - out.push_str(&format!( - "- {}: {}{}\n", - text(&assumption["about"]), - text(&assumption["decision"]), - assumption["reason"] - .as_str() - .map(|reason| format!(" ({reason})")) - .unwrap_or_default() - )); - } + let lists = trailing_lists(plan); + if !lists.is_empty() { + out.push_str("\n## Not built, and why\n"); + out.push_str(&lists); } out } @@ -2301,7 +3106,7 @@ pub(super) async fn status( return Ok(json!({ "status": "not-confirmed", "planId": plan["planId"], - "questionRounds": question_rounds(array(&plan["questions"])), + "questionRounds": question_rounds(array(&plan["questions"]), per_round(&plan)), "questions": ask_shape(array(&plan["questions"])), "askInstruction": ASK_INSTRUCTION, "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"]}, "how": ASK_INSTRUCTION}, @@ -2476,6 +3281,22 @@ pub(super) fn status_report( if assumptions.len() == 1 { "" } else { "s" } )); } + let design_fixes = array(&plan["designFixes"]); + if !design_fixes.is_empty() { + summary.push_str(&format!( + "; {} design fix{} requested, not built", + design_fixes.len(), + if design_fixes.len() == 1 { "" } else { "es" } + )); + } + let excluded = array(&plan["excluded"]) + .iter() + .filter(|row| row["decision"] == "excluded") + .cloned() + .collect::>(); + if !excluded.is_empty() { + summary.push_str(&format!("; {} frames excluded, not built", excluded.len())); + } let mut table = String::from( "| URL | Figma screen | file | status | widths verified | screenshots |\n|---|---|---|---|---|---|\n", ); @@ -2522,6 +3343,7 @@ pub(super) fn status_report( cell(&json!(shots)), )); } + table.push_str(&trailing_lists(plan)); json!({ "status": "confirmed", "planId": plan["planId"], @@ -2530,27 +3352,60 @@ pub(super) fn status_report( "states": states, "widths": widths, "audit": audit.map(|audit| json!({"verdict": audit["verdict"], "planId": audit["planId"], "reason": audit["reason"]})), - "markdownTable": if assumptions.is_empty() { - table - } else { - format!( - "{table} -Assumptions made during implementation: -{}", - assumptions - .iter() - .map(|assumption| format!("- {}: {}", text(&assumption["about"]), text(&assumption["decision"]))) - .collect::>() - .join(" - ") - ) - }, + "markdownTable": table, "assumptions": assumptions, + "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.", }) } +/// The lists every final report ends with: design fixes requested, frames +/// excluded, and assumptions made during implementation. Shared by the plan +/// file and `status`, so the two never disagree about what was not built. +fn trailing_lists(plan: &Value) -> String { + let mut out = String::new(); + let fixes = array(&plan["designFixes"]); + if !fixes.is_empty() { + out.push_str("\nDesign fixes requested (not built):\n"); + for fix in fixes { + out.push_str(&format!("- {}\n", text(&fix["fix"]))); + } + } + let excluded = array(&plan["excluded"]) + .iter() + .filter(|row| row["decision"] == "excluded" || row["decision"].is_null()) + .collect::>(); + if !excluded.is_empty() { + out.push_str("\nExcluded (never built):\n"); + for row in excluded { + out.push_str(&format!( + "- \"{}\" ({}): {}\n", + text(&row["name"]), + text(&row["nodeId"]), + text(&row["reason"]) + )); + } + } + let assumptions = array(&plan["assumptions"]); + if !assumptions.is_empty() { + out.push_str("\nAssumptions made during implementation:\n"); + for assumption in assumptions { + out.push_str(&format!( + "- {}: {}{}\n", + text(&assumption["about"]), + text(&assumption["decision"]), + assumption["reason"] + .as_str() + .map(|reason| format!(" ({reason})")) + .unwrap_or_default() + )); + } + } + out +} + /// What the export gate decided. pub(super) enum Gate { /// Not a page write, or no plan applies. @@ -2631,6 +3486,37 @@ pub(super) fn export_gate(request: &ExportRequest<'_>) -> Result return Ok(Gate::Open), }; + // Never built, whatever else the call says: not by this export, and not + // by an assumption recorded during implementation. + let refused = request + .frames + .iter() + .filter_map(|frame| { + let excluded = array(&plan["excluded"]) + .iter() + .find(|row| row["nodeId"] == frame.as_str()) + .map(|row| format!("excluded: {}", text(&row["reason"]))); + let fix = array(&plan["designFixes"]) + .iter() + .find(|fix| { + array(&fix["frames"]) + .iter() + .any(|f| f["nodeId"] == frame.as_str()) + }) + .map(|fix| format!("design fix requested: {}", text(&fix["fix"]))); + excluded + .or(fix) + .map(|reason| json!({"frame": frame, "reason": reason})) + }) + .collect::>(); + if !refused.is_empty() && (gated || request.scaffold_route.is_some()) { + return Ok(Gate::Blocked(json!({ + "status": "refused", + "written": false, + "refused": refused, + "how": "Do not build these frames and do not record an assumption to build them. They are listed in devup_screen_plan status; continue with the rest of the plan.", + }))); + } let routes = array(&plan["routes"]); let row_of = |frame: &str| { routes diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index 1dc9db4b..a4be3f58 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -341,7 +341,7 @@ fn every_question_has_the_question_tool_shape() { /// a conditional question always after the question it depends on. fn assert_rounds_cover(plan: &Value) { let questions = array(&plan["questions"]); - let rounds = question_rounds(questions); + let rounds = question_rounds(questions, MAX_QUESTIONS_PER_ROUND); let mut seen = Vec::new(); let mut round_of = BTreeMap::new(); for (index, round) in rounds.iter().enumerate() { @@ -384,15 +384,22 @@ fn rounds_split_many_questions_into_fours() { .map(|index| screen(&format!("8:{index}"), &format!("Page{index}"), 1440.0)) .collect::>(); let plan = draft_plan(&screens, &BTreeMap::new(), &evidence(), json!({}), None).unwrap(); - let rounds = question_rounds(array(&plan["questions"])); + let rounds = question_rounds(array(&plan["questions"]), MAX_QUESTIONS_PER_ROUND); assert_eq!(rounds.len(), 3); assert_rounds_cover(&plan); + // A runtime with a larger cap gets fewer, larger rounds of the same + // questions; one with no cap asks the flat list. + let wide = question_rounds(array(&plan["questions"]), 10); + assert_eq!(wide.len(), 2); + assert_eq!(array(&wide[0]["questions"]).len(), 10); + assert_eq!(question_rounds(array(&plan["questions"]), 50).len(), 1); } -/// Designers differ: the same name in two sections is recommended as two -/// pages, and in sections named only for devices as one. +/// Frames of one name in different sections are never resolved here: the +/// question states the sections, frames, widths and ids, and offers a Figma +/// fix. Separate pages are recommended unless every section names a device. #[test] -fn sections_drive_the_recommendation_for_each_group() { +fn sections_are_asked_explicitly_with_a_fix_option() { let plan = draft_plan( &[ sectioned("9:1", "About", 1440.0, "Company"), @@ -406,13 +413,33 @@ fn sections_drive_the_recommendation_for_each_group() { None, ) .unwrap(); + let company = find(&plan, "sections:9:1"); assert_eq!( - first_label(find(&plan, "group:9:1")), - "Separate pages (Recommended)" + first_label(company), + "Separate pages: the sections are different pages (Recommended)" ); + let device = find(&plan, "sections:9:3"); assert_eq!( - first_label(find(&plan, "group:9:3")), - "One responsive page (Recommended)" + first_label(device), + "One responsive page: the sections only split by device (Recommended)" + ); + let prompt = text(&device["question"]); + for fact in ["PC", "Mobile", "9:3", "9:4", "1440px", "390px", "Pricing"] { + assert!(prompt.contains(fact), "{fact} missing from {prompt}"); + } + assert!( + text(&device["options"][0]["description"]) + .contains("Inferred from the section names (PC, Mobile)") + ); + let labels = array(&device["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert!(labels.contains(&FIX_FIGMA), "{labels:?}"); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("group:")) ); let split = find(&plan, "split-route:9:2"); assert!( @@ -431,6 +458,7 @@ fn project_config_counts_as_evidence_and_suppresses_its_questions() { modal_keywords: Some(vec!["sheet".into()]), route_aliases: BTreeMap::from([("요금제".to_owned(), "/pricing".to_owned())]), width_grouping: Some("responsive".into()), + ..PlanConfig::default() }; let plan = draft_plan( &[ @@ -866,7 +894,7 @@ async fn assumptions_are_recorded_after_confirm_and_reported_by_status() { assert!(text(&report["summary"]).ends_with("; 1 assumption made during implementation")); assert!(text(&report["markdownTable"]).contains("- 13:9: Not built")); let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); - assert!(markdown.contains("## Assumptions made during implementation")); + assert!(markdown.contains("Assumptions made during implementation:")); let mut uncovered = export(&fixture, &["13:9"]); uncovered.scaffold_write = true; @@ -892,3 +920,260 @@ async fn an_invalid_config_is_refused_rather_than_ignored() { assert_eq!(evidence.config_file.as_deref(), Some(CONFIG_FILE)); assert_eq!(evidence.config.route_aliases["메인"], "/"); } + +fn in_trash(node_id: &str, name: &str, page: &str, area: &str) -> PlanScreenInput { + PlanScreenInput { + section: Some(format!("section-{area}")), + section_name: Some(area.into()), + breadcrumb: vec![page.into(), area.into(), name.into()], + ..screen(node_id, name, 1440.0) + } +} + +fn page_screen(node_id: &str, name: &str, page: &str, area: &str) -> PlanScreenInput { + in_trash(node_id, name, page, area) +} + +/// Picking the Figma fix marks the group: its frames are not built, the rest +/// goes on, and status reports exactly what to move or rename. +#[tokio::test] +async fn a_requested_design_fix_is_reported_not_built() { + let fixture = Fixture::new(); + let mut confirm_input = drafted( + &fixture, + &[ + sectioned("14:1", "Pricing", 1440.0, "PC"), + sectioned("14:2", "Pricing", 390.0, "Mobile"), + sectioned("14:3", "Careers", 1440.0, "PC"), + ], + ) + .await; + confirm_input.answers = answers(&[ + ("sections:14:1", FIX_FIGMA), + ("route:14:3", "/careers (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let built = array(&confirmed["routes"]) + .iter() + .flat_map(|row| array(&row["nodeIds"]).to_vec()) + .collect::>(); + assert_eq!(built, [json!("14:3")]); + let fix = &confirmed["designFixes"][0]; + assert_eq!(fix["question"], "sections:14:1"); + assert_eq!(array(&fix["frames"]).len(), 2); + assert!(text(&fix["fix"]).contains("PC, Mobile"), "{fix}"); + + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + assert!(text(&report["summary"]).contains("1 design fix requested, not built")); + let table = text(&report["markdownTable"]); + assert!( + table.contains("Design fixes requested (not built):"), + "{table}" + ); + assert!(table.contains("14:1") && table.contains("14:2"), "{table}"); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("Design fixes requested"), "{markdown}"); + + let mut fixed = export(&fixture, &["14:2"]); + fixed.scaffold_write = true; + let Gate::Blocked(response) = export_gate(&fixed).unwrap() else { + panic!("a frame marked for a design fix is never written"); + }; + assert_eq!(response["status"], "refused"); +} + +/// Trash, archive and copies are never built and never silently dropped. +#[test] +fn trash_and_archive_frames_are_excluded_and_asked_about_once() { + let plan = draft_plan( + &[ + page_screen("15:1", "Home", "Site", "Pages"), + in_trash("15:2", "Home", "Site", "휴지통"), + in_trash("15:3", "About", "Site", "Archive"), + page_screen("15:4", "Home copy", "Site", "Pages"), + ], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "page-1", "anchorName": "Site", "anchorBreadcrumb": ["Site"]}), + None, + ) + .unwrap(); + let excluded = array(&plan["excluded"]); + let ids = excluded + .iter() + .map(|row| text(&row["nodeId"])) + .collect::>(); + assert_eq!(ids, ["15:2", "15:3", "15:4"]); + assert!(text(&excluded[0]["reason"]).contains("휴지통")); + assert!(text(&excluded[1]["reason"]).contains("\"archive\"")); + assert!(text(&excluded[2]["reason"]).contains("\"copy\"")); + let question = find(&plan, "exclusions"); + assert_eq!(first_label(question), "Accept the exclusions (Recommended)"); + for id in ["15:2", "15:3", "15:4"] { + assert!(text(&question["question"]).contains(id)); + } + assert_eq!(array(&plan["screens"]).len(), 1); + assert!(!question_ids(&plan).iter().any(|id| id == "route:15:3")); + assert_rounds_cover(&plan); +} + +#[test] +fn the_config_can_change_and_override_the_exclusion_words() { + let mut evidence = evidence(); + evidence.config.exclude_keywords = Some(vec!["legacy".into()]); + evidence.config.include_keywords = Some(vec!["test drive".into()]); + let plan = draft_plan( + &[ + page_screen("16:1", "Archive", "Site", "Pages"), + page_screen("16:2", "Legacy home", "Site", "Pages"), + page_screen("16:3", "Test drive", "Site", "Legacy"), + ], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let excluded = array(&plan["excluded"]) + .iter() + .map(|row| text(&row["nodeId"])) + .collect::>(); + assert_eq!(excluded, ["16:2"]); +} + +/// A link that itself points into an archive is asked once, not excluded. +#[test] +fn a_link_into_an_archive_is_asked_once_instead_of_excluded() { + let plan = draft_plan( + &[in_trash("17:1", "Home", "Site", "Archive")], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "section-Archive", "anchorName": "Archive", "anchorBreadcrumb": ["Site", "Archive"]}), + None, + ) + .unwrap(); + assert!(array(&plan["excluded"]).is_empty()); + let scope = find(&plan, "scope"); + assert!(text(&scope["question"]).contains("Archive")); + assert_eq!(find(&plan, "heuristics")["when"][0]["question"], "scope"); +} + +/// Never wider than the link: a frame whose ancestry does not reach the +/// linked node is not a candidate. +#[test] +fn frames_outside_the_linked_node_are_never_candidates() { + let plan = draft_plan( + &[ + page_screen("18:1", "About", "Site", "Company"), + page_screen("18:2", "Pricing", "Site", "Sales"), + ], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "section-Company", "anchorName": "Company", "anchorBreadcrumb": ["Site", "Company"]}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["id"], "18:1"); + assert_eq!(plan["outOfScope"][0]["nodeId"], "18:2"); + assert!(!plan["questions"].to_string().contains("18:2")); + // Explore's own answer maps the same way: a frame straight on the page + // is in no section, and ancestry keeps it in or out of scope. + let on_page = screen_from_explore(&json!({"nodeId": "19:1", "name": "Loose", "parentId": "0:1", + "bounds": {"width": 1440.0, "height": 900.0}, "breadcrumb": ["Site", "Loose"], "visible": true})) + .unwrap(); + assert_eq!(on_page.section, None); + assert!(in_scope(&on_page, Some("0:1"), Some("Site"))); + assert!(!in_scope( + &on_page, + Some("section-Company"), + Some("Company") + )); + let hidden = screen_from_explore(&json!({"nodeId": "19:2", "name": "Old", "parentId": "s", + "bounds": {"width": 1440.0}, "breadcrumb": ["Site", "Pages", "Old"], "visible": false})) + .unwrap(); + assert_eq!(hidden.section.as_deref(), Some("s")); + assert_eq!( + exclusion_reason(&hidden, &[], &PlanConfig::default()).as_deref(), + Some("hidden in Figma") + ); +} + +#[test] +fn other_structural_oddities_are_flagged_with_the_same_fix_option() { + let plan = draft_plan( + &[ + sectioned("20:1", "Notice", 1440.0, "Board"), + sectioned("20:2", "Notice", 1440.0, "Board"), + sectioned("20:3", "Guide 1", 1440.0, "Help"), + sectioned("20:4", "Guide 2", 1440.0, "Support"), + screen("20:5", "Contact", 1440.0), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + for id in [ + "structure:duplicate:20:1", + "structure:split:20:3", + "structure:loose:20:5", + ] { + let question = find(&plan, id); + let labels = array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert!(labels.contains(&FIX_FIGMA), "{id}: {labels:?}"); + } + // The duplicates' own route questions wait on the structure answer. + let route = find(&plan, "route:20:2"); + assert!( + array(&route["when"]) + .iter() + .any(|condition| condition["question"] == "structure:duplicate:20:1"), + "{route}" + ); + assert_rounds_cover(&plan); +} + +#[tokio::test] +async fn the_gate_refuses_an_excluded_frame_even_with_an_assumption() { + let fixture = Fixture::new(); + let plan = draft( + &input(&fixture, "draft"), + &[ + page_screen("21:1", "Pricing", "Site", "Pages"), + in_trash("21:2", "Pricing", "Site", "Trash"), + ], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); + confirm_input.answers = answers(&[ + ("route:21:1", "/pricing (Recommended)"), + ("exclusions", "Accept the exclusions (Recommended)"), + ]); + confirm(&confirm_input, &fixture.policy()).unwrap(); + let mut assume_input = input(&fixture, "assume"); + assume_input.assumptions = vec![AssumptionInput { + about: "21:2".into(), + decision: "Build it as /pricing-old".into(), + reason: None, + }]; + let recorded = assume(&assume_input, &fixture.policy()).unwrap(); + assert_eq!(recorded["stillNotBuilt"][0]["frame"], "21:2"); + let mut excluded = export(&fixture, &["21:2"]); + excluded.scaffold_write = true; + let Gate::Blocked(response) = export_gate(&excluded).unwrap() else { + panic!("an excluded frame is never written"); + }; + assert_eq!(response["status"], "refused"); + assert!(text(&response["refused"][0]["reason"]).starts_with("excluded")); +} From ec18b4317911bde1b5a2cbdadfa34945db7cbf97 Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 22:12:32 +0900 Subject: [PATCH 10/24] Document scope, exclusions and design fixes in the skill, guide and README The devup-implementation-plan skill gains the hard rules: stay inside the linked scope and out of trash, archive and draft areas, never resolve a cross-section ambiguity yourself, and report frames marked for a design fix instead of building them. It describes the new questions, the flat list for uncapped question tools, and excludeKeywords/includeKeywords next to the other config entries. Guide rule 2c, the tool and export descriptions, the README, the manifest and the changepack note match. Co-Authored-By: Claude Opus 5.5 --- .../changepack_log_screen_plan_first.json | 2 +- README.md | 8 ++- crates/devup-mcp/src/server/guide.rs | 4 +- .../skills/devup-implementation-plan/SKILL.md | 68 +++++++++++++------ .../devup-mcp/src/server/skills/manifest.json | 6 +- 5 files changed, 62 insertions(+), 26 deletions(-) diff --git a/.changepacks/changepack_log_screen_plan_first.json b/.changepacks/changepack_log_screen_plan_first.json index 6010e5f3..82ee2c91 100644 --- a/.changepacks/changepack_log_screen_plan_first.json +++ b/.changepacks/changepack_log_screen_plan_first.json @@ -2,6 +2,6 @@ "changes": { "crates/devup-mcp/Cargo.toml": "Minor" }, - "note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen is mapped to an evidence-backed URL and confirmed in question rounds asked up front before pages are written, never asking during implementation; ask per group whether same-named frames are one responsive page or separate pages; surface layer-name guesses for confirmation; honour an optional .devup/screen-plan.config.json; gate page-writing exports on a confirmed plan; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill.", + "note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen under the linked node is mapped to an evidence-backed URL and confirmed in questions asked up front, never during implementation; keep the plan inside the linked scope and out of trash, archive, draft, copy and hidden areas (listed and confirmed, overridable with excludeKeywords/includeKeywords); ask cross-section and other structural ambiguities explicitly with a fix-the-Figma option whose frames are reported, not built; surface layer-name guesses; honour .devup/screen-plan.config.json; gate page-writing exports on the confirmed plan and refuse excluded or design-fix frames; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill.", "date": "2026-10-17T00:00:00+09:00" } diff --git a/README.md b/README.md index d72bbf1a..f2887bdd 100644 --- a/README.md +++ b/README.md @@ -1058,7 +1058,13 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 `devup_figma_export`는 `projectRoot`가 있고 페이지를 쓰는 호출(`pageScaffold` write, `app/` 아래 `outputPaths`, `outputPaths`가 있는 여러 프레임·`allScreens`)인데 그 프레임을 덮는 확정 계획이 없으면 쓰지 않고 `status: "plan_required"`와 `devup_screen_plan draft`로 가는 `nextAction`을 돌려줍니다. 컴포넌트 하나, 쓰지 않는 미리보기는 막지 않습니다. 확정 계획이 있으면 `pageScaffold.route`는 계획에서 가져오고, 다른 라우트를 넘기면 `routeMismatch`로 거절합니다. 전체 흐름은 `devup_skills`로 설치하는 `devup-implementation-plan` 스킬에 있습니다. -디자이너마다 이름 짓는 방식이 달라서, 프로젝트는 선택적으로 `.devup/screen-plan.config.json`에 `modalKeywords`(모달로 볼 레이어 이름 단어, 기본 목록을 대체), `routeAliases`(레이어 이름 → 라우트), `widthGrouping`(`responsive` | `separate` | `ask`, 기본 `ask`)을 둘 수 있습니다. 있는 항목은 근거로 쓰이고 그에 해당하는 질문을 대신합니다. +디자이너마다 이름 짓는 방식이 달라서, 프로젝트는 선택적으로 `.devup/screen-plan.config.json`에 `modalKeywords`(모달로 볼 레이어 이름 단어, 기본 목록을 대체), `routeAliases`(레이어 이름 → 라우트), `widthGrouping`(한 Section 안의 여러 폭: `responsive` | `separate` | `ask`, 기본 `ask`), `excludeKeywords`(구현 대상이 아닌 영역을 표시하는 단어, 기본 목록을 대체), `includeKeywords`(이 단어가 든 이름은 제외하지 않음)를 둘 수 있습니다. 있는 항목은 근거로 쓰이고 그에 해당하는 질문을 대신합니다. + +범위는 링크가 가리킨 노드 아래로만 한정합니다. 파일 전체나 다른 페이지로 넓히지 않고(`outOfScope`에 기록), 이름이 휴지통/trash, 아카이브/archive, 구버전/old, 사용안함/deprecated, 백업/backup, 작업중/draft/WIP, 테스트/test, sandbox, playground, 사본/copy인 페이지·Section·프레임과 숨겨진 프레임은 짓지 않습니다. 제외한 것은 `excluded`에 이유와 함께 남기고 `.md`와 확인 질문("These N were excluded ... Accept the exclusions (Recommended) / Review individually")에 보여 줍니다. 링크 자체가 그런 영역 안을 가리키면 제외하지 않고 한 번 묻습니다. + +같은 이름의 프레임이 서로 다른 Section에 있으면(PC/Mobile처럼 기기 이름 Section 포함) 서버가 정하지 않습니다. Section 이름·프레임 이름·폭·nodeId와 관련 있어 보이는 이유를 적은 질문으로 "Separate pages: the sections are different pages", "One responsive page: the sections only split by device"(모든 Section 이름이 기기 이름일 때만 추천), "The Figma structure is wrong: I will fix it" 중에서 묻습니다. 한 화면이 여러 Section에 나뉜 경우, Section 밖에 홀로 있는 프레임, 한 Section 안의 같은 이름·같은 폭 중복도 같은 "고치겠다" 선택지로 묻습니다. 고치겠다고 한 프레임은 짓지 않고 `status`와 최종 보고의 "Design fixes requested"에 옮기거나 이름을 바꿀 프레임과 Section을 정확히 적습니다. 에이전트는 기다리지 않고 나머지를 계속합니다. export gate는 제외되었거나 디자인 수정 대상인 프레임의 페이지 쓰기를 가정(`assume`)이 있어도 거절합니다. + +질문 도구에 호출당 개수 제한이 없으면(OpenCode `question`) 평평한 `questions` 목록을 한 번에 묻고, 제한이 있으면(Claude Code `AskUserQuestion`: 4) `maxQuestionsPerRound`(기본 4)로 나눈 `questionRounds`를 연달아 묻습니다. ## 읽기 전용·개인정보 보호 diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index 0492646a..7706f5b3 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -39,8 +39,8 @@ pub const INSTRUCTIONS: &str = concat!( "run devup_ui_validate and report wired/tested counts before functional completion.\n", "2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ", "workspace lacks and installs the ones it carries.\n", - "2c. Several pages: devup_screen_plan first; ask every questionRound back to back before implementing, ", - "never after confirm (skill devup-implementation-plan).\n", + "2c. Several pages: devup_screen_plan first; only the linked scope, never trash/archive; ask all its questions ", + "before implementing, never after confirm (skill devup-implementation-plan).\n", "3. Request only outputs you will read. Before your second call, resources/read \"devup://guide/usage\" ", "for output sizing, verification boundaries, SECTION batching, assets and delivery." ); 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 63fbe10b..38865549 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 @@ -31,11 +31,13 @@ So the order is fixed, and none of it is optional. - **No implementation before confirm.** No page, layout or component file is written until `devup_screen_plan` `confirm` has succeeded. -- **All questions up front, back to back.** Ask every round in - `questionRounds` before any implementation starts, in consecutive turns with - no other work in between. Several rounds are fine - AskUserQuestion takes at - most four questions per call, and the server has already split them - but - never one decision at a time spread through the work. +- **All questions up front, back to back.** Ask every question before any + implementation starts. If your question tool has no per-call cap (OpenCode + `question`), ask the flat `questions` list in one call; each conditional + question says when it applies. If it has one (Claude Code AskUserQuestion: + 4), ask `questionRounds` in consecutive turns with no other work in between; + pass `maxQuestionsPerRound` to draft when your cap is different. Never one + decision at a time spread through the work. - **Never ask during implementation.** After `confirm`, ask the user nothing: someone may have walked away from the keyboard, and a question three hours in stops everything. When something new comes up, take the plan's @@ -52,6 +54,19 @@ So the order is fixed, and none of it is optional. named question got that answer, e.g. a per-frame route only after "Separate pages". Rounds are ordered so the answer it depends on is always already in hand. +- **Stay inside the scope the user gave.** Only frames under the linked + Section, frame or page (or the Bridge selection) are candidates. Never widen + to the whole file or other pages on your own, and never build from trash, + archive, old, draft, test, copy or hidden areas: draft lists them under + `excluded` and asks once whether to accept that. +- **Never resolve a cross-section ambiguity yourself.** Frames that look like + one screen in different sections - device-named ones such as PC / Mobile + included - are a designer's structure. Ask the `sections:` question as + returned; it names the sections, frames, widths and node ids. +- **Frames marked for a design fix are reported, not built.** When the user + answers "The Figma structure is wrong: I will fix it", those frames are left + out, listed under "Design fixes requested" with what to move or rename, and + the rest of the work goes on without waiting. - **Never invent a route.** A route comes from an existing page built from the frame, a nav link already bound to an href, an explicit `navigationTargets` entry, the project's `routeAliases`, or the user's answer. A route inferred @@ -70,11 +85,12 @@ So the order is fixed, and none of it is optional. step 1 (with each frame's `section`) to skip a second Figma read, and `navigationTargets` for any Figma node -> route binding you were given explicitly. It writes `.devup/screen-plan.json` and `.devup/screen-plan.md`. -3. **Show the plan, then ask every round.** Show the user the `screens` table +3. **Show the plan, then ask everything.** Show the user the `screens` table (Figma screen, widths, proposed route, evidence, existing file, shared - parts, open decisions) and the `heuristics` list, then ask round 1, round - 2, ... of `questionRounds` back to back, exactly as returned. Do not reword - the options; the labels are what confirm reads. + parts, open decisions), the `heuristics` list and the `excluded` list, then + ask the questions - the flat list in one call, or the rounds back to back - + exactly as returned. Do not reword the options; the labels are what confirm + reads. 4. **Confirm.** Call `devup_screen_plan` with `action: "confirm"`, the `planId`, and `answers` keyed by question id (the chosen label, with or without " (Recommended)", or the user's custom text) for every question @@ -86,7 +102,8 @@ So the order is fixed, and none of it is optional. a different one. Screens confirmed as a state of another page are built inside that page. An export of a frame the confirmed plan does not place answers `plan_required` and writes nothing: leave it unbuilt and record an - assumption - do not ask. + assumption - do not ask. An excluded or design-fix frame answers `refused`: + it is never built, and no assumption changes that. 6. **Run the runtime audit.** `devup_runtime_audit` `plan` with every confirmed URL as a screen (`routePath`) at the default widths (1440, 1100, 858, 390), each with a `no-horizontal-overflow` check; run the executor in the @@ -96,7 +113,8 @@ So the order is fixed, and none of it is optional. audit item is `not-run`, never `pass`. 8. **Finish with the table.** End your reply with the `markdownTable` from status - `URL | Figma screen | file | status | widths verified | - screenshots`, followed by the assumptions made during implementation - and + screenshots`, followed by the design fixes requested, the excluded frames + and the assumptions made during implementation - and its one-line `summary`, such as `7 URLs: 5 implemented, 3 verified at all widths, /about overflows at 390`. Report every `fail`, `not-run` and assumption as what it is. Do not call the @@ -106,7 +124,11 @@ So the order is fixed, and none of it is optional. | id | asked when | resolves to | | --- | --- | --- | -| `group:` | frames share a name across widths or sections | one responsive page, separate pages, or one route per frame (custom `/a, /b`) | +| `scope` | the link itself points into a trash, archive or draft area | plan it anyway, or build nothing | +| `exclusions` | frames were excluded by name or because they are hidden | accept, or review each (`exclusion:` / `exclusion-route:`) | +| `group:` | one name drawn at several widths in one section | one responsive page, separate pages, or one route per frame (custom `/a, /b`) | +| `sections:` | one name in different sections | separate pages, one responsive page, or the Figma structure is wrong | +| `structure::` | a duplicate, a screen split across sections, or a frame outside any section | keep as is, or the Figma structure is wrong | | `split-route:` | `when` its group is separate pages | that frame's URL, skip, or not a page | | `route:` | no evidence names a route, or evidence conflicts | the URL, skip, or not a page | | `heuristics` | a route was inferred from a layer name | accept all, or review individually | @@ -117,10 +139,10 @@ So the order is fixed, and none of it is optional. | `link::` | a link's destination is not in the plan | a route, or unlinked | | `layout:` | a part appears on several screens and no layout renders it | shared layout, or per page | -Which way a `group` question recommends follows the evidence: several widths -in one section with matching names recommend one responsive page; the same -name in different sections recommends separate pages, unless the sections are -named only for devices (`PC`, `Mobile`). +A `group` question recommends one responsive page: the widths are in one +section with matching names. A `sections` question recommends separate pages, +unless every section name is a device name (`PC`, `Mobile`, `Tablet`, +`모바일`) - and then it says the recommendation was inferred from those names. ## Project config @@ -132,11 +154,19 @@ evidence and replaces the question it would otherwise take: { "modalKeywords": ["popup", "sheet"], "routeAliases": { "메인": "/", "회사소개": "/company" }, - "widthGrouping": "responsive" + "widthGrouping": "responsive", + "excludeKeywords": ["trash", "archive", "legacy"], + "includeKeywords": ["test drive"] } ``` - `modalKeywords` replaces the built-in modal words. - `routeAliases` maps a layer name to a route. -- `widthGrouping` is `responsive` (merge same-named widths into one page), - `separate` (never merge), or `ask` (the default: ask per group). +- `widthGrouping` is `responsive` (merge same-named widths in one section + into one page), `separate` (never merge), or `ask` (the default: ask per + group). Frames in different sections are always asked. +- `excludeKeywords` replaces the built-in words that mark an area as not for + implementation (trash/휴지통, archive/아카이브, old/구버전/이전, + deprecated/사용안함/미사용, backup/백업, draft/WIP/작업중, test/테스트, + sandbox, playground, copy/사본). +- `includeKeywords` forces names containing them back in. diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index d62fb743..6c4d2a00 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -64,15 +64,15 @@ "name": "devup-implementation-plan", "origin": "own", "title": "Screen plan first, confirm once, then implement", - "description": "The order a Figma design becomes pages in: list the screens, draft a screen-to-URL plan with devup_screen_plan, ask every question round back to back before any work, confirm, implement URL by URL without asking anything more (recording assumptions instead), run the runtime audit, and finish with a URL x width status table.", + "description": "The order a Figma design becomes pages in: list the screens, draft a screen-to-URL plan with devup_screen_plan, stay inside the linked scope and out of trash/archive/draft areas, ask every question before any work (cross-section ambiguities included, with a fix-the-Figma option), confirm, implement URL by URL without asking anything more (recording assumptions instead), run the runtime audit, and finish with a URL x width status table.", "usedFor": "Without it an agent handed a Figma Section picks URLs itself and writes pages straight away, so nobody can tell afterwards which screen became which URL or whether each one works at every width. It also keeps the questions in structured rounds asked up front, instead of a trickle of prose confirmations - or a question hours into the work while nobody is at the keyboard.", "repo": "dev-five-git/devup-mcp", "path": "crates/devup-mcp/src/server/skills/devup-implementation-plan", "documents": [ { "path": "SKILL.md", - "bytes": 8117, - "sha256": "478effcc7b0aa69f3a0603f38679f44c2f0d434e144fd85f0d9bbeef240ca1d5" + "bytes": 10447, + "sha256": "debd22d8b37812ce7d9c89196695d67607017c597f46e86b6237685fbd06c2fc" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", From b02da9aefd15d9d9c440392fc4a65df833ebe1d8 Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 22:36:45 +0900 Subject: [PATCH 11/24] Warn when generated pages give no feedback or carry no metadata New devup-ui site checks, run per file in bundle mode: an interactive element (as="a"/"button", Link, button, onClick) with neither _hover nor _focusVisible is an interactive-states warning, a _hover with no transition is hover-transition info, and an App Router page or layout exporting neither metadata nor generateMetadata is a page-metadata warning. interaction_coverage counts elements with and without states for the screen plan's status. The one-component-per-file fixture page now exports metadata so its strict verdict stays about that rule. Co-Authored-By: Claude Opus 5.5 --- crates/devup-mcp-devup-ui/src/ui_structure.rs | 6 + crates/devup-mcp-devup-ui/src/ui_validate.rs | 2 + .../src/ui_validate/site.rs | 330 ++++++++++++++++++ .../devup-mcp/tests/ui_validate_response.rs | 4 +- 4 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 crates/devup-mcp-devup-ui/src/ui_validate/site.rs diff --git a/crates/devup-mcp-devup-ui/src/ui_structure.rs b/crates/devup-mcp-devup-ui/src/ui_structure.rs index f4daa838..f2831004 100644 --- a/crates/devup-mcp-devup-ui/src/ui_structure.rs +++ b/crates/devup-mcp-devup-ui/src/ui_structure.rs @@ -82,6 +82,12 @@ pub fn validate_bundle( let mut report = validate_source(&file.content, theme, strict, source_type(&file.path)); if !report.violations.iter().any(|v| v.rule == "invalid-syntax") { report.violations.extend(structural(file, boundary)); + report + .violations + .extend(super::site::interaction_findings(&file.path, &file.content)); + report + .violations + .extend(super::site::metadata_finding(&file.path, &file.content)); } for finding in &mut report.violations { finding diff --git a/crates/devup-mcp-devup-ui/src/ui_validate.rs b/crates/devup-mcp-devup-ui/src/ui_validate.rs index e07f764a..13280cda 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -35,6 +35,8 @@ use std::collections::BTreeSet; #[path = "ui_validate/blur_layers.rs"] mod blur_layers; +#[path = "ui_validate/site.rs"] +pub mod site; use oxc_allocator::Allocator; use oxc_ast::ast::{ diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/site.rs b/crates/devup-mcp-devup-ui/src/ui_validate/site.rs new file mode 100644 index 00000000..ebc46340 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/site.rs @@ -0,0 +1,330 @@ +//! Site completeness: what makes a page built from Figma feel alive and ship. +//! +//! The generator emits what Figma draws, and Figma draws a resting state. A +//! site built from it alone had no hover, focus or pressed feedback, no +//! transitions and no metadata, and nothing said so. These checks name each +//! gap in the code a caller supplies; they open no file. +//! +//! - `interactive-states` (warning): a link, button or click target with +//! neither `_hover` nor `_focusVisible`. Without a visible focus state a +//! keyboard user cannot see where they are. +//! - `hover-transition` (info): a `_hover` that changes something with no +//! `transition`, so the change snaps instead of easing. +//! - `page-metadata` (warning): an App Router `page` or `layout` that exports +//! neither `metadata` nor `generateMetadata`. + +use super::{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, Span}; + +/// Interactive elements found in one source, and how many carry states. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct InteractionCoverage { + pub interactive: usize, + pub with_states: usize, + pub hover_without_transition: usize, +} + +struct Element { + span: Span, + name: String, + has_states: bool, + hover_without_transition: bool, +} + +#[derive(Default)] +struct Collector { + elements: Vec, +} + +fn attribute_name<'a>(item: &'a JSXAttributeItem<'a>) -> Option<&'a str> { + match item { + JSXAttributeItem::Attribute(attribute) => match &attribute.name { + JSXAttributeName::Identifier(name) => Some(name.name.as_str()), + JSXAttributeName::NamespacedName(_) => None, + }, + JSXAttributeItem::SpreadAttribute(_) => None, + } +} + +/// `as="a"`, `as="button"` or `as={Link}`. +fn interactive_as(item: &JSXAttributeItem<'_>) -> bool { + let JSXAttributeItem::Attribute(attribute) = item else { + return false; + }; + if !matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "as") { + return false; + } + match &attribute.value { + Some(JSXAttributeValue::StringLiteral(value)) => { + matches!(value.value.as_str(), "a" | "button") + } + Some(JSXAttributeValue::ExpressionContainer(container)) => { + matches!(&container.expression, JSXExpression::Identifier(id) if id.name == "Link") + } + _ => false, + } +} + +impl<'a> Visit<'a> for Collector { + fn visit_jsx_opening_element(&mut self, node: &JSXOpeningElement<'a>) { + let name = match &node.name { + JSXElementName::Identifier(id) => id.name.to_string(), + JSXElementName::IdentifierReference(id) => id.name.to_string(), + _ => String::new(), + }; + let names = node + .attributes + .iter() + .filter_map(attribute_name) + .collect::>(); + // A spread may carry the states; nothing here can see into it. + let spread = node + .attributes + .iter() + .any(|item| matches!(item, JSXAttributeItem::SpreadAttribute(_))); + let interactive = matches!(name.as_str(), "a" | "button" | "Link") + || node.attributes.iter().any(interactive_as) + || names.contains(&"onClick"); + if interactive && !spread { + let hover = names.contains(&"_hover"); + let transition = names.iter().any(|name| name.starts_with("transition")); + self.elements.push(Element { + span: node.span(), + name, + has_states: hover || names.contains(&"_focusVisible"), + hover_without_transition: hover && !transition, + }); + } + walk::walk_jsx_opening_element(self, node); + } +} + +fn source_type(path: &str) -> SourceType { + if path.ends_with(".ts") { + SourceType::ts() + } else { + SourceType::tsx() + } +} + +fn collect(path: &str, source: &str) -> Option> { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, source_type(path)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return None; + } + let mut collector = Collector::default(); + collector.visit_program(&parsed.program); + Some(collector.elements) +} + +/// How many interactive elements one source has, and how many carry a hover +/// or focus state. A source that does not parse counts as none. +pub fn interaction_coverage(path: &str, source: &str) -> InteractionCoverage { + let elements = collect(path, source).unwrap_or_default(); + InteractionCoverage { + interactive: elements.len(), + with_states: elements.iter().filter(|element| element.has_states).count(), + hover_without_transition: elements + .iter() + .filter(|element| element.hover_without_transition) + .count(), + } +} + +fn violation( + source: &str, + span: Span, + rule: &'static str, + severity: Severity, + message: String, + suggestion: &str, +) -> Violation { + let start = span.start as usize; + let prefix = source.get(..start).unwrap_or(""); + let mut context = serde_json::Map::new(); + context.insert( + "line".into(), + serde_json::json!(prefix.bytes().filter(|b| *b == b'\n').count() + 1), + ); + // The same position fields every structural finding carries. + context.insert( + "column".into(), + serde_json::json!(prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1), + ); + context.insert( + "positionEncoding".into(), + serde_json::json!("1-based Unicode scalar columns; byteRange uses UTF-8 bytes"), + ); + context.insert( + "snippet".into(), + serde_json::json!( + source + .get(start..span.end as usize) + .unwrap_or("") + .chars() + .take(120) + .collect::() + ), + ); + Violation { + rule, + severity, + byte_range: [start, span.end as usize], + context, + message, + suggestion: Some(suggestion.into()), + } +} + +/// `interactive-states` and `hover-transition` findings for one source. +pub fn interaction_findings(path: &str, source: &str) -> Vec { + let mut found = Vec::new(); + for element in collect(path, source).unwrap_or_default() { + if !element.has_states { + found.push(violation( + source, + element.span, + "interactive-states", + Severity::Warning, + format!( + "<{}> is interactive but has neither _hover nor _focusVisible; it gives no feedback and a keyboard user cannot see focus on it.", + element.name + ), + "Add _hover, _focusVisible (a visible focus ring), _active, cursor=\"pointer\" and _disabled where it applies, built from existing theme tokens, with a shared transition. On a next/link, render it as so the states are devup-ui props.", + )); + } else if element.hover_without_transition { + found.push(violation( + source, + element.span, + "hover-transition", + Severity::Info, + format!( + "<{}> changes on _hover with no transition, so the change snaps.", + element.name + ), + "Add one shared transition (about 0.2s ease on the properties that change) and _motionReduce={{ transition: \"none\" }}.", + )); + } + } + found +} + +/// Whether a path is an App Router `page` or `layout` file. +pub fn is_route_file(path: &str) -> bool { + let path = path.replace('\\', "/"); + let mut segments = path.split('/').collect::>(); + let Some(file) = segments.pop() else { + return false; + }; + let stem = file.split('.').next().unwrap_or_default(); + matches!(stem, "page" | "layout") + && file + .rsplit('.') + .next() + .is_some_and(|ext| matches!(ext, "tsx" | "ts" | "jsx" | "js")) + && segments.contains(&"app") +} + +/// Whether a source exports `metadata` or `generateMetadata`. +pub fn exports_metadata(source: &str) -> bool { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let named = |name: &str| matches!(name, "metadata" | "generateMetadata"); + parsed.program.body.iter().any(|statement| match statement { + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(variables) => { + variables.declarations.iter().any(|declarator| { + matches!(&declarator.id, BindingPattern::BindingIdentifier(id) if named(&id.name)) + }) + } + Declaration::FunctionDeclaration(function) => function + .id + .as_ref() + .is_some_and(|id| id.name == "generateMetadata"), + _ => false, + }, + Statement::ExportNamedDeclaration(export) => export + .specifiers + .iter() + .any(|specifier| named(&specifier.exported.to_string())), + _ => false, + }) +} + +/// `page-metadata` for one file, when it is a page or layout without one. +pub fn metadata_finding(path: &str, source: &str) -> Option { + if !is_route_file(path) || exports_metadata(source) { + return None; + } + Some(violation( + source, + Span::new(0, 0), + "page-metadata", + Severity::Warning, + format!( + "{path} exports no metadata, so the page has no title, description or share card of its own." + ), + "Export metadata with title, description and openGraph (the devfive-frontend SEO rule); the root layout also carries the title template and metadataBase. A 'use client' page cannot export it: move the client part into a component.", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_link_or_button_is_a_warning_and_a_styled_one_is_not() { + let source = r#"import Link from "next/link"; +export function Nav() { + return <> + About + Go + X + Y + + ; +}"#; + let found = interaction_findings("Nav.tsx", source); + let rules = found.iter().map(|v| v.rule).collect::>(); + assert_eq!( + rules, + [ + "interactive-states", + "interactive-states", + "hover-transition" + ] + ); + assert_eq!(found[0].severity, Severity::Warning); + assert_eq!(found[2].severity, Severity::Info); + let coverage = interaction_coverage("Nav.tsx", source); + assert_eq!( + coverage, + InteractionCoverage { + interactive: 4, + with_states: 2, + hover_without_transition: 1 + } + ); + } + + #[test] + fn a_page_or_layout_without_metadata_is_a_warning() { + let bare = "export default function Page() { return
; }"; + let finding = metadata_finding("src/app/about/page.tsx", bare).unwrap(); + assert_eq!(finding.rule, "page-metadata"); + assert_eq!(finding.severity, Severity::Warning); + for with in [ + "export const metadata = { title: 'About' };\nexport default function Page() { return
; }", + "export async function generateMetadata() { return {}; }\nexport default function Page() { return
; }", + ] { + assert!(metadata_finding("app/about/page.tsx", with).is_none()); + } + assert!(metadata_finding("src/components/About.tsx", bare).is_none()); + assert!(is_route_file("app/layout.tsx")); + } +} diff --git a/crates/devup-mcp/tests/ui_validate_response.rs b/crates/devup-mcp/tests/ui_validate_response.rs index e5b5a493..cc4645a8 100644 --- a/crates/devup-mcp/tests/ui_validate_response.rs +++ b/crates/devup-mcp/tests/ui_validate_response.rs @@ -970,8 +970,10 @@ async fn bundle_page_helper_is_not_judged_as_the_page() -> anyhow::Result<()> { /// itself a violation — for a component used by exactly one route. #[tokio::test] async fn a_single_use_route_helper_is_not_pushed_into_shared_components() -> anyhow::Result<()> { + // The metadata export keeps `ok` about this rule alone: a page without + // one is its own warning (`page-metadata`). let output = structural( - "function PageContent(){return
}\nexport default function NoticePage(){return }", + "export const metadata = { title: 'Notice' };\nfunction PageContent(){return
}\nexport default function NoticePage(){return }", "src/app/notice/page.tsx", ) .await?; From d54e61ffb6a615d93fba5522848986a74cc8f78a Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 22:36:45 +0900 Subject: [PATCH 12/24] Add site completeness to the screen plan and a /devup entry point Site completeness: draft records the defaults the agent fills in without asking - interaction states from Figma variants or existing tokens only, one shared transition, reduced motion, root and per-page metadata with openGraph, lang, alt text, image sizes, a not-found page - and asks, in the same up-front rounds, only for what needs a file or a fact: favicon and share image (offered from existing files and Figma logo/og nodes in scope, never generated), site name and URL when the project does not say, analytics when it shows signs, and motion beyond hover. The project's layout, .env and package.json, and .devup/screen-plan.config.json (favicon, ogImage, siteName, siteUrl, motion), pre-answer them. status reports favicon, metadata per URL, share image, hover/focus coverage, reduced motion, site name and URL as filled, asked-and-answered or missing, and lists the defaults. devup_ui_validate with projectRoot also checks single-file code for states and warns when the app has no icon. /devup: a new own skill, devup, carries the whole workflow; Claude Code offers it as /devup, and devup_skills install writes an OpenCode command at .opencode/command/devup.md beside it. Other runtimes are reported as unsupported rather than given an unverified path. devup_skills status and the skillGap hint report the command. Rule 2c and the export description now start the workflow on any request to build pages or a site from Figma, /devup or not; the instructions dropped the displayVersion sentence to stay under their byte cap. Co-Authored-By: Claude Opus 5.5 --- .../changepack_log_screen_plan_first.json | 5 +- README.md | 4 + .../src/server/commands/opencode-devup.md | 29 + crates/devup-mcp/src/server/guide.rs | 8 +- crates/devup-mcp/src/server/mod.rs | 59 +- crates/devup-mcp/src/server/screen_plan.rs | 787 +++++++++++++++++- .../devup-mcp/src/server/screen_plan_tests.rs | 259 +++++- crates/devup-mcp/src/server/skills.rs | 188 ++++- .../skills/devup-implementation-plan/SKILL.md | 61 +- .../src/server/skills/devup/SKILL.md | 57 ++ .../devup-mcp/src/server/skills/manifest.json | 22 +- crates/devup-mcp/tests/screen_plan_tool.rs | 52 +- crates/devup-mcp/tests/skills_install.rs | 37 +- 13 files changed, 1539 insertions(+), 29 deletions(-) create mode 100644 crates/devup-mcp/src/server/commands/opencode-devup.md create mode 100644 crates/devup-mcp/src/server/skills/devup/SKILL.md diff --git a/.changepacks/changepack_log_screen_plan_first.json b/.changepacks/changepack_log_screen_plan_first.json index 82ee2c91..5833580d 100644 --- a/.changepacks/changepack_log_screen_plan_first.json +++ b/.changepacks/changepack_log_screen_plan_first.json @@ -1,7 +1,8 @@ { "changes": { - "crates/devup-mcp/Cargo.toml": "Minor" + "crates/devup-mcp/Cargo.toml": "Minor", + "crates/devup-mcp-devup-ui/Cargo.toml": "Minor" }, - "note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen under the linked node is mapped to an evidence-backed URL and confirmed in questions asked up front, never during implementation; keep the plan inside the linked scope and out of trash, archive, draft, copy and hidden areas (listed and confirmed, overridable with excludeKeywords/includeKeywords); ask cross-section and other structural ambiguities explicitly with a fix-the-Figma option whose frames are reported, not built; surface layer-name guesses; honour .devup/screen-plan.config.json; gate page-writing exports on the confirmed plan and refuse excluded or design-fix frames; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill.", + "note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen under the linked node is mapped to an evidence-backed URL and confirmed in questions asked up front, never during implementation; keep the plan inside the linked scope and out of trash, archive, draft, copy and hidden areas (listed and confirmed, overridable with excludeKeywords/includeKeywords); ask cross-section and other structural ambiguities explicitly with a fix-the-Figma option whose frames are reported, not built; surface layer-name guesses; honour .devup/screen-plan.config.json; gate page-writing exports on the confirmed plan and refuse excluded or design-fix frames; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill. Add a site-completeness step: the plan records the defaults the agent fills in (interaction states from existing tokens, one shared transition, reduced motion, metadata, lang, alt, not-found) and asks only for files and facts (favicon, share image, site name and URL, analytics, motion); status reports site completeness and devup_ui_validate warns on stateless controls, pages without metadata and a missing app icon. Add the /devup entry point, installed as a skill (Claude Code) and an OpenCode command.", "date": "2026-10-17T00:00:00+09:00" } diff --git a/README.md b/README.md index f2887bdd..3f0e4cfa 100644 --- a/README.md +++ b/README.md @@ -1064,6 +1064,10 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 같은 이름의 프레임이 서로 다른 Section에 있으면(PC/Mobile처럼 기기 이름 Section 포함) 서버가 정하지 않습니다. Section 이름·프레임 이름·폭·nodeId와 관련 있어 보이는 이유를 적은 질문으로 "Separate pages: the sections are different pages", "One responsive page: the sections only split by device"(모든 Section 이름이 기기 이름일 때만 추천), "The Figma structure is wrong: I will fix it" 중에서 묻습니다. 한 화면이 여러 Section에 나뉜 경우, Section 밖에 홀로 있는 프레임, 한 Section 안의 같은 이름·같은 폭 중복도 같은 "고치겠다" 선택지로 묻습니다. 고치겠다고 한 프레임은 짓지 않고 `status`와 최종 보고의 "Design fixes requested"에 옮기거나 이름을 바꿀 프레임과 Section을 정확히 적습니다. 에이전트는 기다리지 않고 나머지를 계속합니다. export gate는 제외되었거나 디자인 수정 대상인 프레임의 페이지 쓰기를 가정(`assume`)이 있어도 거절합니다. +사이트 완성도: Figma가 그린 것만으로 만든 사이트는 hover·focus·전환·metadata·아이콘이 없어 "죽어" 보입니다. 그래서 계획은 둘로 나눕니다. **에이전트가 묻지 않고 채우는 것**(`defaults`에 기록, 최종 보고에 표시): 모든 링크·버튼·카드·탭·입력의 `_hover`·`_focusVisible`·`_active`·`_disabled`·`cursor`(Figma 상태 variant 우선, 없으면 기존 테마 토큰으로만 만든 작은 대체 — 새 색은 만들지 않음), 한 번만 정의한 약 0.2s `transition`, `_motionReduce`, 루트·페이지별 `metadata`(openGraph 포함, devfive-frontend 규칙), ``, alt, 이미지 크기, not-found 페이지. **같은 사전 질문 라운드에서 묻는 것**: 파비콘·공유(OG) 이미지(기존 파일, 범위 안의 Figma 로고/og 노드, 직접 제공 중에서 — 직접 그리거나 생성하지 않음), 프로젝트에 없을 때만 사이트 이름·운영 URL, 흔적이 있을 때만 분석/인증 태그, hover 이상의 모션("None" 추천). `.devup/screen-plan.config.json`의 `favicon`·`ogImage`·`siteName`·`siteUrl`·`motion`으로 미리 답할 수 있습니다. `status`는 파비콘, URL별 metadata, OG 이미지, hover/focus 적용 비율, reduced-motion을 filled / asked-and-answered / missing으로 보고하고, `devup_ui_validate`는 상태 없는 상호작용 요소(`interactive-states`), transition 없는 `_hover`(`hover-transition`), metadata 없는 page/layout(`page-metadata`), projectRoot에 아이콘이 없는 앱(`app-icon`)을 알립니다. + +명시적 시작점 `/devup [figma url] [notes]`: `devup_skills install`이 `devup` 스킬을 다른 스킬과 함께 쓰고(Claude Code는 프로젝트 스킬을 같은 이름의 슬래시 명령으로 제공하므로 `.claude/skills/devup/SKILL.md`가 곧 `/devup`), OpenCode에는 `.opencode/command/devup.md`도 씁니다. 명령 위치가 검증되지 않은 다른 런타임은 지원하지 않는다고 보고합니다. `devup_skills status`의 `command`가 설치 여부를 알려 줍니다. `/devup` 없이도 Figma로 페이지나 사이트를 만들어 달라는 요청이면 같은 흐름을 시작합니다. + 질문 도구에 호출당 개수 제한이 없으면(OpenCode `question`) 평평한 `questions` 목록을 한 번에 묻고, 제한이 있으면(Claude Code `AskUserQuestion`: 4) `maxQuestionsPerRound`(기본 4)로 나눈 `questionRounds`를 연달아 묻습니다. ## 읽기 전용·개인정보 보호 diff --git a/crates/devup-mcp/src/server/commands/opencode-devup.md b/crates/devup-mcp/src/server/commands/opencode-devup.md new file mode 100644 index 00000000..1ed775f9 --- /dev/null +++ b/crates/devup-mcp/src/server/commands/opencode-devup.md @@ -0,0 +1,29 @@ +--- +description: Build pages or a site from Figma with devup-mcp (screen plan, questions up front, states, metadata, audit, final table) +--- + + +Run the devup workflow for: $ARGUMENTS + +The arguments are a Figma Section, frame or page link (optional while one +Devup Bridge plugin is attached) followed by notes for the plan. + +Follow the `devup` skill step by step - it is installed next to this command - +and the devup-implementation-plan skill for its rules: + +1. `devup_figma_auth` status. +2. `devup_skills` status, then install what is missing, and load the skills. +3. `devup_project_context` with scope theme and scope routes. +4. `devup_screen_plan` draft with projectRoot, the link and the notes. +5. Ask every question now with the `question` tool: the flat `questions` list + in one call. Never in prose, never during implementation. +6. `devup_screen_plan` confirm with every answer that applies. +7. Implement URL by URL without asking anything: `devup_figma_export` with + projectRoot and frameIds, plus the plan's defaults - hover, focus-visible, + active and disabled states from existing tokens, one shared transition, + reduced motion, metadata with openGraph, lang, alt text, image sizes, a + not-found page. Validate with `devup_ui_validate` and projectRoot. Record + anything new with `devup_screen_plan` assume. +8. `devup_runtime_audit` plan at 1440, 1100, 858 and 390, run it, ingest. +9. `devup_screen_plan` status with the audit plan and report. +10. Finish with the status markdownTable and summary. diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index 7706f5b3..a9a28e0b 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -27,8 +27,8 @@ pub const GUIDE_MIME_TYPE: &str = "text/markdown"; /// The original rule numbering is preserved in [`GUIDE`] - it skipped 9 - so a /// reader comparing the two can see nothing was dropped in the move. pub const INSTRUCTIONS: &str = concat!( - "Build identity: identify deployments by server.commit/buildId, not version alone; ", - "server.displayVersion combines version and buildId. Reconnect the MCP server if the expected build differs. ", + "Build identity: identify deployments by server.commit/buildId, not version alone. ", + "Reconnect the MCP server if the expected build differs. ", "server.updateAvailable reports whether a newer release exists; state \"unknown\" means the check is disabled, ", "not yet run, or could not reach the network, and it never blocks a call.\n", "1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n", @@ -39,8 +39,8 @@ pub const INSTRUCTIONS: &str = concat!( "run devup_ui_validate and report wired/tested counts before functional completion.\n", "2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ", "workspace lacks and installs the ones it carries.\n", - "2c. Several pages: devup_screen_plan first; only the linked scope, never trash/archive; ask all its questions ", - "before implementing, never after confirm (skill devup-implementation-plan).\n", + "2c. Building pages or a site from Figma (a Section, several frames): follow skill ", + "devup-implementation-plan even without /devup - devup_screen_plan first, linked scope, all questions up front.\n", "3. Request only outputs you will read. Before your second call, resources/read \"devup://guide/usage\" ", "for output sizing, verification boundaries, SECTION batching, assets and delivery." ); diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 916937ea..13432829 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -934,9 +934,20 @@ impl DevupServer { .filter(|node| !screens_only || node["kind"] == "screen") .filter_map(screen_plan::screen_from_explore) .collect(); + // Nodes beside the screens - a logo, a frame named og - are the + // favicon and share image candidates the site questions offer. + let assets = candidates + .iter() + .map(|candidate| &candidate["node"]) + .filter(|node| screens_only && node["kind"] != "screen") + .filter_map(|node| { + Some(json!({"nodeId": node["nodeId"].as_str()?, "name": node["name"].as_str()?})) + }) + .collect::>(); Ok(( screens, json!({ + "assetCandidates": assets, "from": "devup_figma_explore", "url": input.url, "nodeId": explored["anchor"]["nodeId"], @@ -1680,7 +1691,7 @@ impl DevupServer { } #[tool( - description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. 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). \ + description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs and adds hover/focus states, transitions and metadata. 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. \ @@ -2317,7 +2328,7 @@ impl DevupServer { #[tool( description = "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. `themeNotes` summarizes each encountered empty token category once. \ - 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.", + Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), a _hover with no transition is info (hover-transition), 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.", output_schema = permissive_object_output_schema() )] async fn devup_ui_validate( @@ -2360,6 +2371,29 @@ impl DevupServer { input.strict, ) }; + // Site completeness, for code checked against a project: feedback on + // every interactive element (bundle mode checks it per file), and an + // icon for the app. + if let Some(root) = input.project_root.as_deref() { + if input.files.is_none() { + report.violations.extend( + devup_mcp_devup_ui::ui_validate::site::interaction_findings( + input.source_name.as_deref().unwrap_or("input.tsx"), + &input.tsx, + ), + ); + } + report + .violations + .extend(screen_plan::missing_app_icon(root)); + if input.strict + && report.violations.iter().any(|finding| { + finding.severity == devup_mcp_devup_ui::ui_validate::Severity::Warning + }) + { + report.ok = false; + } + } if input.asset_policies.len() > 4096 { return Err(invalid("assetPolicies exceeds 4096 entries.".into())); } @@ -2581,7 +2615,7 @@ impl DevupServer { Ok(tool_result(result)) } #[tool( - description = "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). 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. The devup-implementation-plan skill (devup_skills) carries the whole workflow.", + description = "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). 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 site defaults it fills in without asking (interaction states from existing tokens, one shared transition, reduced motion, 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 motion beyond hover; .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. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it.", output_schema = permissive_object_output_schema() )] async fn devup_screen_plan( @@ -2591,7 +2625,16 @@ impl DevupServer { let result = match input.action.as_str() { "draft" => { let (screens, source) = self.plan_screens(&input).await.map_err(to_mcp_error)?; - screen_plan::draft(&input, &screens, source, &self.output_policy).await + let mut assets = input.assets.clone(); + assets.extend( + source["assetCandidates"] + .as_array() + .into_iter() + .flatten() + .filter_map(|asset| serde_json::from_value(asset.clone()).ok()), + ); + screen_plan::draft_with(&input, &screens, &assets, source, &self.output_policy) + .await } "confirm" => screen_plan::confirm(&input, &self.output_policy), "assume" => screen_plan::assume(&input, &self.output_policy), @@ -2799,9 +2842,17 @@ fn skill_gap<'a>( if missing.is_empty() && outdated.is_empty() { return None; } + // The same install writes /devup, the one word that starts the whole + // workflow; say so where the agent is already being told to install. + let command = skills::command_state(lookup); Some(json!({ "missing": missing, "outdated": outdated, + "command": if command["installed"] == true { Value::Null } else { json!({ + "invocation": command["invocation"], + "supported": command["supported"], + "how": command["how"], + }) }, "why": why, "how": "Call devup_skills with action \"install\" and this same projectRoot, then load \ the skills the way your runtime loads a project skill. Before writing the code, \ diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index f042830a..4005440e 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -145,6 +145,10 @@ pub struct ScreenPlanInput { #[serde(default)] #[schemars(range(min = 1, max = 50))] pub max_questions_per_round: Option, + /// draft: Figma nodes that may be the favicon or share image (a logo, a + /// frame named og or share), beside the screens. + #[serde(default)] + pub assets: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -182,6 +186,19 @@ pub(super) struct PlanConfig { /// Names containing one of these are never excluded by keyword. #[serde(default)] pub include_keywords: Option>, + /// Pre-answers for the site questions: the favicon and share image (a + /// path, or figma:), the site name, its production URL, and + /// motion beyond hover (none | reveal). + #[serde(default)] + pub favicon: Option, + #[serde(default)] + pub og_image: Option, + #[serde(default)] + pub site_name: Option, + #[serde(default)] + pub site_url: Option, + #[serde(default)] + pub motion: Option, } fn read_config(project_root: &str) -> Result, DevupError> { @@ -325,6 +342,19 @@ pub(super) struct ProjectEvidence { pub config: PlanConfig, /// The config file, when one was read. pub config_file: Option, + /// The project root the relative paths here are under. + pub root: PathBuf, + /// Route pages and the components they render, relative to `root`. + pub sources: Vec, + pub icon_files: Vec, + pub og_files: Vec, + /// (value, where it was found). + pub site_name: Option<(String, String)>, + pub site_url: Option<(String, String)>, + pub package_name: Option, + pub analytics: Vec, + pub not_found: Option, + pub lang: Option<(String, String)>, } /// The `data-figma-node-id="..."` values in one source file. @@ -357,7 +387,9 @@ pub(super) async fn project_evidence(project_root: &str) -> Result Result>::new(); for route in array(&inventory["routes"]) { @@ -457,6 +490,13 @@ pub(super) async fn project_evidence(project_root: &str) -> Result Vec { found } +/// Layer names that mark a Figma node as the site's logo or icon, and as its +/// share image. Frames named like this are site assets, not pages. +const LOGO_WORDS: &[&str] = &[ + "logo", + "symbol", + "favicon", + "appicon", + "로고", + "심볼", + "파비콘", +]; +/// Narrow on purpose: a bare "share" or "thumbnail" names real UI - a share +/// sheet, a video thumbnail - and those are screens. +const OG_WORDS: &[&str] = &[ + "og", + "opengraph", + "open graph", + "share image", + "share card", + "social image", + "공유 이미지", + "대표 이미지", +]; +/// Package names that say a project already measures or verifies something. +const ANALYTICS_PACKAGES: &[&str] = &[ + "@vercel/analytics", + "@next/third-parties", + "react-ga4", + "react-gtm-module", + "@amplitude/analytics-browser", + "mixpanel-browser", + "posthog-js", +]; +const ANALYTICS_ENV: &[&str] = &[ + "GA_", + "GTM", + "GOOGLE_ANALYTICS", + "GOOGLE_SITE_VERIFICATION", + "NAVER_SITE_VERIFICATION", +]; + +fn asset_kind(name: &str) -> Option<&'static str> { + if LOGO_WORDS.iter().any(|word| keyword_in(name, word)) || keyword_in(name, "app icon") { + Some("logo") + } else if OG_WORDS.iter().any(|word| keyword_in(name, word)) { + Some("og") + } else { + None + } +} + +/// A Figma node that may become the favicon or the share image. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanAssetInput { + pub node_id: String, + pub name: String, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +/// Files under `directory` (relative to `root`) whose names start with one of +/// `prefixes`, as relative paths. +fn files_named(root: &Path, directory: &str, prefixes: &[&str]) -> Vec { + let Ok(entries) = std::fs::read_dir(join_relative(root, directory)) else { + return Vec::new(); + }; + let mut found = entries + .flatten() + .filter(|entry| entry.path().is_file()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| { + let lowered = name.to_lowercase(); + prefixes.iter().any(|prefix| lowered.starts_with(prefix)) + }) + .map(|name| format!("{directory}/{name}")) + .collect::>(); + found.sort(); + found +} + +fn join_relative(root: &Path, relative: &str) -> PathBuf { + relative + .split('/') + .filter(|part| !part.is_empty()) + .fold(root.to_path_buf(), |path, part| path.join(part)) +} + +fn read_small(path: &Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if metadata.len() > MAX_SOURCE_BYTES { + return None; + } + std::fs::read_to_string(path).ok() +} + +/// The quoted value after `marker` in `source`, e.g. the URL in +/// `metadataBase: new URL("...")`. +fn quoted_after(source: &str, marker: &str) -> Option { + let rest = &source[source.find(marker)? + marker.len()..]; + let quote_at = rest.find(['"', '\'', '`'])?; + let quote = rest[quote_at..].chars().next()?; + let value = &rest[quote_at + 1..]; + let end = value.find(quote)?; + Some(value[..end].to_owned()).filter(|value| !value.is_empty()) +} + +/// What the project already says about the site as a whole: its icon, share +/// image, name, URL, analytics, language and not-found page. +fn read_site_facts(root: &Path, evidence: &mut ProjectEvidence) { + let app = evidence.app_dir.clone(); + evidence.icon_files = [ + files_named(root, &app, &["icon.", "favicon.", "apple-icon."]), + files_named(root, "public", &["favicon", "icon.", "apple-touch-icon"]), + ] + .concat(); + evidence.og_files = [ + files_named(root, &app, &["opengraph-image.", "twitter-image."]), + files_named(root, "public", &["og", "share", "opengraph"]), + ] + .concat(); + evidence.not_found = ["tsx", "jsx", "js"] + .iter() + .map(|ext| format!("{app}/not-found.{ext}")) + .find(|path| join_relative(root, path).is_file()); + for ext in ["tsx", "jsx", "js"] { + let layout = format!("{app}/layout.{ext}"); + let Some(source) = read_small(&join_relative(root, &layout)) else { + continue; + }; + evidence.lang = quoted_after(&source, "(&text).ok()) + { + evidence.package_name = package["name"].as_str().map(str::to_owned); + for section in ["dependencies", "devDependencies"] { + for name in ANALYTICS_PACKAGES { + if package[section].get(*name).is_some() { + evidence.analytics.push(format!("{name} in package.json")); + } + } + } + } +} + +/// What the agent fills in by itself, without a question. Recorded in the +/// plan's `defaults` and the final report, so nothing it added is a surprise. +fn site_defaults(evidence: &ProjectEvidence, screens: &[PlanScreenInput]) -> Vec { + let lang = evidence.lang.clone().unwrap_or_else(|| { + let korean = screens + .iter() + .any(|screen| screen.name.chars().any(|c| ('가'..='힣').contains(&c))); + if korean { + ( + "ko".into(), + "Korean text in the design's layer names".into(), + ) + } else { + ( + "en".into(), + "no Korean text in the design's layer names".into(), + ) + } + }); + let mut defaults = vec![ + json!({"item": "interaction-states", + "decision": "Every link, button, linked card, tab and input gets _hover, _focusVisible (a visible focus ring), _active, _disabled where it applies, and cursor=\"pointer\". Figma hover/pressed/focus variants are used when they exist; otherwise a small consistent fallback from existing theme tokens only - a darker or lighter shade, an underline, a slight lift or shadow. No new colours: with no suitable token, record an assumption.", + "evidence": "devfive-frontend critical-rules 4 (interactive states); devup-ui pseudo-selectors"}), + json!({"item": "transition", + "decision": "One shared transition of about 0.2s ease on the properties that change (colour, background, border, shadow, transform), defined once as a token or shared constant and not per element.", + "evidence": "devfive-frontend critical-rules 4 (transition all 0.2s ease)"}), + json!({"item": "reduced-motion", + "decision": "_motionReduce removes transforms and transitions (or one globalCss rule does), so prefers-reduced-motion gets no movement.", + "evidence": "devup-ui _motionReduce"}), + json!({"item": "metadata", + "decision": "Root layout metadata with a title template, description and metadataBase when the site URL is known; every page exports title, description and openGraph taken from its Figma heading and lead text.", + "evidence": "devfive-frontend critical-rules 2 (SEO metadata with openGraph)"}), + json!({"item": "lang", "decision": format!("", lang.0), "evidence": lang.1}), + json!({"item": "alt-text", "decision": "alt text from the Figma layer name or its text content on every image.", + "evidence": "devfive-frontend critical-rules 3 (image alt required)"}), + json!({"item": "image-dimensions", "decision": "width and height on every image, so nothing shifts as it loads.", + "evidence": "no layout shift"}), + ]; + defaults.push(match &evidence.not_found { + Some(path) => { + json!({"item": "not-found", "decision": format!("Keep {path}."), "evidence": path}) + } + None => json!({"item": "not-found", + "decision": format!("Add {}/not-found.tsx in the site's own style.", evidence.app_dir), + "evidence": "no not-found page in the project"}), + }); + defaults +} + +/// One site fact that needs a file or a fact only the user has. +fn site_question( + field: &str, + id: &str, + header: &str, + prompt: String, + choices: Vec, +) -> Value { + let mut asked = question(id.into(), header.into(), prompt, choices, "site"); + asked["field"] = json!(field); + asked +} + +/// The site-level questions and the state of each fact, asked in the same +/// up-front rounds as everything else. Never asked when the project already +/// says it, or the project config pre-answers it. +fn site_questions( + evidence: &ProjectEvidence, + assets: &[(String, String, &'static str)], + cover: Option<(&str, &str)>, +) -> (Value, Vec) { + let config = &evidence.config; + let mut site = serde_json::Map::new(); + let mut questions = Vec::new(); + let mut file_question = |field: &str, + id: &str, + header: &str, + what: &str, + files: &[String], + kind: &'static str| { + let mut choices = Vec::new(); + for file in files.iter().take(2) { + choices.push(choice( + format!("Use {file}"), + "Already in the project.", + json!({field: {"source": "file", "path": file}}), + )); + } + let (format, target) = if kind == "logo" { + ("svg", format!("{}/icon.svg", evidence.app_dir)) + } else { + ("png", format!("{}/opengraph-image.png", evidence.app_dir)) + }; + for (node, name, _) in assets.iter().filter(|(_, _, found)| *found == kind).take(2) { + choices.push(choice( + format!("Export \"{}\" ({node}) as {}", truncate(name, 30), format.to_uppercase()), + format!("A Figma node in the linked scope; exported with devup_figma_export assetRequests (format {format}) to {target}."), + json!({field: {"source": "figma", "nodeId": node, "format": format, "path": target}}), + )); + } + if kind == "og" + && let Some((node, name)) = cover + { + choices.push(choice( + format!("Use the top of \"{}\" ({node})", truncate(name, 24)), + format!("Export the first screen as PNG and crop it to 1200x630 for {target}."), + json!({field: {"source": "figma-cover", "nodeId": node, "format": "png", "path": target}}), + )); + } + choices.truncate(MAX_OPTIONS - 2); + choices.push(choice( + "I will provide a file", + "Answer with its path, e.g. public/favicon.ico. Nothing is generated or drawn.", + json!({field: {"source": "provide"}}), + )); + choices.push(choice( + "Leave it for later", + "Not added now; status reports it missing.", + json!({field: {"source": "later"}}), + )); + let evidence_text = + if files.is_empty() && !assets.iter().any(|(_, _, found)| *found == kind) { + "Nothing in the project or the linked Figma scope looks like one.".to_owned() + } else { + "The options come from the project's files and the linked Figma scope.".to_owned() + }; + questions.push(site_question( + field, + id, + header, + format!("Which file should be the site's {what}? {evidence_text}"), + choices, + )); + }; + match &config.favicon { + Some(value) => { + site.insert( + "favicon".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "favicon".into(), + json!({"state": "asked", "question": "site:favicon", "found": evidence.icon_files}), + ); + file_question( + "favicon", + "site:favicon", + "Favicon / app icon", + "favicon and app icon", + &evidence.icon_files, + "logo", + ); + } + } + match &config.og_image { + Some(value) => { + site.insert( + "ogImage".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "ogImage".into(), + json!({"state": "asked", "question": "site:og", "found": evidence.og_files}), + ); + file_question( + "ogImage", + "site:og", + "Share (OG) image", + "share image (Open Graph)", + &evidence.og_files, + "og", + ); + } + } + match (&config.site_name, &evidence.site_name) { + (Some(value), _) => { + site.insert( + "siteName".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + (None, Some((value, from))) => { + site.insert( + "siteName".into(), + json!({"state": "found", "value": value, "evidence": from}), + ); + } + (None, None) => { + site.insert( + "siteName".into(), + json!({"state": "asked", "question": "site:name"}), + ); + let mut choices = Vec::new(); + if let Some(package) = &evidence.package_name { + choices.push(choice( + package.as_str(), + "The package.json name; it may not be the name people see.", + json!({"siteName": {"source": "package", "value": package}}), + )); + } + choices.push(choice( + "Use the main page's heading", + "Taken from the first page's heading text in Figma while building.", + json!({"siteName": {"source": "heading"}}), + )); + choices.push(choice( + "Leave it for later", + "Titles go without a site suffix until it is known.", + json!({"siteName": {"source": "later"}}), + )); + questions.push(site_question( + "siteName", + "site:name", + "Site name", + "What is the site's name, for the title template (\"Page | Name\")? It is not in the project's metadata or environment. A custom answer is the name itself.".into(), + choices, + )); + } + } + match (&config.site_url, &evidence.site_url) { + (Some(value), _) => { + site.insert( + "siteUrl".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + (None, Some((value, from))) => { + site.insert( + "siteUrl".into(), + json!({"state": "found", "value": value, "evidence": from}), + ); + } + (None, None) => { + site.insert( + "siteUrl".into(), + json!({"state": "asked", "question": "site:url"}), + ); + questions.push(site_question( + "siteUrl", + "site:url", + "Production URL", + "What is the production URL, for metadataBase and absolute share links? It is not in the project's metadata or .env files. A custom answer is the URL itself.".into(), + vec![ + choice( + "No production URL yet", + "metadataBase is left out; share images use relative URLs until it is set.", + json!({"siteUrl": {"source": "none"}}), + ), + choice( + "Read it from NEXT_PUBLIC_SITE_URL", + "metadataBase comes from that environment variable, set per deployment.", + json!({"siteUrl": {"source": "env", "name": "NEXT_PUBLIC_SITE_URL"}}), + ), + ], + )); + } + } + if !evidence.analytics.is_empty() { + site.insert( + "analytics".into(), + json!({"state": "asked", "question": "site:analytics", "found": evidence.analytics}), + ); + questions.push(site_question( + "analytics", + "site:analytics", + "Analytics / verification", + format!( + "The project shows analytics or verification setup: {}. Wire it into the new root layout?", + evidence.analytics.join(", ") + ), + vec![ + choice("Keep the existing setup", "Carried into the new layout as it is configured.", json!({"analytics": {"source": "keep"}})), + choice("Leave it out", "The new layout has no analytics or verification tags.", json!({"analytics": {"source": "none"}})), + ], + )); + } else { + site.insert("analytics".into(), json!({"state": "not-needed", "evidence": "no analytics or verification signs in the project"})); + } + match &config.motion { + Some(value) => { + site.insert( + "motion".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "motion".into(), + json!({"state": "asked", "question": "site:motion"}), + ); + questions.push(site_question( + "motion", + "site:motion", + "Motion beyond hover", + "Beyond hover and focus transitions, should sections animate? Nothing in the plan shows designed motion.".into(), + vec![ + choice("None", "Recommended unless the design shows motion; hover and focus transitions are still added.", json!({"motion": {"source": "none"}})), + choice("Subtle reveal on scroll", "Sections fade and slide in once as they enter; off under prefers-reduced-motion.", json!({"motion": {"source": "reveal"}})), + ], + )); + } + } + (Value::Object(site), questions) +} + +/// The site completeness block of `status`: each item filled, asked and +/// answered, or missing, read from the files as they are now. +fn site_completeness(plan: &Value, evidence: &ProjectEvidence) -> Vec { + let site = &plan["site"]; + let decided = |field: &str| { + let entry = &site[field]; + let source = entry["answer"][field]["source"] + .as_str() + .or_else(|| entry["answer"]["source"].as_str()); + entry["state"] == "config" || (entry["state"] == "answered" && source != Some("later")) + }; + let file_item = |item: &str, field: &str, files: &[String]| { + if !files.is_empty() { + json!({"item": item, "state": "filled", "detail": files.join(", ")}) + } else if decided(field) { + json!({"item": item, "state": "asked-and-answered", "detail": "decided, but no file is in place yet"}) + } else { + json!({"item": item, "state": "missing", "detail": "no file, and no answer that provides one"}) + } + }; + let mut items = vec![ + file_item("favicon", "favicon", &evidence.icon_files), + file_item("og-image", "ogImage", &evidence.og_files), + ]; + for row in array(&plan["routes"]) + .iter() + .filter(|row| row["implementation"] == "page") + { + let url = text(&row["url"]); + let page = evidence + .routes + .iter() + .find(|(pattern, _)| pattern == url) + .map(|(_, page)| page.clone()); + let state = match &page { + Some(page) => match read_small(&join_relative(&evidence.root, page)) { + Some(source) + if devup_mcp_devup_ui::ui_validate::site::exports_metadata(&source) => + { + "filled" + } + _ => "missing", + }, + None => "missing", + }; + items.push(json!({"item": format!("metadata {url}"), "state": state, + "detail": page.unwrap_or_else(|| "page not implemented".into())})); + } + let mut interactive = 0; + let mut with_states = 0; + let mut motion = false; + let mut reduced = false; + for relative in &evidence.sources { + let Some(source) = read_small(&join_relative(&evidence.root, relative)) else { + continue; + }; + let coverage = + devup_mcp_devup_ui::ui_validate::site::interaction_coverage(relative, &source); + interactive += coverage.interactive; + with_states += coverage.with_states; + motion |= source.contains("transition") + || source.contains("transform") + || source.contains("animation"); + reduced |= source.contains("_motionReduce") || source.contains("prefers-reduced-motion"); + } + items.push(json!({"item": "hover-focus", + "state": if interactive == 0 { "not-applicable" } else if with_states == interactive { "filled" } else { "missing" }, + "detail": format!("{with_states} of {interactive} interactive elements have hover or focus states")})); + items.push(json!({"item": "reduced-motion", + "state": if reduced { "filled" } else if motion { "missing" } else { "not-applicable" }, + "detail": if reduced { "prefers-reduced-motion is handled" } else if motion { "transitions or transforms with no prefers-reduced-motion handling" } else { "no motion in the sources" }})); + for (item, field) in [("site-name", "siteName"), ("site-url", "siteUrl")] { + let entry = &site[field]; + let state = match entry["state"].as_str() { + Some("found" | "config") => "filled", + Some("answered") if decided(field) => "asked-and-answered", + _ => "missing", + }; + items.push(json!({"item": item, "state": state, "detail": entry.get("value").or(entry.get("answer")).cloned()})); + } + items +} + /// Names that mark a page, section or frame as not for implementation. Words /// in ASCII match whole words; the rest match anywhere in the name. const EXCLUDE_WORDS: &[&str] = &[ @@ -1350,12 +1974,26 @@ const REVIEW: &str = "Review individually"; /// Builds the draft plan. Pure: every input is passed in, so the rules are /// tested without Figma or a project on disk. +#[cfg(test)] pub(super) fn draft_plan( screens: &[PlanScreenInput], targets: &BTreeMap, evidence: &ProjectEvidence, source: Value, notes: Option<&str>, +) -> Result { + draft_plan_with(screens, &[], targets, evidence, source, notes) +} + +/// [`draft_plan`] with Figma nodes that may become the favicon or share +/// image. +pub(super) fn draft_plan_with( + screens: &[PlanScreenInput], + assets: &[PlanAssetInput], + targets: &BTreeMap, + evidence: &ProjectEvidence, + source: Value, + notes: Option<&str>, ) -> Result { if screens.is_empty() { return Err(invalid( @@ -1539,6 +2177,34 @@ pub(super) fn draft_plan( "reason": reason}) }) .collect::>(); + // A frame named like a logo or a share image is a site asset, not a page. + let mut site_assets = assets + .iter() + .filter_map(|asset| { + asset_kind(&asset.name).map(|kind| (asset.node_id.clone(), asset.name.clone(), kind)) + }) + .collect::>(); + kept.retain(|screen| match asset_kind(&screen.name) { + Some(kind) => { + site_assets.push((screen.node_id.clone(), screen.name.clone(), kind)); + false + } + None => true, + }); + let defaults = site_defaults(evidence, &kept); + let cover = kept + .first() + .map(|screen| (screen.node_id.clone(), screen.name.clone())); + let (site, site_asked) = site_questions( + evidence, + &site_assets, + cover + .as_ref() + .map(|(node, name)| (node.as_str(), name.as_str())), + ); + for asked in site_asked { + questions.push(with_when(asked, &base_when)); + } let mut drafts = group_screens(&kept, &evidence.config); for draft in &mut drafts { draft.extra_when.extend(base_when.iter().cloned()); @@ -2333,6 +2999,9 @@ pub(super) fn draft_plan( "heuristics": heuristics, "excluded": excluded_rows, "outOfScope": out_of_scope, + "site": site, + "defaults": defaults, + "siteAssets": site_assets.iter().map(|(node, name, kind)| json!({"nodeId": node, "name": name, "kind": kind})).collect::>(), "questions": body["questions"], "answers": {}, "routes": [], @@ -2451,16 +3120,30 @@ fn require_root(input: &ScreenPlanInput) -> Result<&str, DevupError> { } /// `draft`: plan the given screens against the project and write both files. +#[cfg(test)] pub(super) async fn draft( input: &ScreenPlanInput, screens: &[PlanScreenInput], source: Value, policy: &OutputPolicy, +) -> Result { + draft_with(input, screens, &input.assets, source, policy).await +} + +/// [`draft`] with the Figma asset candidates an explore found beside the +/// screens. +pub(super) async fn draft_with( + input: &ScreenPlanInput, + screens: &[PlanScreenInput], + assets: &[PlanAssetInput], + source: Value, + policy: &OutputPolicy, ) -> Result { let root = require_root(input)?; let evidence = project_evidence(root).await?; - let mut plan = draft_plan( + let mut plan = draft_plan_with( screens, + assets, &input.navigation_targets, &evidence, source, @@ -2495,6 +3178,8 @@ pub(super) async fn draft( "questionCount": questions.len(), "askInstruction": ASK_INSTRUCTION, "maxQuestionsPerRound": per_round, + "site": plan["site"], + "defaults": plan["defaults"], "heuristics": plan["heuristics"], "excluded": plan["excluded"], "outOfScope": plan["outOfScope"], @@ -2577,6 +3262,34 @@ fn resolve_answer( json!({"link": normalize_route(label)}), Some(label.to_owned()), )), + // A custom site answer is the file, name or URL itself. + "site" => { + let field = text(&question["field"]); + match field { + "favicon" | "ogImage" => Ok(( + json!({field: {"source": "provide", "path": label}}), + Some(label.to_owned()), + )), + "siteName" => Ok(( + json!({field: {"source": "answer", "value": label}}), + Some(label.to_owned()), + )), + "siteUrl" if label.starts_with("https://") || label.starts_with("http://") => Ok(( + json!({field: {"source": "answer", "value": label}}), + Some(label.to_owned()), + )), + "siteUrl" => Err(invalid(format!( + "{id}: a custom answer must be an http(s) URL, not {label:?}." + ))), + _ => Ok(( + array(&question["resolve"]) + .first() + .map(|option| option["action"].clone()) + .unwrap_or(Value::Null), + Some(label.to_owned()), + )), + } + } _ => Ok(( array(&question["resolve"]) .first() @@ -2880,6 +3593,16 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< .map(|(id, action)| (id.clone(), action["layout"].clone())) .collect::>() ); + for asked in questions + .iter() + .filter(|asked| text(&asked["id"]).starts_with("site:")) + { + let field = text(&asked["field"]).to_owned(); + if let Some(action) = actions.get(text(&asked["id"])) { + plan["site"][&field]["state"] = json!("answered"); + plan["site"][&field]["answer"] = action[&field].clone(); + } + } plan["routes"] = json!(routes); plan["excluded"] = json!(excluded); plan["designFixes"] = json!(design_fixes); @@ -2896,6 +3619,8 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< "markdownTable": route_markdown(&routes), "excluded": excluded, "designFixes": design_fixes, + "site": plan["site"], + "defaults": plan["defaults"], "customAnswers": custom, "rule": IMPLEMENT_INSTRUCTION, "writtenTo": written, @@ -3343,6 +4068,23 @@ pub(super) fn status_report( cell(&json!(shots)), )); } + let completeness = site_completeness(plan, evidence); + let incomplete = completeness + .iter() + .filter(|item| item["state"] == "missing") + .count(); + if incomplete > 0 { + summary.push_str(&format!("; site completeness: {incomplete} missing")); + } + table.push_str("\nSite completeness:\n"); + for item in &completeness { + table.push_str(&format!( + "- {}: {} ({})\n", + text(&item["item"]), + text(&item["state"]), + cell(&item["detail"]) + )); + } table.push_str(&trailing_lists(plan)); json!({ "status": "confirmed", @@ -3354,6 +4096,8 @@ pub(super) fn status_report( "audit": audit.map(|audit| json!({"verdict": audit["verdict"], "planId": audit["planId"], "reason": audit["reason"]})), "markdownTable": table, "assumptions": assumptions, + "siteCompleteness": completeness, + "defaults": plan["defaults"], "designFixes": design_fixes, "excluded": excluded, "inventoryNote": evidence.inventory_note, @@ -3366,6 +4110,17 @@ pub(super) fn status_report( /// file and `status`, so the two never disagree about what was not built. fn trailing_lists(plan: &Value) -> String { let mut out = String::new(); + let defaults = array(&plan["defaults"]); + if !defaults.is_empty() { + out.push_str("\nFilled in by default (no question):\n"); + for default in defaults { + out.push_str(&format!( + "- {}: {}\n", + text(&default["item"]), + text(&default["decision"]) + )); + } + } let fixes = array(&plan["designFixes"]); if !fixes.is_empty() { out.push_str("\nDesign fixes requested (not built):\n"); @@ -3406,6 +4161,36 @@ fn trailing_lists(plan: &Value) -> String { out } +/// `app-icon` for `devup_ui_validate` with a projectRoot: an App Router app +/// with no icon or favicon. Read from disk here because the devup-ui checks +/// open no file. +pub(super) fn missing_app_icon( + project_root: &str, +) -> Option { + let root = Path::new(project_root); + let app = ["src/app", "app"] + .into_iter() + .find(|app| join_relative(root, app).is_dir())?; + let mut evidence = ProjectEvidence { + app_dir: app.to_owned(), + ..ProjectEvidence::default() + }; + read_site_facts(root, &mut evidence); + if !evidence.icon_files.is_empty() { + return None; + } + let mut context = serde_json::Map::new(); + context.insert("appDir".into(), json!(app)); + Some(devup_mcp_devup_ui::ui_validate::Violation { + rule: "app-icon", + severity: devup_mcp_devup_ui::ui_validate::Severity::Warning, + byte_range: [0, 0], + context, + message: format!("{app} has no icon.* or favicon, and public/ has no favicon either, so the browser tab shows a blank icon."), + suggestion: Some("Add app/icon.svg (or favicon.ico) from a file the user provides or a Figma logo node exported as SVG; devup_screen_plan asks which. Never draw one.".into()), + }) +} + /// What the export gate decided. pub(super) enum Gate { /// Not a page write, or no plan applies. diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index a4be3f58..0c966151 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -15,6 +15,7 @@ impl Fixture { std::fs::create_dir_all(&root).unwrap(); let fixture = Self(dunce::canonicalize(&root).unwrap()); fixture.write("package.json", "{}"); + fixture.write(CONFIG_FILE, SITE_CONFIG); fixture.write( "src/app/layout.tsx", "import { Header } from '../components/Header';\nexport default function RootLayout({ children }) { return
{children}; }", @@ -79,10 +80,27 @@ fn evidence() -> ProjectEvidence { )], layout_parts: vec!["Header".into()], app_dir: "src/app".into(), + // The site questions are pre-answered here so each test below is + // about its own questions; `site_*` tests start from bare evidence. + config: answered_site(), ..ProjectEvidence::default() } } +fn answered_site() -> PlanConfig { + PlanConfig { + favicon: Some("app/icon.svg".into()), + og_image: Some("public/og.png".into()), + site_name: Some("Prime".into()), + site_url: Some("https://example.com".into()), + motion: Some("none".into()), + ..PlanConfig::default() + } +} + +/// The site config the fixtures write, matching [`answered_site`]. +const SITE_CONFIG: &str = "{\"favicon\": \"app/icon.svg\", \"ogImage\": \"public/og.png\", \"siteName\": \"Prime\", \"siteUrl\": \"https://example.com\", \"motion\": \"none\"}"; + fn sectioned(node_id: &str, name: &str, width: f64, section: &str) -> PlanScreenInput { PlanScreenInput { section: Some(format!("section-{section}")), @@ -458,7 +476,7 @@ fn project_config_counts_as_evidence_and_suppresses_its_questions() { modal_keywords: Some(vec!["sheet".into()]), route_aliases: BTreeMap::from([("요금제".to_owned(), "/pricing".to_owned())]), width_grouping: Some("responsive".into()), - ..PlanConfig::default() + ..answered_site() }; let plan = draft_plan( &[ @@ -621,9 +639,12 @@ fn status_marks_widths_without_an_audit_item_not_run() { 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!( - report["summary"], - "2 URLs: 1 implemented, 0 verified at all widths, /about overflows at 390" + assert!( + text(&report["summary"]).starts_with( + "2 URLs: 1 implemented, 0 verified at all widths, /about overflows at 390" + ), + "{}", + report["summary"] ); assert_eq!(report["states"][0]["hostUrl"], "/"); assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); @@ -891,7 +912,7 @@ async fn assumptions_are_recorded_after_confirm_and_reported_by_status() { report["assumptions"][0]["decision"], "Not built: outside the confirmed screen plan." ); - assert!(text(&report["summary"]).ends_with("; 1 assumption made during implementation")); + assert!(text(&report["summary"]).contains("; 1 assumption made during implementation")); assert!(text(&report["markdownTable"]).contains("- 13:9: Not built")); let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); assert!(markdown.contains("Assumptions made during implementation:")); @@ -914,7 +935,7 @@ async fn an_invalid_config_is_refused_rather_than_ignored() { assert!(error.message.contains("widthGrouping")); fixture.write( CONFIG_FILE, - r#"{"routeAliases": {"메인": "/"}, "widthGrouping": "ask"}"#, + r#"{"routeAliases": {"메인": "/"}, "widthGrouping": "ask", "siteName": "Prime"}"#, ); let evidence = project_evidence(fixture.root()).await.unwrap(); assert_eq!(evidence.config_file.as_deref(), Some(CONFIG_FILE)); @@ -1022,6 +1043,7 @@ fn trash_and_archive_frames_are_excluded_and_asked_about_once() { fn the_config_can_change_and_override_the_exclusion_words() { let mut evidence = evidence(); evidence.config.exclude_keywords = Some(vec!["legacy".into()]); + evidence.config.include_keywords = None; evidence.config.include_keywords = Some(vec!["test drive".into()]); let plan = draft_plan( &[ @@ -1177,3 +1199,228 @@ async fn the_gate_refuses_an_excluded_frame_even_with_an_assumption() { assert_eq!(response["status"], "refused"); assert!(text(&response["refused"][0]["reason"]).starts_with("excluded")); } + +fn bare_site() -> ProjectEvidence { + ProjectEvidence { + config: PlanConfig::default(), + ..evidence() + } +} + +fn labels(question: &Value) -> Vec<&str> { + array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect() +} + +/// The favicon and share image are files only the user can pick, so they +/// are asked - with the candidates the project and the linked Figma scope +/// actually hold, and never a generated one. +#[test] +fn site_files_are_asked_with_evidence_backed_candidates() { + let mut evidence = bare_site(); + evidence.icon_files = vec!["src/app/icon.svg".into()]; + evidence.package_name = Some("prime-web".into()); + let plan = draft_plan_with( + &[ + screen("30:3", "Home", 1440.0), + screen("30:2", "OG image", 1200.0), + ], + &[PlanAssetInput { + node_id: "30:1".into(), + name: "Logo".into(), + ..PlanAssetInput::default() + }], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let favicon = find(&plan, "site:favicon"); + assert_eq!( + labels(favicon), + [ + "Use src/app/icon.svg (Recommended)", + "Export \"Logo\" (30:1) as SVG", + "I will provide a file", + "Leave it for later" + ] + ); + assert!(text(&favicon["options"][2]["description"]).contains("Nothing is generated or drawn")); + let og = find(&plan, "site:og"); + assert_eq!( + labels(og)[..2], + [ + "Export \"OG image\" (30:2) as PNG (Recommended)", + "Use the top of \"Home\" (30:3)" + ] + ); + // A frame named like a share image is an asset, not a page. + assert_eq!(array(&plan["screens"]).len(), 1); + assert_eq!( + labels(find(&plan, "site:name"))[0], + "prime-web (Recommended)" + ); + assert_eq!(labels(find(&plan, "site:motion"))[0], "None (Recommended)"); + assert!(!question_ids(&plan).contains(&"site:analytics".to_owned())); + let defaults = array(&plan["defaults"]) + .iter() + .map(|default| text(&default["item"])) + .collect::>(); + for item in [ + "interaction-states", + "transition", + "reduced-motion", + "metadata", + "lang", + "alt-text", + "not-found", + "image-dimensions", + ] { + assert!(defaults.contains(&item), "{item} missing from {defaults:?}"); + } + assert_eq!(plan["site"]["favicon"]["state"], "asked"); + assert_rounds_cover(&plan); +} + +#[test] +fn the_config_and_the_project_pre_answer_the_site_questions() { + let plan = draft_plan( + &[screen("31:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + assert!( + !question_ids(&plan).iter().any(|id| id.starts_with("site:")), + "{:?}", + question_ids(&plan) + ); + assert_eq!(plan["site"]["siteName"]["state"], "config"); + + let mut found = bare_site(); + found.site_name = Some(( + "Prime".into(), + "title template in src/app/layout.tsx".into(), + )); + found.site_url = Some(( + "https://prime.example".into(), + "NEXT_PUBLIC_SITE_URL in .env".into(), + )); + found.analytics = vec!["@vercel/analytics in package.json".into()]; + let plan = draft_plan( + &[screen("31:1", "Home", 1440.0)], + &BTreeMap::new(), + &found, + json!({}), + None, + ) + .unwrap(); + let ids = question_ids(&plan); + assert!( + !ids.contains(&"site:name".to_owned()) && !ids.contains(&"site:url".to_owned()), + "{ids:?}" + ); + assert_eq!( + plan["site"]["siteUrl"]["evidence"], + "NEXT_PUBLIC_SITE_URL in .env" + ); + let analytics = find(&plan, "site:analytics"); + assert!(text(&analytics["question"]).contains("@vercel/analytics")); +} + +#[tokio::test] +async fn project_evidence_reads_the_site_facts() { + let fixture = Fixture::new(); + fixture.write("public/favicon.ico", "ico"); + fixture.write( + ".env", + "NEXT_PUBLIC_SITE_URL=https://prime.example\nNEXT_PUBLIC_GA_ID=G-1\n", + ); + fixture.write( + "src/app/layout.tsx", + "export const metadata = { title: { template: '%s | Prime' } };\nexport default function RootLayout({ children }) { return {children}; }", + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + assert_eq!(evidence.icon_files, ["public/favicon.ico"]); + assert_eq!( + evidence.site_url.as_ref().unwrap().0, + "https://prime.example" + ); + assert_eq!(evidence.site_name.as_ref().unwrap().0, "Prime"); + assert_eq!(evidence.lang.as_ref().unwrap().0, "ko"); + assert!( + evidence + .analytics + .iter() + .any(|sign| sign.contains("NEXT_PUBLIC_GA_ID")) + ); + assert!(evidence.not_found.is_none()); +} + +/// Status says, per item, whether it is filled, decided but not in place, +/// or missing - read from the files as they are now. +#[tokio::test] +async fn status_reports_site_completeness() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &[screen("32:1", "Company", 1440.0)]).await; + confirm_input.answers = answers(&[("heuristics", "Accept all (Recommended)")]); + confirm(&confirm_input, &fixture.policy()).unwrap(); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let report = status_report(&plan, &evidence, None); + let item = |name: &str| { + array(&report["siteCompleteness"]) + .iter() + .find(|item| item["item"] == name) + .cloned() + .unwrap_or_else(|| panic!("{name} missing: {}", report["siteCompleteness"])) + }; + assert_eq!(item("favicon")["state"], "asked-and-answered"); + assert_eq!(item("metadata /company")["state"], "missing"); + // The fixture header has two bare links. + assert_eq!(item("hover-focus")["state"], "missing"); + assert_eq!( + item("hover-focus")["detail"], + "0 of 2 interactive elements have hover or focus states" + ); + assert_eq!(item("site-name")["state"], "filled"); + let table = text(&report["markdownTable"]); + assert!(table.contains("Site completeness:"), "{table}"); + assert!( + table.contains("Filled in by default (no question):"), + "{table}" + ); + + fixture.write("src/app/icon.svg", ""); + fixture.write( + "src/app/company/page.tsx", + "export const metadata = { title: 'Company' };\nexport default function Page() { return
; }", + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let report = status_report(&plan, &evidence, None); + let states = array(&report["siteCompleteness"]) + .iter() + .map(|item| { + ( + text(&item["item"]).to_owned(), + text(&item["state"]).to_owned(), + ) + }) + .collect::>(); + assert_eq!(states["favicon"], "filled"); + assert_eq!(states["metadata /company"], "filled"); +} + +#[test] +fn a_missing_app_icon_is_a_warning() { + let fixture = Fixture::new(); + let finding = missing_app_icon(fixture.root()).expect("the fixture app has no icon"); + assert_eq!(finding.rule, "app-icon"); + fixture.write("src/app/favicon.ico", "ico"); + assert!(missing_app_icon(fixture.root()).is_none()); +} diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs index fb84cd84..23b30309 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -27,8 +27,8 @@ //! each one pins the commit it copied and `scripts/refresh-skills.mjs` is how //! that copy is moved forward. //! -//! `own` skills are written here, in this repository - devfive-frontend and -//! devup-implementation-plan. There +//! `own` skills are written here, in this repository - devfive-frontend, +//! devup-implementation-plan and devup. There //! is no upstream to vendor from and nothing for the refresh script to compare //! against, so they pin no commit; this repository's own history is their //! provenance. They are otherwise installed exactly like an embedded skill, @@ -144,6 +144,10 @@ const EMBEDDED: &[(&str, Documents)] = &[ include_str!("skills/devup-implementation-plan/SKILL.md"), )], ), + ( + "devup", + &[("SKILL.md", include_str!("skills/devup/SKILL.md"))], + ), ( "changepacks", &[("SKILL.md", include_str!("skills/changepacks/SKILL.md"))], @@ -1167,6 +1171,137 @@ impl Skill { } } +/// The explicit entry point: `/devup [figma url] [notes]`. +/// +/// An MCP server cannot start a skill or a slash command itself, so the user +/// gets one to type. Claude Code turns every project skill into a slash +/// command of the same name, so the `devup` skill installed with the others +/// already is `/devup` there. OpenCode keeps slash commands in their own +/// directory, so it gets a command file as well. Every other runtime is +/// reported unsupported rather than given a path nobody verified. +pub const COMMAND_NAME: &str = "devup"; +pub const COMMAND_INVOCATION: &str = "/devup [figma url] [notes]"; +/// Marks a command file devup-mcp wrote, so a later install may rewrite it +/// and leaves anyone else's alone. +const COMMAND_MARKER: &str = " +

First

Next
+ + + diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index 55cf5b1f..a7c77ce2 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -15,7 +15,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { release } from 'node:os'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { evaluateCheck, evaluateDimensions, summarize } from './checks.mjs'; +import { evaluateCheck, evaluateDimensions, evaluateViewTransition, summarize } from './checks.mjs'; const PLAN_KIND = 'devup-runtime-audit-plan'; const REPORT_KIND = 'devup-runtime-audit-report'; @@ -126,6 +126,50 @@ async function measureBox(page, target, measure = 'box') { return { count, box: box ?? { x: 0, y: 0, width: 0, height: 0 }, visible }; } +// Counts the view transitions a navigation runs. A same-document one goes +// through document.startViewTransition, which React's calls; +// a cross-document one (the CSS @view-transition rule) arrives as a pagereveal +// event carrying a viewTransition. The count survives a document change in +// sessionStorage, which is per tab and origin. +const VIEW_TRANSITION_PROBE = () => { + const key = '__devupViewTransitions'; + const bump = () => sessionStorage.setItem(key, String(Number(sessionStorage.getItem(key) || 0) + 1)); + window.addEventListener('pagereveal', (event) => { + if (event.viewTransition) bump(); + }); + if (typeof document.startViewTransition === 'function' && !document.startViewTransition.__devup) { + const original = document.startViewTransition; + const counted = function startViewTransition(...args) { + bump(); + return original.apply(this, args); + }; + counted.__devup = true; + document.startViewTransition = counted; + } +}; + +async function observeViewTransition(page, check, timeout) { + const target = locate(page, check.subject); + const subjectCount = await target.count(); + if (subjectCount !== 1) return { subjectCount }; + const supported = await page.evaluate(() => typeof document.startViewTransition === 'function'); + const read = () => page.evaluate(() => Number(sessionStorage.getItem('__devupViewTransitions') || 0)); + const before = await read(); + const from = new URL(page.url()).pathname; + await target.click({ timeout }); + const navigated = await page + .waitForURL((url) => url.pathname !== from, { timeout }) + .then(() => true, () => false); + let calls = 0; + if (supported && navigated) { + calls = await page + .waitForFunction((start) => Number(sessionStorage.getItem('__devupViewTransitions') || 0) > start, before, { timeout: 2000 }) + .then(read, read) + .then((now) => now - before); + } + return { subjectCount, supported, navigated, calls, from, to: new URL(page.url()).pathname }; +} + async function pageMetrics(page) { return page.evaluate(() => { const viewportWidth = window.innerWidth; @@ -207,6 +251,7 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { blocked.push(url); return route.abort('blockedbyclient'); }); + 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) => { @@ -278,6 +323,9 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { environmentErrors.push({ kind: 'font-missing', message: `${family} is not loaded` }); } for (const [index, check] of screen.checks.entries()) { + // A view-transition check navigates away, so it runs after every + // other check and the screenshot have seen this screen. + if (check.kind === 'view-transition') continue; const subject = await measureBox(page, check.subject, check.measure); const container = await measureBox(page, check.container); const result = subject.error @@ -301,6 +349,17 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { }); await writeFile(join(out, name), bytes); record.screenshot = { path: name, sha256: sha256(bytes), bytes: bytes.length }; + for (const [index, check] of screen.checks.entries()) { + if (check.kind !== 'view-transition') continue; + const observed = await observeViewTransition(page, check, stepTimeout).catch((error) => ({ + subjectCount: 1, supported: true, navigated: false, calls: 0, error: String(error?.message ?? error), + })); + const result = observed.error + ? { status: 'inconclusive', reason: `the subject could not be followed: ${observed.error.split('\n')[0]}`, measured: {} } + : evaluateViewTransition(observed); + record.checks.push({ index, kind: check.kind, label: check.label, ...result }); + } + record.checks.sort((a, b) => a.index - b.index); } } finally { run.finishedAt = new Date().toISOString(); diff --git a/harness/runtime-audit/test/browser.test.mjs b/harness/runtime-audit/test/browser.test.mjs index 5c30eea3..5839b7c4 100644 --- a/harness/runtime-audit/test/browser.test.mjs +++ b/harness/runtime-audit/test/browser.test.mjs @@ -219,3 +219,40 @@ test('dimensions across 1440/1100/858/390 retain CSS pixels at DPR 2 and filter await rm(out, { recursive: true, force: true }); } }); + +test('a view-transition check sees same-document and cross-document transitions, and their absence', async () => { + const server = await serve(); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const out = await mkdtemp(join(tmpdir(), 'devup-runtime-audit-vt-')); + try { + const planPath = join(out, 'plan.json'); + const check = { kind: 'view-transition', label: 'next', subject: { testId: 'go' } }; + await writeFile(planPath, JSON.stringify({ + schemaVersion: 1, + kind: 'devup-runtime-audit-plan', + planId: 'browser-vt', + plan: { + baseUrl, + allowedOrigins: [baseUrl], + viewports: [{ width: 1200, height: 800, deviceScaleFactor: 1 }], + screens: [ + { id: 'same', routePath: '/transition.html', checks: [{ kind: 'no-horizontal-overflow', label: 'page' }, check] }, + { id: 'cross', routePath: '/crossdoc.html', checks: [check] }, + { id: 'plain', routePath: '/plain.html', checks: [check] }, + ], + locale: null, + timezone: null, + readiness: null, + stepTimeoutMs: null, + }, + })); + const report = await runPlan({ plan: planPath, out, maxMinutes: 5 }); + const [same, cross, plain] = report.runs[0].screens; + assert.deepEqual(same.checks.map((item) => [item.index, item.status]), [[0, 'pass'], [1, 'pass']], JSON.stringify(same, null, 2)); + assert.equal(cross.checks[0].status, 'pass', JSON.stringify(cross, null, 2)); + assert.equal(plain.checks[0].status, 'fail', JSON.stringify(plain, null, 2)); + } finally { + server.close(); + await rm(out, { recursive: true, force: true }); + } +}); diff --git a/harness/runtime-audit/test/checks.test.mjs b/harness/runtime-audit/test/checks.test.mjs index d03396cc..22e142d5 100644 --- a/harness/runtime-audit/test/checks.test.mjs +++ b/harness/runtime-audit/test/checks.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { evaluateCheck, evaluateDimensions, summarize } from '../checks.mjs'; +import { evaluateCheck, evaluateDimensions, evaluateViewTransition, summarize } from '../checks.mjs'; import { validateDimensionsPlan } from '../run.mjs'; const page = { viewportWidth: 360, scrollWidth: 360, maxRight: 340, overflowHidden: false }; @@ -78,3 +78,13 @@ test('dimension runner refuses inferred values, duplicate mappings and invalid t { ...check, figmaDimensions: [{ ...mapping, viewportWidth: 858 }] }, ]) assert.throws(() => validateDimensionsPlan(plan(entry))); }); + +test('a view-transition check passes only when following the link ran one', () => { + const observed = { subjectCount: 1, supported: true, navigated: true, calls: 1, from: '/a', to: '/b' }; + assert.equal(evaluateViewTransition(observed).status, 'pass'); + assert.equal(evaluateViewTransition({ ...observed, calls: 0 }).status, 'fail'); + assert.match(evaluateViewTransition({ ...observed, calls: 0 }).reason, /ran no view transition/); + assert.equal(evaluateViewTransition({ ...observed, navigated: false }).status, 'inconclusive'); + assert.equal(evaluateViewTransition({ ...observed, supported: false }).status, 'inconclusive'); + assert.equal(evaluateViewTransition({ subjectCount: 0 }).status, 'inconclusive'); +}); From 9be2ed2727d96135c2fa655a17c75ec5df37076f Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 23:47:51 +0900 Subject: [PATCH 18/24] Always plan view transitions, and pick the target app by meaning - View transitions are always on (react-view-transition): the dependency step brings every project to the latest Next and React, so the version-keyed trees for vinext, new projects and the next CLI are gone, with their tests. Skipped only for viewTransitions: false in the project config, or updateDependencies: false on a React older than 19.3. The decision names the runtime-audit view-transition check that proves it; experimental.viewTransition is added only if that check fails without it. - In a repository with several apps the target app is chosen from the apps' names and routes and the frames' own names (public site vs admin console): all-public frames go to the public app and all-admin frames to the admin app without a question, a mixed file maps each screen and asks only about screens of unclear side, and only frames that say nothing are asked once, recommending the public app. The evidence is recorded in defaults (target-app), route rows carry an app column and a file path in that app, one URL may exist once per app, and route evidence is scoped to the chosen app. - framework::App carries the package name. Co-Authored-By: Claude Opus 5.5 --- crates/devup-mcp/src/server/framework.rs | 7 +- crates/devup-mcp/src/server/mod.rs | 4 +- crates/devup-mcp/src/server/screen_plan.rs | 568 ++++++++++++------ .../devup-mcp/src/server/screen_plan_tests.rs | 265 ++++++-- crates/devup-mcp/tests/screen_plan_tool.rs | 2 +- 5 files changed, 621 insertions(+), 225 deletions(-) diff --git a/crates/devup-mcp/src/server/framework.rs b/crates/devup-mcp/src/server/framework.rs index 9b23ea04..3bdac353 100644 --- a/crates/devup-mcp/src/server/framework.rs +++ b/crates/devup-mcp/src/server/framework.rs @@ -26,12 +26,14 @@ pub(super) struct App { pub evidence: Vec, /// `src/app` or `app` under `dir`, when either exists. pub app_router: Option, + /// The `name` in its package.json. + pub name: Option, } impl App { pub fn json(&self) -> Value { - json!({"dir": self.dir, "framework": self.framework, "evidence": self.evidence, - "appRouter": self.app_router}) + json!({"dir": self.dir, "name": self.name, "framework": self.framework, + "evidence": self.evidence, "appRouter": self.app_router}) } /// `dir` joined with a relative path, without a leading `./`. @@ -149,6 +151,7 @@ fn classify(root: &Path, dir: &str) -> App { framework, evidence, app_router, + name: package["name"].as_str().map(str::to_owned), } } diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index e138b1ac..00e0c075 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -2583,7 +2583,7 @@ impl DevupServer { #[tool( description = "Plan, then verify, a browser run of generated screens at several viewport widths that the consumer project executes (action: plan | ingest). This server never launches a browser or runs a command. \ - Optional templatePath transactionally writes the fixed Playwright runner and package files alongside outputPath. Dimension checks bind explicit Figma artifactId/nodeId references to CSS logical DOM sizes at viewports (default 1440/1100/858/390); width tables disclose missing references and report tolerance exceedances only, never multiplying zoom or DPR. plan validates a declarative plan - baseUrl and allowedOrigins, viewports, screens by routePath or by steps that find elements by role and accessible name, testId or text, and geometry checks (no-horizontal-overflow, contained, aligned, spans, visible) - gives it a content id, and writes it under an allowed root with outputPath. Unknown fields, shell strings, other origins and URLs carrying credentials are refused. \ + 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) 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.", output_schema = permissive_object_output_schema() )] @@ -2631,7 +2631,7 @@ impl DevupServer { Ok(tool_result(result)) } #[tool( - description = "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). 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 and, with several workspace apps, which one; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions by the mechanism the project's Next.js and React versions support, or skipped with the reason; 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. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it.", + description = "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). 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 site for marketing frames, the admin app for admin frames, per screen in a mixed file) and asks only when the frames say nothing; 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. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it.", output_schema = permissive_object_output_schema() )] async fn devup_screen_plan( diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index 7dfae146..d181372f 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -305,6 +305,9 @@ pub(super) struct PlanConfig { /// vinext | next: pre-answers the migrate-to-vinext question. #[serde(default)] pub framework: Option, + /// false opts the project out of view transitions. + #[serde(default)] + pub view_transitions: Option, } fn read_config(project_root: &str) -> Result, DevupError> { @@ -1616,11 +1619,11 @@ fn version_triple(value: &str) -> Option<(u64, u64, u64)> { } /// The dependency step every job starts with, word for word as the skills -/// give it. `-d` scans every manifest under the root recursively (that is how -/// workspace apps are reached), `-u` applies the updates as one recoverable -/// batch, and `--rm` removes the lockfiles and installed directories next to -/// each manifest - for a Cargo.toml too, which means Cargo.lock and target/ - -/// so the next install resolves everything fresh. +/// give it. It updates every manifest in the repository - every bun +/// workspace and every Cargo.toml - to the latest versions: `-d` finds them +/// all, `-u` applies the updates as one recoverable batch, and `--rm` removes +/// the lockfiles and installed directories next to each (Cargo.lock and +/// target/ included) so the next install resolves everything fresh. pub(super) const UPDATE_STEP: &str = "bunx @dependency-check-updates/cli -d -u --rm"; /// The documents the view-transition decision rests on. Read when it was @@ -1635,9 +1638,9 @@ pub(super) const UPDATE_STEP: &str = "bunx @dependency-check-updates/cli -d -u - /// vinext's own example imports `ViewTransition` from 'react' and sets /// `experimental.viewTransition: true` in its next config. /// - : -/// `vinext check` still labels that import "partial (passthrough fallback, -/// won't animate)" whatever the React version; the runtime audit is where -/// that is settled. +/// `vinext check` labels that import "partial (passthrough fallback)" +/// whatever the React version; the runtime audit's view-transition check is +/// what settles whether it animates. /// - https://github.com/cloudflare/vinext#readme - peer dependency react ^19.3.1. const VIEW_TRANSITION_DOCS: &[&str] = &[ "https://nextjs.org/docs/app/guides/view-transitions", @@ -1647,64 +1650,44 @@ const VIEW_TRANSITION_DOCS: &[&str] = &[ "https://github.com/cloudflare/vinext/blob/main/packages/vinext/src/check.ts", ]; -fn vinext_transition(evidence: &ProjectEvidence, common: &str, why: String) -> Value { - json!({"item": "view-transitions", "mechanism": "react-view-transition (vinext)", - "decision": format!("import {{ ViewTransition }} from 'react', wrapping each page's content in its page.tsx, with experimental: {{ viewTransition: true }} in vinext({{ nextConfig }}) as vinext's own example does; {common}. vinext check still labels this import partial (a passthrough shim), so the runtime audit confirms the cross-fade animates in Chromium; if it does not, pages still navigate normally - record that as an assumption."), - "evidence": why, "app": evidence.app_root(), "docs": VIEW_TRANSITION_DOCS}) -} - -/// The view-transition default for the latest stack: vinext with React -/// 19.3+, decided from the versions installed after the dependency update - -/// status decides again from what is installed then. The `next` CLI path is -/// a fallback for existing projects that do not run on vinext. +/// The view-transition default: always on. DevFive runs the latest versions +/// (the dependency step comes first), and the latest Next App Router and +/// React support `` on vinext and on the `next` CLI alike, so +/// no version is checked. Whether it really animates is not taken on trust +/// either way: the runtime audit's `view-transition` check follows a link and +/// fails when no transition ran. The only "skipped" results are a project +/// that opts out (`viewTransitions: false`) and one that forbids the update +/// (`updateDependencies: false`) while its installed React predates 19.3. fn view_transition_default(evidence: &ProjectEvidence) -> Value { - let common = "a short cross-fade of about 250ms, no big slides; shared elements (page title, hero image, a card image on its detail page) named only where the same element is on both pages - a static name as the devup-ui viewTransitionName prop, a per-record name on ; `::view-transition { pointer-events: none }`; animation-duration 0s under prefers-reduced-motion; browsers without support navigate normally"; - if is_new_project(evidence) { - return vinext_transition( - evidence, - common, - "new project from dev-five-git/service-template, which runs vinext 1.0 with React 19.3 - after the update step, newer".into(), - ); - } - let migrating = evidence.config.framework.as_deref() == Some("vinext"); - if evidence.framework() != "vinext" && !migrating { - let mut fallback = next_cli_view_transition(evidence); - fallback["docs"] = json!(VIEW_TRANSITION_DOCS); - return fallback; - } - let version = |slot: &Option<(String, String)>| { - slot.as_ref() - .map_or(("not installed".to_owned(), None), |(text, _)| { - (text.clone(), version_triple(text)) - }) + let skipped = |why: String| { + json!({"item": "view-transitions", "mechanism": "skipped", + "decision": "No view transitions.", "evidence": why, "docs": VIEW_TRANSITION_DOCS}) }; - // The highest React the app declares: `react` may be a bare `^19` while - // react-dom and react-server-dom-webpack pin the release it runs. - let (react_text, react) = std::iter::once(version(&evidence.react_version)) - .chain( - evidence - .react_peers - .iter() - .map(|(text, _)| (text.clone(), version_triple(text))), - ) - .max_by_key(|(_, triple)| *triple) - .unwrap_or(("not installed".to_owned(), None)); - let (vinext_text, vinext) = version(&evidence.vinext_version); - if react.is_some_and(|react| react >= (19, 3, 0)) - && vinext.is_some_and(|vinext| vinext >= (1, 0, 0)) - { - return vinext_transition( - evidence, - common, - format!( - "vinext {vinext_text} with react {react_text}: React 19.3 exports ViewTransition, and Next's view-transitions suite passes under vinext" - ), - ); + if evidence.config.view_transitions == Some(false) { + return skipped(format!("viewTransitions: false in {CONFIG_FILE}")); + } + if evidence.config.update_dependencies == Some(false) { + let react = std::iter::once(&evidence.react_version) + .flatten() + .chain(evidence.react_peers.iter()) + .filter_map(|(text, _)| version_triple(text)) + .max(); + if react.is_none_or(|react| react < (19, 3, 0)) { + return skipped(format!( + "updateDependencies: false in {CONFIG_FILE} keeps React below 19.3, which has no ViewTransition" + )); + } } - json!({"item": "view-transitions", "mechanism": "skipped", - "decision": "No view transitions yet: the update step brings vinext and React up, and status decides again from the installed versions.".to_owned(), - "evidence": format!("vinext {vinext_text} with react {react_text}: needs vinext 1.0+ and React 19.3+"), - "docs": VIEW_TRANSITION_DOCS}) + let runner = if evidence.framework() == "next" + && evidence.config.framework.as_deref() != Some("vinext") + { + "the next CLI" + } else { + "vinext" + }; + json!({"item": "view-transitions", "mechanism": "react-view-transition", + "decision": "import { ViewTransition } from 'react', wrapping each page's content in its page.tsx (not the layout, which persists): a short cross-fade of about 250ms, no big slides; shared elements (page title, hero image, a card image on its detail page) named only where the same element is on both pages - a static name as the devup-ui viewTransitionName prop, a per-record name on ; `::view-transition { pointer-events: none }`; animation-duration 0s under prefers-reduced-motion. Verify it with a runtime-audit view-transition check (follow a link between two routes; it fails when no transition ran) - a failure is a bug to fix. experimental.viewTransition is added to the next config only if that check fails without it (vinext's own example sets it).", + "evidence": format!("runs on {runner} at the latest versions"), "app": evidence.app_root(), "docs": VIEW_TRANSITION_DOCS}) } /// No app, no routes and no framework: a project still to be created. @@ -1735,48 +1718,310 @@ fn stack_defaults(evidence: &ProjectEvidence) -> Vec { "evidence": format!("updateDependencies: false in {CONFIG_FILE}")}) } else { json!({"item": "dependencies", "mechanism": "update", - "decision": format!("Before implementing, at the repository root: {UPDATE_STEP} (-d scans every manifest below the root, so workspace apps are included; -u applies the updates as one recoverable batch; --rm removes the lockfiles and installed directories next to each manifest - Cargo.lock and target/ too - so everything resolves fresh), then bun install, build, lint and typecheck. Fix code to new APIs; pin back only when no fix exists, and record the pin as an assumption. Record the outcome with devup_screen_plan assume (dependencyUpdate)."), + "decision": format!("Before implementing, at the repository root: {UPDATE_STEP}, which updates every manifest in the repository - every bun workspace and every Cargo.toml - to the latest versions (-d finds them all, -u applies the updates as one recoverable batch, --rm removes each lockfile and installed directory, Cargo.lock and target/ included, so everything resolves fresh); then bun install, build, lint and typecheck. Fix code to new APIs; pin back only when no fix exists, and record the pin as an assumption. Record the outcome with devup_screen_plan assume (dependencyUpdate)."), "evidence": "DevFive works on the latest versions; API knowledge from memory is assumed stale"}) }); defaults } -/// The questions about the stack itself: which app, migrating off the -/// `next` CLI, and for a new project its name and parts. -fn stack_questions(evidence: &ProjectEvidence) -> Vec { +/// Words that mark an app, or a screen, as the public site or the admin +/// console. An app named `admin` is the admin console; a Figma design for a +/// public site is almost never for it. +const PUBLIC_APP_WORDS: &[&str] = &[ + "front", "web", "site", "www", "landing", "home", "public", "client", +]; +const ADMIN_WORDS: &[&str] = &[ + "admin", + "dashboard", + "console", + "backoffice", + "back office", + "cms", + "manager", + "관리자", + "어드민", + "대시보드", + "회원관리", +]; +/// Screen names a public site has and an admin console does not. +const PUBLIC_SCREEN_WORDS: &[&str] = &[ + "home", + "main", + "landing", + "about", + "company", + "service", + "services", + "contact", + "portfolio", + "pricing", + "blog", + "news", + "faq", + "gnb", + "메인", + "홈", + "회사소개", + "서비스", + "문의", + "포트폴리오", + "소개", +]; +/// Routes that say an app is a console: a sign-in gate and a dashboard. +const ADMIN_ROUTE_SEGMENTS: &[&str] = &["signin", "sign-in", "login", "dashboard", "admin"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Side { + Public, + Admin, + Unclear, +} + +fn words_side(names: &[&str], public: &[&str]) -> (usize, usize) { + let admin = names + .iter() + .filter(|name| ADMIN_WORDS.iter().any(|word| keyword_in(name, word))) + .count(); + let public = names + .iter() + .filter(|name| public.iter().any(|word| keyword_in(name, word))) + .count(); + (public, admin) +} + +/// Which side an app serves, and the evidence: its directory and package +/// name, then its routes. +fn app_side(app: &super::framework::App, evidence: &ProjectEvidence) -> (Side, Vec) { + let base = app.dir.rsplit('/').next().unwrap_or(&app.dir).to_owned(); + let mut names = vec![base.as_str()]; + if let Some(name) = &app.name { + names.push(name.as_str()); + } + let (mut public, mut admin) = words_side(&names, PUBLIC_APP_WORDS); + let mut why = Vec::new(); + if public > 0 { + why.push(format!("{} is named like the public site", app.dir)); + } + if admin > 0 { + why.push(format!("{} is named like an admin console", app.dir)); + } + let console_routes = evidence + .routes + .iter() + .filter(|(_, page)| page.starts_with(&format!("{}/", app.dir))) + .filter(|(route, _)| { + route + .trim_matches('/') + .split('/') + .any(|segment| ADMIN_ROUTE_SEGMENTS.contains(&segment)) + }) + .map(|(route, _)| route.clone()) + .collect::>(); + if !console_routes.is_empty() { + admin += 1; + why.push(format!( + "{} has console routes ({})", + app.dir, + console_routes.join(", ") + )); + } + if public == admin { + public = 0; + admin = 0; + } + let side = match public.cmp(&admin) { + std::cmp::Ordering::Greater => Side::Public, + std::cmp::Ordering::Less => Side::Admin, + std::cmp::Ordering::Equal => Side::Unclear, + }; + (side, why) +} + +/// Which side one Figma frame belongs to, from its own name and its +/// section's. Admin markers win: a "관리자 - 메인" frame is the console's. +fn screen_side(screen: &PlanScreenInput) -> Side { + let mut names = vec![screen.name.as_str()]; + names.extend(screen.section_name.as_deref()); + names.extend(screen.breadcrumb.iter().map(String::as_str)); + match words_side(&names, PUBLIC_SCREEN_WORDS) { + (_, admin) if admin > 0 => Side::Admin, + (public, _) if public > 0 => Side::Public, + _ => Side::Unclear, + } +} + +fn app_action(app: &super::framework::App) -> Value { + json!({"app": {"dir": app.dir, "appDir": app.path(app.app_router.as_deref().unwrap_or("src/app"))}}) +} + +/// Which app each screen is built in, by meaning rather than by name order. +/// +/// With one app, or a projectRoot that is an app, there is nothing to pick. +/// Otherwise the screens' own names decide: all public screens go to the +/// public app and all admin screens to the admin app without a question; a +/// file with both maps each screen to its side and asks only about screens +/// whose side is unclear; a file whose screens say nothing either way is the +/// one genuinely ambiguous case, asked once - recommending the public app, +/// never whichever comes first alphabetically. +/// +/// Returns the app per screen (by node id), the questions, and the default +/// that records the evidence. +fn assign_apps( + evidence: &ProjectEvidence, + screens: &[PlanScreenInput], +) -> ( + BTreeMap, + Vec, + Option, +) { + let mut assigned = BTreeMap::new(); + if evidence.target_app.is_some() || evidence.apps.len() < 2 { + return (assigned, Vec::new(), None); + } + let sides = evidence + .apps + .iter() + .map(|app| (app, app_side(app, evidence))) + .collect::>(); + let public_app = sides + .iter() + .find(|(_, (side, _))| *side == Side::Public) + .or_else(|| sides.iter().find(|(_, (side, _))| *side != Side::Admin)) + .map(|(app, _)| (*app).clone()) + .unwrap_or_else(|| evidence.apps[0].clone()); + let admin_app = sides + .iter() + .find(|(_, (side, _))| *side == Side::Admin) + .map(|(app, _)| (*app).clone()); + let app_evidence = sides + .iter() + .flat_map(|(_, (_, why))| why.clone()) + .collect::>(); + let screen_sides = screens + .iter() + .map(|screen| (screen, screen_side(screen))) + .collect::>(); + let admin_screens = screen_sides + .iter() + .filter(|(_, side)| *side == Side::Admin) + .count(); + let public_screens = screen_sides + .iter() + .filter(|(_, side)| *side == Side::Public) + .count(); let mut questions = Vec::new(); - if evidence.target_app.is_none() && evidence.apps.len() > 1 { - let choices = evidence - .apps + let options = || { + let mut ordered = vec![public_app.clone()]; + ordered.extend( + evidence + .apps + .iter() + .filter(|app| app.dir != public_app.dir) + .cloned(), + ); + ordered .iter() .take(MAX_OPTIONS) .map(|app| { + let side = sides + .iter() + .find(|(candidate, _)| candidate.dir == app.dir) + .map(|(_, (side, _))| *side); choice( app.dir.as_str(), - format!("{} app; {}", app.framework, app.evidence.join("; ")), - json!({"app": {"dir": app.dir, "appDir": app.path(app.app_router.as_deref().unwrap_or("src/app"))}}), + format!( + "{} - {} app{}", + match side { + Some(Side::Public) => "the public site", + Some(Side::Admin) => "the admin console", + _ => "an app", + }, + app.framework, + app.name + .as_deref() + .map(|name| format!(" ({name})")) + .unwrap_or_default() + ), + app_action(app), ) }) - .collect::>(); - let mut asked = question( - "site:app".into(), - "Which app".into(), - format!( - "This repository has {} apps ({}). Which one does this design build? A custom answer is its directory.", - evidence.apps.len(), - evidence - .apps - .iter() - .map(|app| app.dir.as_str()) - .collect::>() - .join(", ") - ), - choices, - "site", - ); - asked["field"] = json!("app"); - questions.push(asked); - } + .collect::>() + }; + let decision = match (admin_screens, public_screens, &admin_app) { + (0, 0, _) => { + let mut asked = question( + "site:app".into(), + "Which app".into(), + format!( + "This repository has {} apps ({}), and the frames' names say nothing about which one they are for. Which app does this design build? A custom answer is its directory.", + evidence.apps.len(), + evidence + .apps + .iter() + .map(|app| app.dir.as_str()) + .collect::>() + .join(", ") + ), + options(), + "site", + ); + asked["field"] = json!("app"); + questions.push(asked); + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), public_app.clone()); + } + json!({"item": "target-app", "mechanism": "asked", "question": "site:app", + "decision": format!("Asked; {} is recommended as the public app.", public_app.dir), "evidence": app_evidence}) + } + (admin, public, Some(admin_app)) if admin > 0 && public > 0 => { + for (screen, side) in &screen_sides { + let app = if *side == Side::Admin { + admin_app.clone() + } else { + public_app.clone() + }; + assigned.insert(screen.node_id.clone(), app); + if *side == Side::Unclear { + let mut asked = question( + format!("app:{}", screen.node_id), + header("App", &screen.name), + format!( + "This file has public and admin screens. Which app is \"{}\" ({}) for?", + screen.name, screen.node_id + ), + options(), + "site", + ); + asked["field"] = json!("app"); + questions.push(asked); + } + } + json!({"item": "target-app", "mechanism": "per-screen", + "decision": format!("Admin screens go to {}, public screens to {}; screens of unclear side are asked.", admin_app.dir, public_app.dir), + "evidence": app_evidence}) + } + (admin, 0, Some(admin_app)) if admin > 0 => { + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), admin_app.clone()); + } + json!({"item": "target-app", "mechanism": "decided", "decision": admin_app.dir, + "evidence": ([app_evidence, vec![format!("{admin} of {} frames are named for the admin console", screens.len())]].concat())}) + } + _ => { + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), public_app.clone()); + } + json!({"item": "target-app", "mechanism": "decided", "decision": public_app.dir, + "evidence": ([app_evidence, vec![format!("{public_screens} of {} frames are public-site screens and none is named for an admin console", screens.len())]].concat())}) + } + }; + (assigned, questions, Some(decision)) +} + +/// The questions about the stack itself: migrating off the `next` CLI, and +/// for a new project its name and parts. Which app is [`assign_apps`]'s. +fn stack_questions(evidence: &ProjectEvidence) -> Vec { + let mut questions = Vec::new(); if evidence.framework() == "next" && evidence.config.framework.is_none() { let app = evidence.target_app.as_ref(); let mut asked = question( @@ -1841,76 +2086,6 @@ fn stack_questions(evidence: &ProjectEvidence) -> Vec { questions } -/// The `next` CLI fallback: which mechanism this project's Next.js and -/// React support, or why there is none. Decided from the versions the -/// project declares, against the current Next.js view-transitions guide -/// (App Router, `import { ViewTransition } from 'react'`, React 19.3) and the -/// Next 15 `experimental.viewTransition` flag that preceded it. -fn next_cli_view_transition(evidence: &ProjectEvidence) -> Value { - let item = |mechanism: &str, decision: String, why: String| json!({"item": "view-transitions", "mechanism": mechanism, "decision": decision, "evidence": why}); - let common = "a short cross-fade of about 250ms, no big slides; shared elements (page title, hero image, a card image on its detail page) named only where the same element is on both pages - a static name as the devup-ui viewTransitionName prop, a per-record name on ; `::view-transition { pointer-events: none }`; animation-duration 0s under prefers-reduced-motion; browsers without support navigate normally"; - let Some((next_text, next_from)) = &evidence.next_version else { - if !evidence.routes.is_empty() { - return item( - "skipped", - "No view transitions until the Next.js version is known.".into(), - "App Router pages exist but package.json declares no next version".into(), - ); - } - return item( - "css-cross-document", - format!("@view-transition {{ navigation: auto; }} with a ::view-transition-old/new(root) cross-fade, for full page loads; {common}."), - "no Next.js in package.json: navigations load whole documents, which is where the CSS rule applies".into(), - ); - }; - let Some(next) = version_triple(next_text) else { - return item( - "skipped", - "No view transitions until the Next.js version is known.".into(), - format!( - "next is {next_text:?} in {next_from}, which names no version; resolve it and redraft" - ), - ); - }; - let react = evidence - .react_version - .as_ref() - .and_then(|(text, _)| version_triple(text)); - let react_text = evidence - .react_version - .as_ref() - .map_or("not declared".to_owned(), |(text, _)| text.clone()); - if next.0 >= 16 && react.is_some_and(|react| react >= (19, 3, 0)) { - return item( - "react-view-transition", - format!( - "import {{ ViewTransition }} from 'react' - no next.config flag - wrapping each page's content in page.tsx (not the layout, which persists); {common}." - ), - format!( - "next {next_text} and react {react_text}: the App Router supports React's ViewTransition with no configuration" - ), - ); - } - if next >= (15, 2, 0) { - return item( - "react-unstable-view-transition", - format!( - "experimental.viewTransition: true in next.config and import {{ unstable_ViewTransition as ViewTransition }} from 'react', wrapping each page's content; {common}. Record it as an assumption: the Next.js docs mark this flag experimental." - ), - format!( - "next {next_text} with react {react_text}: only the experimental flag is available; Next 16 with React 19.3 needs none" - ), - ); - } - item( - "skipped", - "No view transitions.".into(), - format!( - "next {next_text} predates view-transition support, and its App Router navigations stay in one document, where the CSS @view-transition rule does not apply" - ), - ) -} - /// What the agent fills in by itself, without a question. Recorded in the /// plan's `defaults` and the final report, so nothing it added is a surprise. fn site_defaults(evidence: &ProjectEvidence, screens: &[PlanScreenInput]) -> Vec { @@ -2334,7 +2509,7 @@ fn site_completeness(plan: &Value, evidence: &ProjectEvidence) -> Vec { _ if declined => json!({"item": "view-transitions", "state": "not-applicable", "detail": "skipped: the user kept the project's own page motion"}), _ if transitions => json!({"item": "view-transitions", "state": "filled", - "detail": format!("enabled ({mechanism})")}), + "detail": format!("enabled ({mechanism}); the runtime audit's view-transition check proves it runs")}), _ => json!({"item": "view-transitions", "state": "missing", "detail": format!("planned ({mechanism}) but not in the sources yet")}), }); @@ -2889,10 +3064,42 @@ pub(super) fn draft_plan_with( for asked in extra_questions(extra)? { questions.push(with_when(asked, &base_when)); } + let (screen_apps, app_questions, app_default) = assign_apps(evidence, &kept); + for asked in app_questions { + questions.push(with_when(asked, &base_when)); + } + // One app for the whole plan: its routes are the only route evidence. + let chosen = screen_apps + .values() + .map(|app| app.dir.clone()) + .collect::>(); + let scoped; + let evidence = match (chosen.len(), screen_apps.values().next()) { + (1, Some(app)) + if app_default + .as_ref() + .is_some_and(|item| item["mechanism"] == "decided") => + { + let mut narrowed = evidence.clone(); + narrowed + .routes + .retain(|(_, page)| page.starts_with(&format!("{}/", app.dir))); + narrowed.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); + narrowed.target_app = Some(app.clone()); + scoped = narrowed; + &scoped + } + _ => evidence, + }; for asked in stack_questions(evidence) { questions.push(with_when(asked, &base_when)); } - let defaults = [stack_defaults(evidence), site_defaults(evidence, &kept)].concat(); + let defaults = [ + app_default.into_iter().collect::>(), + stack_defaults(evidence), + site_defaults(evidence, &kept), + ] + .concat(); let cover = kept .first() .map(|screen| (screen.node_id.clone(), screen.name.clone())); @@ -3595,6 +3802,8 @@ pub(super) fn draft_plan_with( "kindEvidence": if modal { json!(kind_evidence) } else { Value::Null }, "frames": screen.frames.iter().map(Frame::json).collect::>(), "drawnWidths": widths, + "app": screen_apps.get(screen.id()).map(|app| app.dir.clone()), + "appDir": screen_apps.get(screen.id()).map(|app| app.path(app.app_router.as_deref().unwrap_or("src/app"))), "groupingQuestion": screen.grouping_question(), "structureQuestions": screen.structure, "grouping": match &screen.grouping { @@ -4148,6 +4357,21 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< if nothing_here || fixed(&deciding) { continue; } + let chosen_app = actions + .get(&format!("app:{id}")) + .or_else(|| actions.get("site:app")) + .map(|action| action["app"].clone()); + let screen_app = chosen_app + .as_ref() + .and_then(|app| app["dir"].as_str()) + .or(screen["app"].as_str()) + .map(str::to_owned); + let screen_app_dir = chosen_app + .as_ref() + .and_then(|app| app["appDir"].as_str()) + .or(screen["appDir"].as_str()) + .unwrap_or(&app_dir) + .to_owned(); let group = screen["groupingQuestion"] .as_str() .and_then(|question| actions.get(question)); @@ -4228,7 +4452,8 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< "drawnWidths": group_frames.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), "implementation": implementation, "hostScreen": host, - "file": existing.clone().unwrap_or_else(|| route.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &app_dir)))), + "app": screen_app, + "file": existing.clone().unwrap_or_else(|| route.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &screen_app_dir)))), "fileExists": existing.is_some(), "collisionPolicy": collision, "data": data, @@ -4269,10 +4494,14 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< } } } - let mut seen = BTreeMap::::new(); + // One URL per app: the public site and the console may both have `/`. + let mut seen = BTreeMap::<(String, String), String>::new(); for row in &routes { if let Some(url) = row["url"].as_str() - && let Some(other) = seen.insert(url.to_owned(), text(&row["screen"]).to_owned()) + && let Some(other) = seen.insert( + (text(&row["app"]).to_owned(), url.to_owned()), + text(&row["screen"]).to_owned(), + ) && other != text(&row["screen"]) { return Err(invalid(format!( @@ -4462,7 +4691,7 @@ fn cell(value: &Value) -> String { fn route_markdown(routes: &[Value]) -> String { let mut out = String::from( - "| URL | Figma screen | nodeIds | widths | file | implementation |\n|---|---|---|---|---|---|\n", + "| URL | Figma screen | nodeIds | widths | file | implementation | app |\n|---|---|---|---|---|---|---|\n", ); for row in routes { let url = if row["implementation"] == "state" { @@ -4471,12 +4700,13 @@ fn route_markdown(routes: &[Value]) -> String { cell(&row["url"]) }; out.push_str(&format!( - "| {url} | {} | {} | {} | {} | {} |\n", + "| {url} | {} | {} | {} | {} | {} | {} |\n", cell(&row["name"]), cell(&row["nodeIds"]), cell(&row["drawnWidths"]), cell(&row["file"]), cell(&row["implementation"]), + cell(&row["app"]), )); } out diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index 589d3a9a..5e529596 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -1390,10 +1390,11 @@ async fn status_reports_site_completeness() { item("interaction-roles")["detail"], "nav items 0/2 hover+focus+aria-current" ); - // App Router pages but no next in package.json: skipped, and said why. - assert_eq!(item("view-transitions")["state"], "not-applicable"); - assert!( - text(&item("view-transitions")["detail"]).starts_with("skipped: App Router pages exist") + // Always planned; missing until the sources carry it. + assert_eq!(item("view-transitions")["state"], "missing"); + assert_eq!( + item("view-transitions")["detail"], + "planned (react-view-transition) but not in the sources yet" ); assert_eq!(item("site-name")["state"], "filled"); let table = text(&report["markdownTable"]); @@ -1458,52 +1459,67 @@ async fn transition_default(fixture: &Fixture) -> Value { .expect("a view-transitions default") } -/// The mechanism follows the versions the project declares, never a guess: -/// Next 16 with React 19.3 needs no flag, Next 15.2+ only has the -/// experimental one, older App Routers get none, and an existing page-motion -/// library turns the default into a question. +/// View transitions are always on: the dependency step brings every project +/// to the latest Next and React, so no version is checked. Only an explicit +/// opt-out, or a project that forbids the update while on an old React, +/// skips them; an existing page-motion library makes them a question. #[tokio::test] -async fn view_transitions_follow_the_project_versions() { - let supported = with_package( +async fn view_transitions_are_always_on_unless_the_project_opts_out() { + for package in [ r#"{"dependencies": {"next": "^16.1.0", "react": "19.3.0"}}"#, + r#"{"dependencies": {"next": "15.3.0", "react": "19.1.0"}}"#, + r#"{"dependencies": {"next": "^14.2.0", "react": "^18.3.0"}}"#, + r#"{"dependencies": {"next": "latest"}}"#, + ] { + let fixture = with_package(package, None); + let chosen = transition_default(&fixture).await; + assert_eq!( + chosen["mechanism"], "react-view-transition", + "{package}: {chosen}" + ); + assert!(text(&chosen["decision"]).contains("import { ViewTransition } from 'react'")); + assert!(text(&chosen["decision"]).contains("prefers-reduced-motion")); + assert!(text(&chosen["decision"]).contains("view-transition check")); + assert!(!array(&chosen["docs"]).is_empty()); + } + + let opted_out = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, None, ); - let chosen = transition_default(&supported).await; - assert_eq!(chosen["mechanism"], "react-view-transition"); - assert!(text(&chosen["decision"]).contains("import { ViewTransition } from 'react'")); - assert!(text(&chosen["decision"]).contains("prefers-reduced-motion")); + opted_out.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "viewTransitions": false}"#), + ); + let chosen = transition_default(&opted_out).await; + assert_eq!(chosen["mechanism"], "skipped"); + assert!(text(&chosen["evidence"]).contains("viewTransitions: false")); - let experimental = with_package( - r#"{"dependencies": {"next": "15.3.0", "react": "19.1.0"}}"#, + let frozen_old = with_package( + r#"{"dependencies": {"next": "14.2.0", "react": "18.3.1"}}"#, None, ); - let chosen = transition_default(&experimental).await; - assert_eq!(chosen["mechanism"], "react-unstable-view-transition"); - assert!(text(&chosen["decision"]).contains("experimental.viewTransition: true")); - - let old = with_package( - r#"{"dependencies": {"next": "^14.2.0", "react": "^18.3.0"}}"#, + frozen_old.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "updateDependencies": false}"#), + ); + assert_eq!( + transition_default(&frozen_old).await["mechanism"], + "skipped" + ); + let frozen_new = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, None, ); - let chosen = transition_default(&old).await; - assert_eq!(chosen["mechanism"], "skipped"); - assert!(text(&chosen["evidence"]).contains("one document")); - - // The lockfile's installed version wins over the declared range. - let locked = with_package( - r#"{"dependencies": {"next": "latest", "react": "latest"}}"#, - Some( - r#"{"packages": {"node_modules/next": {"version": "16.2.1"}, "node_modules/react": {"version": "19.3.2"}}}"#, - ), + frozen_new.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "updateDependencies": false}"#), ); assert_eq!( - transition_default(&locked).await["mechanism"], + transition_default(&frozen_new).await["mechanism"], "react-view-transition" ); - let unknown = with_package(r#"{"dependencies": {"next": "latest"}}"#, None); - assert_eq!(transition_default(&unknown).await["mechanism"], "skipped"); - let animated = with_package( r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0", "framer-motion": "^11.0.0"}}"#, None, @@ -1708,15 +1724,8 @@ async fn a_service_template_project_is_vinext_with_view_transitions_enabled() { let (evidence, plan) = plan_for(front.to_str().unwrap()).await; assert_eq!(evidence.framework(), "vinext"); let chosen = default_item(&plan, "view-transitions"); - assert_eq!( - chosen["mechanism"], "react-view-transition (vinext)", - "{chosen}" - ); - assert!( - text(&chosen["evidence"]).contains("react-dom 19.3.0") - || text(&chosen["evidence"]).contains("19.3.0"), - "{chosen}" - ); + assert_eq!(chosen["mechanism"], "react-view-transition", "{chosen}"); + assert!(text(&chosen["evidence"]).contains("vinext"), "{chosen}"); assert!( array(&chosen["docs"]) .iter() @@ -1727,11 +1736,9 @@ async fn a_service_template_project_is_vinext_with_view_transitions_enabled() { let (evidence, plan) = plan_for(fixture.root()).await; assert_eq!(evidence.apps.len(), 2); assert!(evidence.target_app.is_none()); - let which = find(&plan, "site:app"); - assert_eq!( - labels(which)[..2], - ["apps/admin (Recommended)", "apps/front"] - ); + // "Home" is a public-site screen: apps/front, without asking. + assert_eq!(default_item(&plan, "target-app")["decision"], "apps/front"); + assert!(!question_ids(&plan).contains(&"site:app".to_owned())); let routes = super::super::project_context::run("routes", Some(fixture.root()), None) .await @@ -1793,7 +1800,7 @@ async fn a_new_project_starts_from_the_template() { ); assert_eq!( default_item(&plan, "view-transitions")["mechanism"], - "react-view-transition (vinext)" + "react-view-transition" ); let ids = question_ids(&plan); assert!( @@ -1874,3 +1881,159 @@ fn bun_lock_versions_are_read() { assert_eq!(bun_locked(lock, "vinext").as_deref(), Some("1.0.2")); assert_eq!(bun_locked(lock, "next"), None); } + +async fn plan_screens_at(root: &str, screens: &[PlanScreenInput]) -> Value { + let evidence = project_evidence(root).await.unwrap(); + draft_plan(screens, &BTreeMap::new(), &evidence, json!({}), None).unwrap() +} + +fn rows_app(plan: &Value) -> Vec<(String, String)> { + array(&plan["screens"]) + .iter() + .map(|row| (text(&row["id"]).to_owned(), text(&row["app"]).to_owned())) + .collect() +} + +/// apps/front and apps/admin: the app is picked by what the frames are, not +/// by name order. Marketing frames build the public site without a question. +#[tokio::test] +async fn marketing_frames_go_to_the_public_app_without_a_question() { + let fixture = template_fixture(); + let plan = plan_screens_at( + fixture.root(), + &[ + screen("60:1", "Main", 1440.0), + screen("60:2", "회사소개", 1440.0), + screen("60:3", "Contact", 1440.0), + ], + ) + .await; + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("site:app") || id.starts_with("app:")), + "{:?}", + question_ids(&plan) + ); + let chosen = default_item(&plan, "target-app"); + assert_eq!(chosen["decision"], "apps/front"); + assert_eq!(chosen["mechanism"], "decided"); + assert!( + chosen["evidence"] + .to_string() + .contains("named like the public site"), + "{chosen}" + ); + assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/front")); + assert_eq!(plan["appDir"], "apps/front/src/app"); +} + +#[tokio::test] +async fn admin_frames_go_to_the_admin_app() { + let fixture = template_fixture(); + let plan = plan_screens_at( + fixture.root(), + &[ + screen("61:1", "Admin - 회원관리", 1440.0), + screen("61:2", "Admin - 상품 목록", 1440.0), + ], + ) + .await; + assert_eq!(default_item(&plan, "target-app")["decision"], "apps/admin"); + assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/admin")); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("site:app")) + ); +} + +/// A file with both maps each screen to its side and asks only about the +/// screens whose side is unclear; confirm writes each route in its own app. +#[tokio::test] +async fn a_mixed_file_maps_each_screen_and_asks_only_the_unclear() { + let fixture = template_fixture(); + let screens = [ + screen("62:1", "Home", 1440.0), + screen("62:2", "관리자 - 대시보드", 1440.0), + screen("62:3", "Settings", 1440.0), + ]; + let plan = plan_screens_at(fixture.root(), &screens).await; + assert_eq!(default_item(&plan, "target-app")["mechanism"], "per-screen"); + let apps = rows_app(&plan).into_iter().collect::>(); + assert_eq!(apps["62:1"], "apps/front"); + assert_eq!(apps["62:2"], "apps/admin"); + let unclear = find(&plan, "app:62:3"); + assert_eq!(first_label(unclear), "apps/front (Recommended)"); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id == "app:62:1" || id == "app:62:2") + ); +} + +/// Frames that say nothing either way: asked once, recommending the public +/// app - never the alphabetically first one. +#[tokio::test] +async fn ambiguous_frames_are_asked_recommending_the_public_app() { + let fixture = template_fixture(); + let screens = [ + screen("63:1", "Frame 12", 1440.0), + screen("63:2", "Frame 13", 1440.0), + ]; + let plan = plan_screens_at(fixture.root(), &screens).await; + let which = find(&plan, "site:app"); + assert_eq!( + labels(which)[..2], + ["apps/front (Recommended)", "apps/admin"] + ); + assert!(text(&which["options"][0]["description"]).contains("public site")); + + let draft_input = ScreenPlanInput { + action: "draft".into(), + project_root: Some(fixture.root().into()), + ..ScreenPlanInput::default() + }; + let drafted = draft(&draft_input, &screens, json!({}), &fixture.policy()) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + let mut pairs = vec![("site:app", "apps/admin")]; + for question in array(&drafted["questions"]) { + let id = text(&question["id"]); + if id.starts_with("route:") { + pairs.push(( + id, + if id.ends_with("63:1") { + "/members" + } else { + "/orders" + }, + )); + } + } + let owned = pairs + .iter() + .map(|(id, answer)| (id.to_string(), answer.to_string())) + .collect::>(); + confirm_input.answers = answers( + &owned + .iter() + .map(|(id, answer)| (id.as_str(), answer.as_str())) + .collect::>(), + ); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let files = array(&confirmed["routes"]) + .iter() + .map(|row| (text(&row["app"]).to_owned(), text(&row["file"]).to_owned())) + .collect::>(); + assert_eq!( + files[0], + ( + "apps/admin".to_owned(), + "apps/admin/src/app/members/page.tsx".to_owned() + ), + "{files:?}" + ); +} diff --git a/crates/devup-mcp/tests/screen_plan_tool.rs b/crates/devup-mcp/tests/screen_plan_tool.rs index ffe7e332..945448ae 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: 4 missing" + "3 URLs: 2 implemented, 0 verified at all widths; no runtime audit report, so every width is not-run; site completeness: 5 missing" ); std::fs::remove_dir_all(root).ok(); Ok(()) From 2574ccf2103977dd368d4a84a940b569426e3f98 Mon Sep 17 00:00:00 2001 From: leader Date: Sat, 17 Oct 2026 23:47:51 +0900 Subject: [PATCH 19/24] Document always-on view transitions, the e2e check and app selection The devup-implementation-plan and devup skills and the OpenCode /devup command: view transitions are always on and proved by the runtime audit's view-transition check (a failure is a bug to fix); the update step says plainly that it updates every manifest, bun workspaces and Cargo included; the target app is picked by meaning. The guide, the tool descriptions and the README match, viewTransitions joins the config keys, and node_modules/ is ignored so a local harness install is never committed. Co-Authored-By: Claude Opus 5.5 --- .../changepack_log_screen_plan_first.json | 2 +- .gitignore | 4 + README.md | 2 +- .../src/server/commands/opencode-devup.md | 12 ++- crates/devup-mcp/src/server/guide.rs | 4 +- .../skills/devup-implementation-plan/SKILL.md | 100 ++++++++++-------- .../src/server/skills/devup/SKILL.md | 24 +++-- .../devup-mcp/src/server/skills/manifest.json | 8 +- 8 files changed, 85 insertions(+), 71 deletions(-) diff --git a/.changepacks/changepack_log_screen_plan_first.json b/.changepacks/changepack_log_screen_plan_first.json index d990dc5f..7eed4bde 100644 --- a/.changepacks/changepack_log_screen_plan_first.json +++ b/.changepacks/changepack_log_screen_plan_first.json @@ -3,6 +3,6 @@ "crates/devup-mcp/Cargo.toml": "Minor", "crates/devup-mcp-devup-ui/Cargo.toml": "Minor" }, - "note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen under the linked node is mapped to an evidence-backed URL and confirmed in questions asked up front, never during implementation; keep the plan inside the linked scope and out of trash, archive, draft, copy and hidden areas (listed and confirmed, overridable with excludeKeywords/includeKeywords); ask cross-section and other structural ambiguities explicitly with a fix-the-Figma option whose frames are reported, not built; surface layer-name guesses; honour .devup/screen-plan.config.json; gate page-writing exports on the confirmed plan and refuse excluded or design-fix frames; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill. Add a site-completeness step: the plan records the defaults the agent fills in (interaction states from existing tokens, one shared transition, reduced motion, metadata, lang, alt, not-found) and asks only for files and facts (favicon, share image, site name and URL, analytics, motion); status reports site completeness and devup_ui_validate warns on stateless controls, pages without metadata and a missing app icon. Add the /devup entry point, installed as a skill (Claude Code) and an OpenCode command. Keep multi-colour icons as instead of a single-colour mask, warn multicolor-mask in devup_ui_validate when a maskImage would flatten a multi-colour SVG, and compact export job polls (honour responseFields on them, explain pending files). Replace the flat interaction default with a restrained recipe per element role, add view transitions by the mechanism the project's Next.js and React versions support, let draft take the agent's own extraQuestions, end the workflow with a submission checklist, and widen devup_ui_validate with focus-style, too-much-motion, icon-button-label (warnings) and button-active, motion-reduce, nav-current (info). Recognise vinext apps (no next dependency) across bun workspaces and report frameworks per app, decide view transitions for vinext with React 19.3 from the installed versions, run the dependency update (bunx @dependency-check-updates/cli -d -u --rm) before implementing and report it, start new projects from dev-five-git/service-template, ask once whether a next-CLI app migrates to vinext, and align devfive-frontend with service-template's AGENTS.md.", + "note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen under the linked node is mapped to an evidence-backed URL and confirmed in questions asked up front, never during implementation; keep the plan inside the linked scope and out of trash, archive, draft, copy and hidden areas (listed and confirmed, overridable with excludeKeywords/includeKeywords); ask cross-section and other structural ambiguities explicitly with a fix-the-Figma option whose frames are reported, not built; surface layer-name guesses; honour .devup/screen-plan.config.json; gate page-writing exports on the confirmed plan and refuse excluded or design-fix frames; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill. Add a site-completeness step: the plan records the defaults the agent fills in (interaction states from existing tokens, one shared transition, reduced motion, metadata, lang, alt, not-found) and asks only for files and facts (favicon, share image, site name and URL, analytics, motion); status reports site completeness and devup_ui_validate warns on stateless controls, pages without metadata and a missing app icon. Add the /devup entry point, installed as a skill (Claude Code) and an OpenCode command. Keep multi-colour icons as instead of a single-colour mask, warn multicolor-mask in devup_ui_validate when a maskImage would flatten a multi-colour SVG, and compact export job polls (honour responseFields on them, explain pending files). Replace the flat interaction default with a restrained recipe per element role, add view transitions by the mechanism the project's Next.js and React versions support, let draft take the agent's own extraQuestions, end the workflow with a submission checklist, and widen devup_ui_validate with focus-style, too-much-motion, icon-button-label (warnings) and button-active, motion-reduce, nav-current (info). Recognise vinext apps (no next dependency) across bun workspaces and report frameworks per app, decide view transitions for vinext with React 19.3 from the installed versions, run the dependency update (bunx @dependency-check-updates/cli -d -u --rm) before implementing and report it, start new projects from dev-five-git/service-template, ask once whether a next-CLI app migrates to vinext, and align devfive-frontend with service-template's AGENTS.md. Turn view transitions on for every project (opt out with viewTransitions: false) and prove them with a new runtime-audit view-transition check, and pick the target app of a multi-app repository by meaning - public site vs admin console, per screen in a mixed file - instead of name order.", "date": "2026-10-17T00:00:00+09:00" } diff --git a/.gitignore b/.gitignore index b8d20e7f..332d51df 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ /docs/ /feedback.txt /devup-mcp-feedback/ + +# Installed by `npm ci` in harness/runtime-audit (and plugin/) to run the +# executor locally; never part of the tree. +node_modules/ diff --git a/README.md b/README.md index bd51a8da..4769f7b0 100644 --- a/README.md +++ b/README.md @@ -1066,7 +1066,7 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 사이트 완성도: Figma가 그린 것만으로 만든 사이트는 hover·focus·전환·metadata·아이콘이 없어 "죽어" 보입니다. 그래서 계획은 둘로 나눕니다. **에이전트가 묻지 않고 채우는 것**(`defaults`에 기록, 최종 보고에 표시): 모든 링크·버튼·카드·탭·입력의 `_hover`·`_focusVisible`·`_active`·`_disabled`·`cursor`(Figma 상태 variant 우선, 없으면 기존 테마 토큰으로만 만든 작은 대체 — 새 색은 만들지 않음), 한 번만 정의한 약 0.2s `transition`, `_motionReduce`, 루트·페이지별 `metadata`(openGraph 포함, devfive-frontend 규칙), ``, alt, 이미지 크기, not-found 페이지. **같은 사전 질문 라운드에서 묻는 것**: 파비콘·공유(OG) 이미지(기존 파일, 범위 안의 Figma 로고/og 노드, 직접 제공 중에서 — 직접 그리거나 생성하지 않음), 프로젝트에 없을 때만 사이트 이름·운영 URL, 흔적이 있을 때만 분석/인증 태그, hover 이상의 모션("None" 추천). `.devup/screen-plan.config.json`의 `favicon`·`ogImage`·`siteName`·`siteUrl`·`motion`으로 미리 답할 수 있습니다. `status`는 파비콘, URL별 metadata, OG 이미지, hover/focus 적용 비율, reduced-motion을 filled / asked-and-answered / missing으로 보고하고, `devup_ui_validate`는 상태 없는 상호작용 요소(`interactive-states`), transition 없는 `_hover`(`hover-transition`), metadata 없는 page/layout(`page-metadata`), projectRoot에 아이콘이 없는 앱(`app-icon`)을 알립니다. -상호작용은 역할별 레시피로 채웁니다(버튼·보조 버튼·텍스트 링크·링크 카드·내비/탭·아이콘 버튼·입력·아코디언·모바일 메뉴마다 hover, pressed, focus 링, 현재 페이지(`aria-current`), 펼침(`aria-expanded`), disabled, loading). 절제가 규칙입니다: Figma 상태 variant 우선, 기존 토큰만, 상태당 속성 2개 이하, 150–250ms 한 번 정의, bounce·무한 애니메이션 금지, 이동 4px·scale 0.97–1.05 이내, `_motionReduce`에서 모두 끔, 상호작용 없는 요소는 hover 없음. 페이지 전환(View Transitions)은 프로젝트의 Next.js·React 버전으로 정합니다: Next 16 + React 19.3 이상은 `import { ViewTransition } from 'react'`(설정 불필요), Next 15.2 이상은 `experimental.viewTransition`(실험 기능이라 가정으로 기록), Next가 없으면 CSS `@view-transition`, 그 밖에는 이유와 함께 건너뜀. 짧은 cross-fade, reduced-motion에서 끔. 에이전트 자신의 질문은 `extraQuestions`로 같은 사전 라운드에 넣고, 끝은 제출 체크리스트(모든 URL 구현, build·lint·typecheck 통과, validate 오류 없음, 모든 폭에서 페이지 오류·가로 넘침 없음, 사이트 완성도 채움, 최종 표)입니다. +상호작용은 역할별 레시피로 채웁니다(버튼·보조 버튼·텍스트 링크·링크 카드·내비/탭·아이콘 버튼·입력·아코디언·모바일 메뉴마다 hover, pressed, focus 링, 현재 페이지(`aria-current`), 펼침(`aria-expanded`), disabled, loading). 절제가 규칙입니다: Figma 상태 variant 우선, 기존 토큰만, 상태당 속성 2개 이하, 150–250ms 한 번 정의, bounce·무한 애니메이션 금지, 이동 4px·scale 0.97–1.05 이내, `_motionReduce`에서 모두 끔, 상호작용 없는 요소는 hover 없음. 페이지 전환(View Transitions)은 항상 켭니다: 의존성 업데이트가 먼저 돌아 항상 최신 Next·React이므로 버전을 보지 않고 `import { ViewTransition } from 'react'`를 각 `page.tsx`에 씁니다(짧은 cross-fade, reduced-motion에서 끔). 설정 파일의 `viewTransitions: false`나, `updateDependencies: false`로 React 19.3 미만에 머문 경우에만 건너뜁니다. 동작은 의심하지 않고 검증합니다: runtime audit의 `view-transition` 검사가 링크를 따라가 `document.startViewTransition` 호출(또는 cross-document `pagereveal`)을 확인하고, 실패는 고칠 버그입니다. 저장소에 앱이 여럿이면 이름 순서가 아니라 의미로 고릅니다: 마케팅 화면은 공개 사이트(`apps/front`), "Admin - 회원관리" 같은 화면은 `apps/admin`, 섞인 파일은 화면마다 앱을 정하고(route 표의 `app` 열) 애매한 화면만, 아무 단서가 없을 때만 공개 사이트를 추천하며 묻습니다. 에이전트 자신의 질문은 `extraQuestions`로 같은 사전 라운드에 넣고, 끝은 제출 체크리스트(모든 URL 구현, build·lint·typecheck 통과, validate 오류 없음, 모든 폭에서 페이지 오류·가로 넘침 없음, 사이트 완성도 채움, 최종 표)입니다. 최신 스택이 기본입니다: Next App Router를 vinext(Vite)로 실행하고, bun workspaces(`apps/*`), devup-ui, oxlint(eslint-plugin-devup), 최신 버전. `vinext` 의존성·`vinext` 스크립트·vinext를 import하는 `vite.config`가 있으면 `next` 의존성이 없어도 App Router 앱으로 보고, `devup_project_context`의 ui/routes가 앱별 `frameworks`를 알려 줍니다. 구현 전에는 묻지 않고 저장소 루트에서 `bunx @dependency-check-updates/cli -d -u --rm`(-d: 루트 아래 모든 manifest, -u: 복구 가능한 일괄 적용, --rm: 각 manifest 옆의 lockfile·설치 디렉터리 삭제 — Cargo.lock·target/ 포함) → `bun install` → build·lint·typecheck를 하고, 라이브러리 API는 기억이 아니라 현재 문서를 봅니다. 결과는 `assume`의 `dependencyUpdate`로 남기고 `status`에 dependencies 줄로 나옵니다. 앱이 없으면 `dev-five-git/service-template`에서 시작하고(이름·구성만 질문), `next` CLI 프로젝트에는 "Migrate to vinext (Recommended) / Keep next"를 한 번 묻습니다. 설정 파일의 `framework`·`updateDependencies: false`로 미리 답할 수 있습니다. diff --git a/crates/devup-mcp/src/server/commands/opencode-devup.md b/crates/devup-mcp/src/server/commands/opencode-devup.md index 2bc0301a..27a1cb50 100644 --- a/crates/devup-mcp/src/server/commands/opencode-devup.md +++ b/crates/devup-mcp/src/server/commands/opencode-devup.md @@ -20,10 +20,10 @@ and the devup-implementation-plan skill for its rules: in one call. Never in prose, never during implementation. 6. `devup_screen_plan` confirm with every answer that applies. 7. Latest first, without asking: at the repository root run - `bunx @dependency-check-updates/cli -d -u --rm` (-d: every manifest below - the root, so apps/* too; -u: apply as one recoverable batch; --rm: remove - lockfiles and installed directories next to each manifest, Cargo.lock and - target/ included), then `bun install`, build, lint and typecheck; fix code + `bunx @dependency-check-updates/cli -d -u --rm` - it updates every + manifest in the repository, every bun workspace and Cargo.toml included, + and --rm removes the lockfiles and installed directories so everything + resolves fresh - then `bun install`, build, lint and typecheck; fix code to the new APIs, pin only when no fix exists, and record it with `devup_screen_plan` assume (dependencyUpdate). Check current docs before using any library API. @@ -34,7 +34,9 @@ and the devup-implementation-plan skill for its rules: transitions by the plan's mechanism, metadata with openGraph, lang, alt text, image sizes, a not-found page. Validate with `devup_ui_validate` and projectRoot. Record anything new with `devup_screen_plan` assume. -9. `devup_runtime_audit` plan at 1440, 1100, 858 and 390, run it, ingest. +9. `devup_runtime_audit` plan at 1440, 1100, 858 and 390 with one + view-transition check (follow a link between two routes), run it, ingest. + A failed view-transition check is a bug to fix. 10. `devup_screen_plan` status with the audit plan and report. 11. Pass the submission checklist: every URL implemented, `bun run build`, `bun run lint`, `bunx oxlint --deny-warnings`, `bun tsc --noEmit` in each diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index bdd66f81..7f89fa71 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -228,8 +228,8 @@ drawn width. Hover plus a transition is not a finished interaction. Each interactive element gets its role's recipe - button, link, card, nav item, icon button, input, disclosure - with pressed, focus, current-page, expanded, disabled and -loading states where they apply, and view transitions between pages where the -project's Next.js and React support them. Restraint is a rule, not a taste: at +loading states where they apply, and view transitions between pages - always +on, and proved by the runtime audit's view-transition check. Restraint is a rule, not a taste: at most two properties per state, 150-250ms, existing tokens only, scale 0.97 to 1.05, nothing infinite, everything off under `_motionReduce`. The table is in the devup-implementation-plan skill; `devup_screen_plan` records it in 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 6a02c4bc..3cddc2d6 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 @@ -79,6 +79,17 @@ So the order is fixed, and none of it is optional. starts from `dev-five-git/service-template` (a default, not a question); one on the `next` CLI is asked once, up front, whether to migrate to vinext - never migrated silently. +- **Pick the app by meaning, not by name order.** In a repository with + several apps, `apps/admin` is the admin console and a Figma design for a + public site is almost never for it. The plan decides from the app's name + (`front`, `web`, `site`, `www`, `landing`, `home` for the public site; + `admin`, `dashboard`, `console`, `backoffice`, `cms`, `manager`, `관리자` for + the console), its routes (sign-in, dashboard) and the frames' own names + (Home, About, 회사소개 vs Admin, 관리자, 회원관리, 대시보드). When the + evidence points one way it takes that app without asking and records why in + `defaults`; a file with both maps each screen to its app (the route table's + `app` column) and asks only about screens of unclear side; only frames that + say nothing either way are asked once, recommending the public app. - **Never invent a route.** A route comes from an existing page built from the frame, a nav link already bound to an href, an explicit `navigationTargets` entry, the project's `routeAliases`, or the user's answer. A route inferred @@ -141,40 +152,36 @@ recipe each element got. ### View transitions -On by default where the stack supports them, as progressive enhancement - -browsers without support simply navigate. The plan reads the Next.js and React -versions (package-lock.json first, then package.json) and records the -mechanism in `defaults`: - -- **Next 16+ with React 19.3+**: `import { ViewTransition } from 'react'`, no - `next.config` flag; wrap each page's content in its `page.tsx` (not the - layout, which persists across navigations). -- **Next 15.2+ with an older React**: `experimental.viewTransition: true` and - `unstable_ViewTransition`. The Next.js docs mark it experimental; record an - assumption. -- **vinext (the default stack)**, after the dependency step: vinext 1.0+ with - React 19.3+ (react-dom / react-server-dom-webpack count) uses `import { - ViewTransition } from 'react'` in each `page.tsx`, with `experimental: { - viewTransition: true }` in `vinext({ nextConfig })` as vinext's own example - does. `vinext check` still labels that import partial, so the runtime audit - confirms the cross-fade animates. Status decides again from the versions - installed then. Below those versions: skipped until the update. -- The `next` CLI, in existing projects only: -- **No Next.js** (whole-document navigations): `@view-transition { navigation: - auto; }` with a `::view-transition-old/new(root)` cross-fade. -- **Older Next, or a version the project does not name**: skipped, with the - reason. App Router navigations stay in one document, where the CSS rule does - nothing. - -Always a short cross-fade (about 250ms), no big slides, `animation-duration: 0s` -under `prefers-reduced-motion`, and `::view-transition { pointer-events: none }`. -Name shared elements only where the same element is on both pages - a page -title, a hero image, a card image and its detail page: a static name as the -devup-ui `viewTransitionName` prop, a per-record name on `` (devup-ui extracts style props at build time, so a runtime name cannot -be a style prop). If the project already animates pages with its own library -(framer-motion, next-view-transitions, ...), the plan asks instead. The "motion -beyond hover" question is about scroll reveal only. +Always on. DevFive runs the latest versions - the dependency step comes +first - and the latest Next App Router and React support React's +`` on vinext and on the `next` CLI alike, so no version is +checked. View Transitions are a web standard: browsers without them simply +navigate. + +- `import { ViewTransition } from 'react'`, wrapping each page's content in + its `page.tsx` (not the layout, which persists across navigations). +- A short cross-fade (about 250ms), no big slides, `animation-duration: 0s` + under `prefers-reduced-motion`, and `::view-transition { pointer-events: + none }`. +- Name shared elements only where the same element is on both pages - a page + title, a hero image, a card image and its detail page: a static name as the + devup-ui `viewTransitionName` prop, a per-record name on `` (devup-ui extracts style props at build time, so a runtime name + cannot be a style prop). +- **Verify it, do not doubt it.** The runtime audit plan gets one + `view-transition` check: on a screen with a link to another route, `{"kind": + "view-transition", "subject": }`. The executor follows the link + and passes only when a view transition ran (`document.startViewTransition` + called, or a cross-document `pagereveal` carrying one). A failure is a bug to + fix and re-run, not an assumption. `experimental.viewTransition` goes into + the next config only if that check fails without it (vinext's own example + sets it). + +Skipped only when `.devup/screen-plan.config.json` opts out +(`viewTransitions: false`), or `updateDependencies: false` leaves the project +on a React older than 19.3. If the project already animates pages with its own +library (framer-motion, next-view-transitions, ...), the plan asks instead. +The "motion beyond hover" question is about scroll reveal only. **Asked, in the same up-front rounds** - only what needs a file or a fact: the favicon and the share image (offered from existing files and Figma logo @@ -219,14 +226,12 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. that applies. A refusal names the missing ids and returns them as rounds: ask those right away, still before any implementation. 5. **Latest first, before implementing, without asking.** - a. At the repository root (the workspace root for bun workspaces), run - `bunx @dependency-check-updates/cli -d -u --rm`. `-d` scans every - manifest below the root recursively, which is how the `apps/*` - workspaces are reached; `-u` applies the updates as one recoverable - batch (`--recover rollback` restores the originals); `--rm` removes the - lockfiles and installed directories next to each manifest - `bun.lock` - and `node_modules/`, and for a `Cargo.toml` also `Cargo.lock` and - `target/` - so the next install resolves everything fresh. + 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. @@ -249,8 +254,9 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. it is never built, and no assumption changes that. 7. **Run the runtime audit.** `devup_runtime_audit` `plan` with every confirmed URL as a screen (`routePath`) at the default widths (1440, 1100, 858, 390), - each with a `no-horizontal-overflow` check; run the executor in the - project, then `ingest` the report. + each with a `no-horizontal-overflow` check, and one `view-transition` check + following a link between two routes; run the executor in the project, then + `ingest` the report. 8. **Read the status.** Call `devup_screen_plan` with `action: "status"`, `projectRoot`, `auditPlanPath` and `auditReportPath`. A width without an audit item is `not-run`, never `pass`. @@ -266,7 +272,7 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. Windows - there, run `bun test` and `cargo test`; - `devup_ui_validate` reports no errors; - the runtime audit at every width shows no page error and no horizontal - overflow; + overflow, and its view-transition check passes; - the site completeness block is filled; - the final table `URL | Figma screen | file | status | widths verified | screenshots` is there, with the assumptions, the design fixes requested and @@ -340,6 +346,6 @@ evidence and replaces the question it would otherwise take: - `includeKeywords` forces names containing them back in. - `favicon`, `ogImage` (a path, or `figma:`), `siteName`, `siteUrl` and `motion` (`none` | `reveal`) pre-answer the site questions. -- `framework` (`vinext` | `next`) answers the migrate-to-vinext question, and +- `framework` (`vinext` | `next`) answers the migrate-to-vinext question, `updateDependencies: false` skips the dependency step for a project that - forbids it. + forbids it, and `viewTransitions: false` opts out of view transitions. diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index 8583fa92..cbeb8528 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -29,7 +29,9 @@ the full rules; these are the steps. 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`. + starts from `dev-five-git/service-template`. With several apps the plan + picks the one the frames are for by meaning - the public site for + marketing screens, `apps/admin` only for admin screens. 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 @@ -44,14 +46,12 @@ the full rules; these are the steps. 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 (the workspace root for bun workspaces), run - `bunx @dependency-check-updates/cli -d -u --rm`. `-d` scans every - manifest below the root recursively, which is how the `apps/*` - workspaces are reached; `-u` applies the updates as one recoverable - batch (`--recover rollback` restores the originals); `--rm` removes the - lockfiles and installed directories next to each manifest - `bun.lock` - and `node_modules/`, and for a `Cargo.toml` also `Cargo.lock` and - `target/` - so the next install resolves everything fresh. + 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. @@ -72,7 +72,9 @@ the full rules; these are the steps. take the recommended or most conservative option and record it with `action: "assume"`. 9. **Runtime audit.** `devup_runtime_audit` `plan` with every confirmed URL at - 1440, 1100, 858 and 390, run the executor in the project, then `ingest`. + 1440, 1100, 858 and 390 and one `view-transition` check (follow a link + between two routes; it fails when no transition ran - a bug to fix), run + the executor in the project, then `ingest`. 10. **Status.** `devup_screen_plan` with `action: "status"`, `auditPlanPath` and `auditReportPath`. 11. **Submission checklist.** Before reporting done, every item holds - fix @@ -87,7 +89,7 @@ the full rules; these are the steps. Windows - there, run `bun test` and `cargo test`; - `devup_ui_validate` reports no errors; - the runtime audit at every width shows no page error and no horizontal - overflow; + overflow, and its view-transition check passes; - the site completeness block is filled; - the final table `URL | Figma screen | file | status | widths verified | screenshots` is there, with the assumptions, the design fixes requested and diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index eaf665b6..f7b2b083 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": 20903, - "sha256": "9c9066ea3413c7ff929ed678147efda6e9ead4fe39a06a71ab840b13664cc0c2" + "bytes": 21454, + "sha256": "326018983631b36e5da35e75770b9565a8f7a8d56afe69d956287b53e25cdcf1" } ], "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": 5965, - "sha256": "31e69adb4219b8e9af96d8dc368c1f7d436602da22f7d169d475e1e9d905e960" + "bytes": 6152, + "sha256": "3ff9b9e4676905004f3d3e80f952e5d72b713daadcb02ec6b4ae00fb8572c6e7" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", From 5771a51ea0c4d9f88da2e773dfd0ed85bc5830f1 Mon Sep 17 00:00:00 2001 From: leader Date: Sun, 18 Oct 2026 00:15:26 +0900 Subject: [PATCH 20/24] Add page-health, bounds, clipped-text, interaction and capture audit checks The runtime audit executor now judges page health (error page text, page and console errors), size and position against Figma geometry, text cut off by its box, hover and focus states (one element or a sweep of every link and button) and element captures for visual comparison. View-transition checks move behind their screen's other checks when a plan is written, and the executor returns to the screen afterwards. Exports into a project mark the page root, its sections, their children and every asset with data-figma-node-id, and devup_visual_compare can write its diff PNG to a path. Co-Authored-By: Claude Opus 5.5 --- crates/devup-mcp-devup-ui/src/codegen/mod.rs | 1 + .../src/codegen/node_ids.rs | 127 ++++++++++ crates/devup-mcp/src/server/projection.rs | 61 +++++ crates/devup-mcp/src/server/runtime_audit.rs | 216 +++++++++++++++++- crates/devup-mcp/src/server/screen_plan.rs | 21 ++ crates/devup-mcp/src/server/tools.rs | 4 + crates/devup-mcp/src/server/visual_compare.rs | 32 ++- .../devup-mcp/tests/export_project_theme.rs | 49 ++++ harness/runtime-audit/checks.mjs | 107 +++++++++ harness/runtime-audit/fixtures/broken.html | 5 + harness/runtime-audit/fixtures/layout.html | 32 +++ harness/runtime-audit/run.mjs | 136 ++++++++++- harness/runtime-audit/test/browser.test.mjs | 57 +++++ harness/runtime-audit/test/checks.test.mjs | 47 +++- 14 files changed, 886 insertions(+), 9 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/src/codegen/node_ids.rs create mode 100644 harness/runtime-audit/fixtures/broken.html create mode 100644 harness/runtime-audit/fixtures/layout.html diff --git a/crates/devup-mcp-devup-ui/src/codegen/mod.rs b/crates/devup-mcp-devup-ui/src/codegen/mod.rs index a7ebe375..e1c10b4c 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/mod.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/mod.rs @@ -4,6 +4,7 @@ mod component; mod evidence; mod image; mod layout; +pub mod node_ids; mod property_derivations; pub(crate) use property_derivations::property_derivations; pub mod responsive; diff --git a/crates/devup-mcp-devup-ui/src/codegen/node_ids.rs b/crates/devup-mcp-devup-ui/src/codegen/node_ids.rs new file mode 100644 index 00000000..1228ae48 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/node_ids.rs @@ -0,0 +1,127 @@ +//! Stable Figma node identity on the rendered DOM, so a runtime audit can +//! find a section or an asset by the node it was generated from instead of +//! by a guessed selector. +use std::collections::BTreeSet; + +use crate::codegen::CodegenOutput; +use crate::provenance::attributes::opening_elements; + +pub const NODE_ID_ATTRIBUTE: &str = "data-figma-node-id"; + +/// Writes `data-figma-node-id=""` on the opening element generated for +/// each of `node_ids`, found through the source map, and returns the ids it +/// wrote. An element that already carries the attribute is left alone. The +/// source map's internal ranges are shifted with the text, so later checks +/// against this output still line up. +pub fn annotate_node_ids(output: &mut CodegenOutput, node_ids: &BTreeSet) -> Vec { + let elements = opening_elements(&output.tsx); + let mut targets = Vec::<(usize, String)>::new(); + for entry in &output.source_map.entries { + let (Some(node_id), Some(ordinal)) = (&entry.node_id, entry.element_index) else { + continue; + }; + if !node_ids.contains(node_id) || targets.iter().any(|(_, id)| id == node_id) { + continue; + } + if targets.iter().any(|(taken, _)| *taken == ordinal) { + continue; + } + targets.push((ordinal, node_id.clone())); + } + let mut inserts = Vec::new(); + for (ordinal, node_id) in &targets { + let Some(element) = elements.iter().find(|element| element.ordinal == *ordinal) else { + continue; + }; + let Some(opening) = output.tsx.get(element.start..element.end) else { + continue; + }; + if opening.contains(NODE_ID_ATTRIBUTE) || !opening.starts_with('<') { + continue; + } + let name_end = opening[1..] + .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '.' | ':' | '-' | '$'))) + .map_or(opening.len(), |offset| offset + 1); + if name_end <= 1 { + continue; + } + inserts.push(( + element.start + name_end, + format!(" {NODE_ID_ATTRIBUTE}=\"{}\"", node_id.replace('"', "")), + node_id.clone(), + )); + } + inserts.sort_by_key(|insert| std::cmp::Reverse(insert.0)); + let mut written = Vec::new(); + for (at, text, node_id) in inserts { + output.tsx.insert_str(at, &text); + for entry in &mut output.source_map.entries { + if let Some(range) = &mut entry.generated_range { + if range.start >= at { + range.start += text.len(); + } + if range.end >= at { + range.end += text.len(); + } + } + } + written.push(node_id); + } + written.sort(); + written +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::codegen::{CodegenOptions, generate_component}; + use devup_mcp_figma::Snapshot; + use serde_json::json; + + #[test] + fn the_root_and_each_section_get_their_node_id_and_ranges_follow() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey": "ids", "roots": ["p"], "diagnostics": [], "nodes": { + "p": {"id": "p", "type": "FRAME", "fields": {"layoutMode": "VERTICAL", "width": 400, "height": 300, + "childrenIds": ["a", "b"]}}, + "a": {"id": "a", "type": "FRAME", "fields": {"parentId": "p", "layoutMode": "HORIZONTAL", + "width": 400, "height": 100, "childrenIds": ["at"]}}, + "at": {"id": "at", "type": "TEXT", "fields": {"parentId": "a", "characters": "Hello", + "fontSize": 16, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}}, + "b": {"id": "b", "type": "FRAME", "fields": {"parentId": "p", "layoutMode": "HORIZONTAL", + "width": 400, "height": 200, "childrenIds": ["bt"]}}, + "bt": {"id": "bt", "type": "TEXT", "fields": {"parentId": "b", "characters": "World", + "fontSize": 16, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}} + } + })) + .unwrap(); + let mut output = generate_component(&snapshot, "p", &CodegenOptions::default()).unwrap(); + let before = output.tsx.clone(); + let wanted = ["p", "a", "b"].map(String::from).into_iter().collect(); + let written = annotate_node_ids(&mut output, &wanted); + assert_eq!(written, ["a", "b", "p"], "{before}"); + for id in ["p", "a", "b"] { + assert!( + output + .tsx + .contains(&format!("{NODE_ID_ATTRIBUTE}=\"{id}\"")), + "{}", + output.tsx + ); + } + let elements = opening_elements(&output.tsx); + for entry in &output.source_map.entries { + if let (Some(range), Some(ordinal)) = (&entry.generated_range, entry.element_index) { + let element = elements.iter().find(|e| e.ordinal == ordinal).unwrap(); + assert!( + element.start <= range.start && range.end <= element.end, + "{entry:?}" + ); + } + } + assert!( + annotate_node_ids(&mut output, &wanted).is_empty(), + "a second pass writes nothing" + ); + } +} diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 3335dbbb..f92fc795 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -1462,6 +1462,61 @@ fn projection_failure( "retryable":error.retryable,"details":details}) } +/// Marks the page root, its top-level children (the sections), their +/// children and every asset with `data-figma-node-id`, so the runtime audit captures a section +/// or an asset by the node it came from. Only for exports into a project +/// (`projectRoot`); `.devup/screen-plan.config.json` `figmaNodeIds: false` +/// turns it off. +fn annotate_node_ids( + output: &mut devup_mcp_devup_ui::codegen::CodegenOutput, + snapshot: &devup_mcp_figma::Snapshot, + root_id: &str, +) -> Value { + let sections = snapshot + .nodes + .get(root_id) + .map(|node| { + node.typed_view() + .child_ids() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + // A section's own children (the cards of a card row) too, so their + // order and position are checked against the frame. + let parts = sections + .iter() + .filter_map(|id| snapshot.nodes.get(id)) + .flat_map(|node| { + node.typed_view() + .child_ids() + .map(str::to_owned) + .collect::>() + }) + .collect::>(); + let assets = output + .source_map + .entries + .iter() + .filter(|entry| entry.asset_id.is_some()) + .filter_map(|entry| entry.node_id.clone()) + .collect::>(); + let wanted = std::iter::once(root_id.to_owned()) + .chain(sections.iter().cloned()) + .chain(parts.iter().cloned()) + .chain(assets.iter().cloned()) + .collect(); + let written = devup_mcp_devup_ui::codegen::node_ids::annotate_node_ids(output, &wanted); + json!({ + "attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE, + "root": written.iter().any(|id| id == root_id).then_some(root_id), + "sections": sections.iter().filter(|id| written.contains(id)).collect::>(), + "parts": parts.iter().filter(|id| written.contains(id)).collect::>(), + "assets": assets.iter().filter(|id| written.contains(id)).collect::>(), + "note": "Inert data attributes the runtime audit locates sections and assets by. Keep them; to drop them from a production build use Next's compiler.reactRemoveProperties {properties: [\"^data-figma-node-id$\"]}, or set figmaNodeIds false in .devup/screen-plan.config.json and re-export.", + }) +} + fn generate_collected_component( payload: &CollectedPayload, node_id: &str, @@ -2744,6 +2799,12 @@ async fn project_operation( 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::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()); diff --git a/crates/devup-mcp/src/server/runtime_audit.rs b/crates/devup-mcp/src/server/runtime_audit.rs index c484dda1..0711badb 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -29,7 +29,9 @@ const SCHEMA_VERSION: u64 = 1; const MAX_VIEWPORTS: usize = 8; const MAX_SCREENS: usize = 60; const MAX_STEPS: usize = 20; -const MAX_CHECKS: usize = 40; +// A full audit names a bounds, capture and health check per section and +// asset, so a screen carries far more than a hand-written plan would. +const MAX_CHECKS: usize = 200; const MAX_TIMEOUT_MS: u64 = 120_000; const MAX_VIEWPORT_SIDE: u32 = 4096; const MAX_TEXT: usize = 300; @@ -213,8 +215,21 @@ pub struct CheckInput { /// a view transition ran (document.startViewTransition, or a /// cross-document pagereveal transition). It navigates away, so the /// executor runs it after the screen's other checks and screenshot; the - /// next screen should start from its own routePath. - #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition"]))] + /// 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. + /// 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. + /// bounds: subject's size and position relative to container's top-left, + /// in CSS px, against `expected` (the Figma geometry) within tolerancePx. + /// no-clipped-text: no text inside subject (or the page) is cut off by an + /// overflow-hidden box; ellipsis and line clamp are intended and pass. + /// interaction: hovering or keyboard-focusing subject (`state`) changes + /// 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"]))] pub kind: String, #[serde(default)] pub subject: Option, @@ -241,6 +256,47 @@ pub struct CheckInput { /// intermediate widths are never interpolated or inferred from screenshots. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub figma_dimensions: Vec, + /// bounds: the expected box relative to container, in CSS px. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected: Option, + /// interaction: hover or focus (keyboard, so :focus-visible applies). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(extend("enum" = ["hover", "focus"]))] + pub state: Option, + /// Only at these viewport widths; empty means every width. A bounds or + /// capture check holds Figma geometry, which exists only where the frame + /// was drawn. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub widths: Vec, + /// The Figma node this check is about, so a failure names it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + /// capture: what the element screenshot is compared with. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct BoundsInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// A native-node PNG of the same Figma node, captured by devup_figma_export +/// (assetCaptures, renderMode node, format png, scale 1). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct CaptureReferenceInput { + /// section: a top-level child of the frame. asset: an image or icon. + #[schemars(extend("enum" = ["section", "asset"]))] + pub scope: String, + pub artifact_id: String, + pub asset_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -269,6 +325,21 @@ pub struct ReadinessInput { pub struct FigmaAnchorInput { pub node_id: String, pub drawn_widths: Vec, + /// The frame drawn at each width when a page has one per width, with + /// the devup_figma_export artifact holding its referencePng. Ingest puts + /// that artifactId in the page comparison it returns. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub frames: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct FrameReferenceInput { + pub width: u32, + pub node_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_id: Option, } fn invalid(message: impl Into) -> DevupError { @@ -527,8 +598,53 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { Ok(()) }; match check.kind.as_str() { - "no-horizontal-overflow" => needs(false, false)?, + "no-horizontal-overflow" | "page-health" => needs(false, false)?, "visible" | "view-transition" => needs(true, false)?, + "no-clipped-text" => { + if check.container.is_some() { + return Err(invalid("A no-clipped-text check takes no container.")); + } + } + "bounds" => { + needs(true, true)?; + let Some(expected) = &check.expected else { + return Err(invalid( + "A bounds check needs expected {x, y, width, height}.", + )); + }; + if [expected.x, expected.y, expected.width, expected.height] + .iter() + .any(|value| !value.is_finite() || value.abs() > 100_000.0) + || expected.width < 0.0 + || expected.height < 0.0 + { + return Err(invalid( + "bounds expected values must be finite px, width and height nonnegative.", + )); + } + } + "interaction" => { + // Without a subject it sweeps every visible link and button. + if check.container.is_some() { + return Err(invalid("An interaction check takes no container.")); + } + if !matches!(check.state.as_deref(), Some("hover" | "focus")) { + return Err(invalid("An interaction check needs state hover or focus.")); + } + } + "capture" => { + needs(true, false)?; + let Some(reference) = &check.reference else { + return Err(invalid( + "A capture check needs reference {scope, artifactId, assetId}.", + )); + }; + if !matches!(reference.scope.as_str(), "section" | "asset") { + return Err(invalid("capture reference.scope must be section or asset.")); + } + bounded_text("reference.artifactId", &reference.artifact_id)?; + bounded_text("reference.assetId", &reference.asset_id)?; + } "dimensions" => { needs(true, false)?; if check.tolerance_px.is_none() @@ -551,6 +667,18 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { } other => return Err(invalid(format!("Unknown check kind {other}."))), } + for (field, present, kind) in [ + ("expected", check.expected.is_some(), "bounds"), + ("state", check.state.is_some(), "interaction"), + ("reference", check.reference.is_some(), "capture"), + ] { + if present && check.kind != kind { + return Err(invalid(format!("{field} is only valid for {kind} checks."))); + } + } + if let Some(node_id) = &check.node_id { + bounded_text("checks[].nodeId", node_id)?; + } if check.kind != "dimensions" && !check.figma_dimensions.is_empty() { return Err(invalid( "figmaDimensions is only valid for dimensions checks.", @@ -675,17 +803,30 @@ async fn dimension_references(input: &PlanInput, artifacts: &ArtifactStore) -> V } pub(crate) async fn create_plan( - input: PlanInput, + mut input: PlanInput, policy: &OutputPolicy, output_path: Option<&str>, template_path: Option<&str>, artifacts: &ArtifactStore, ) -> Result { + order_checks(&mut input); validate_plan(&input)?; let references = dimension_references(&input, artifacts).await; write_plan(input, policy, output_path, template_path, references) } +/// A view-transition check clicks away from its screen, so every one moves +/// behind the screen's other checks, keeping their order otherwise. The +/// executor also returns to the screen's URL after it; together a plan +/// cannot measure the wrong page whatever order it was written in. +pub(crate) fn order_checks(input: &mut PlanInput) { + for screen in &mut input.screens { + screen + .checks + .sort_by_key(|check| check.kind == "view-transition"); + } +} + fn write_plan( input: PlanInput, policy: &OutputPolicy, @@ -1891,6 +2032,71 @@ mod tests { ); } + #[test] + fn the_new_check_kinds_take_what_they_measure() { + let ok = |check: Value| { + let mut plan = plan_input(); + plan.screens[0] + .checks + .push(serde_json::from_value(check).unwrap()); + validate_plan(&plan).map_err(|error| error.message) + }; + let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); + assert!(ok(json!({"kind": "page-health"})).is_ok()); + assert!(ok(json!({"kind": "no-clipped-text"})).is_ok()); + assert!(ok(json!({"kind": "interaction", "state": "hover"})).is_ok()); + assert!( + ok( + json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1"), + "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "widths": [1440]}) + ) + .is_ok() + ); + assert!( + ok(json!({"kind": "capture", "subject": node("1:2"), + "reference": {"scope": "asset", "artifactId": "a", "assetId": "b"}})) + .is_ok() + ); + assert!( + ok(json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1")})) + .unwrap_err() + .contains("expected") + ); + assert!( + ok(json!({"kind": "interaction"})) + .unwrap_err() + .contains("hover or focus") + ); + assert!( + ok(json!({"kind": "capture", "subject": node("1:2")})) + .unwrap_err() + .contains("reference") + ); + assert!( + ok(json!({"kind": "visible", "subject": node("1:2"), "state": "hover"})) + .unwrap_err() + .contains("only valid for interaction") + ); + } + + #[test] + fn view_transition_checks_move_behind_the_rest_of_their_screen() { + let mut plan = plan_input(); + plan.screens[0].checks = serde_json::from_value(json!([ + {"kind": "view-transition", "subject": {"testId": "go"}, "label": "vt"}, + {"kind": "page-health", "label": "health"}, + {"kind": "no-horizontal-overflow", "label": "overflow"}, + ])) + .unwrap(); + order_checks(&mut plan); + let labels = plan.screens[0] + .checks + .iter() + .map(|check| check.label.clone().unwrap()) + .collect::>(); + assert_eq!(labels, ["health", "overflow", "vt"]); + } + #[test] fn a_plan_is_identified_by_its_content_and_refuses_what_is_not_declarative() { let first = plan_id(&plan_input()).unwrap(); diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index d181372f..c52b2f3b 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -308,6 +308,27 @@ pub(super) struct PlanConfig { /// false opts the project out of view transitions. #[serde(default)] pub view_transitions: Option, + /// false stops exports into this project from writing + /// data-figma-node-id on the page, section and asset elements. + #[serde(default)] + pub figma_node_ids: Option, + /// How many fix-and-rerun rounds one audit finding gets before it is + /// recorded as unresolved with its evidence; default 3. + #[serde(default)] + pub max_fix_attempts: Option, +} + +/// Whether exports into this project mark their elements with +/// data-figma-node-id: on unless the project config says false. An +/// unreadable config leaves it on; draft reports the config error. +pub(super) fn node_ids_enabled(project_root: &str) -> bool { + !matches!( + read_config(project_root), + Ok(Some(PlanConfig { + figma_node_ids: Some(false), + .. + })) + ) } fn read_config(project_root: &str) -> Result, DevupError> { diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index e46f05b5..6b94cb80 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -512,6 +512,10 @@ pub struct VisualCompareInput { /// Include the red diff PNG using the standard binary delivery limits. #[serde(default)] pub include_diff: bool, + /// Also write the diff PNG to this path under an allowed root, as the + /// evidence an audit finding cites. + #[serde(default)] + pub diff_path: Option, #[serde(default = "default_delivery")] #[schemars(extend("enum" = super::validation::DELIVERY_MODES))] pub delivery: String, diff --git a/crates/devup-mcp/src/server/visual_compare.rs b/crates/devup-mcp/src/server/visual_compare.rs index 0b331c32..f07fa072 100644 --- a/crates/devup-mcp/src/server/visual_compare.rs +++ b/crates/devup-mcp/src/server/visual_compare.rs @@ -282,7 +282,12 @@ pub(super) async fn compare( "WebP actuals require a native PNG asset reference (artifactId plus assetId). Page/card screenshots and their environment checks continue to require PNG.", )); } - let include_diff = input.include_diff; + let diff_target = input + .diff_path + .as_deref() + .map(|path| policy.resolve(path)) + .transpose()?; + let include_diff = input.include_diff || diff_target.is_some(); let reference_hash = hash(&reference); let (report, diff) = tokio::task::spawn_blocking(move || { let actual = if webp_actual { @@ -362,6 +367,14 @@ pub(super) async fn compare( if asset_comparison { result["canvasFidelity"] = json!("unverified-outside-reference-node-subtree"); } + if let (Some(target), Some(bytes)) = (diff_target, diff.as_ref()) { + let display = target.display_path().display().to_string(); + let mut transaction = super::output::OutputTransaction::new(); + transaction.stage("diff", target, bytes)?; + transaction.commit()?; + result["diffPath"] = json!(display); + } + let diff = diff.filter(|_| input.include_diff); if let Some(bytes) = diff { result["diffPng"] = json!({"mimeType":"image/png","dataBase64":STANDARD.encode(&bytes)}); let outputs = vec![ProjectedOutput::binary("diff.png", "image/png", bytes)]; @@ -668,6 +681,23 @@ mod tests { .unwrap(); assert_eq!(r["verdict"], "fail"); assert_eq!(r["visual"]["changedRatio"], 1.0); + assert!(r.get("diffPath").is_none()); + } + #[tokio::test] + async fn a_diff_path_writes_the_diff_png_as_evidence() { + let f = fixture(); + f.png("actual.png", BLACK); + let mut i = input(); + i["threshold"] = json!(0.005); + i["diffPath"] = json!(f.0.join("evidence/diff.png")); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "fail"); + let written = r["diffPath"].as_str().unwrap(); + assert!(written.ends_with("diff.png"), "{r}"); + assert!(f.0.join("evidence/diff.png").exists()); + assert!(r.get("diffPng").is_none(), "includeDiff was not asked for"); } #[tokio::test] async fn out_of_root_actual_rejected() { diff --git a/crates/devup-mcp/tests/export_project_theme.rs b/crates/devup-mcp/tests/export_project_theme.rs index e2c8e97b..84a589df 100644 --- a/crates/devup-mcp/tests/export_project_theme.rs +++ b/crates/devup-mcp/tests/export_project_theme.rs @@ -221,3 +221,52 @@ async fn an_export_confirms_a_project_that_already_has_the_token() -> anyhow::Re assert_eq!(tsx["ok"], true, "{tsx}"); Ok(()) } + +/// An export into a project marks the elements the runtime audit finds by +/// Figma node; the project config turns it off, and an export with no +/// project never writes it, so plugin-parity output stays byte-equal. +#[tokio::test] +async fn an_export_into_a_project_marks_its_elements_with_the_figma_node() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("devup-node-ids-{}", std::process::id())); + fs::create_dir_all(root.join(".devup"))?; + fs::write(root.join("package.json"), r#"{"name":"fixture"}"#)?; + let marked = r#"data-figma-node-id="3879:35481""#; + let result = + export(json!({"url": URL, "outputs": ["tsx"], "projectRoot": root.to_string_lossy()})) + .await?; + assert!( + result["tsx"].as_str().unwrap_or_default().contains(marked), + "{}", + result["tsx"] + ); + assert_eq!( + result["figmaNodeIds"]["root"], "3879:35481", + "{}", + result["figmaNodeIds"] + ); + + fs::write( + root.join(".devup/screen-plan.config.json"), + r#"{"figmaNodeIds": false}"#, + )?; + let result = + export(json!({"url": URL, "outputs": ["tsx"], "projectRoot": root.to_string_lossy()})) + .await?; + assert!( + !result["tsx"] + .as_str() + .unwrap_or_default() + .contains("data-figma-node-id") + ); + assert!(result.get("figmaNodeIds").is_none()); + + let result = export(json!({"url": URL, "outputs": ["tsx"]})).await?; + assert!( + !result["tsx"] + .as_str() + .unwrap_or_default() + .contains("data-figma-node-id") + ); + let _ = fs::remove_dir_all(&root); + Ok(()) +} diff --git a/harness/runtime-audit/checks.mjs b/harness/runtime-audit/checks.mjs index 19b7528e..e1e9eb6b 100644 --- a/harness/runtime-audit/checks.mjs +++ b/harness/runtime-audit/checks.mjs @@ -141,6 +141,113 @@ export function evaluateDimensions(check, measured, reference) { }; } + +/** Text that only an error page shows. */ +export const ERROR_PAGE_TEXT = [ + "This page couldn't load", + 'This page couldn’t load', + 'Application error', + 'Unhandled Runtime Error', + 'Internal Server Error', + 'Something went wrong', +]; + +/** + * A page is healthy when it rendered without an error page, without uncaught + * or console errors and without a failed navigation since it was opened. + * + * @param {object} observed { errorTexts, errors } + */ +export function evaluatePageHealth(observed) { + const problems = [ + ...observed.errorTexts.map((text) => `the page shows "${text}"`), + ...observed.errors.map((error) => `${error.kind}: ${String(error.message).split('\n')[0]}`), + ]; + 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 } }; +} + +/** + * Size and position of the subject relative to its container, against the + * Figma frame's own geometry (expected: { x, y, width, height } in CSS px, + * relative to the container's top-left). + */ +export function evaluateBounds(check, measured) { + const tolerance = check.tolerancePx ?? 2; + const subjectProblem = missing('subject', measured.subjectCount); + if (subjectProblem) return { status: 'inconclusive', reason: subjectProblem, measured: {} }; + const containerProblem = missing('container', measured.containerCount); + if (containerProblem) return { status: 'inconclusive', reason: containerProblem, measured: {} }; + const { subject, container } = measured; + const actual = { + x: subject.x - container.x, + y: subject.y - container.y, + width: subject.width, + height: subject.height, + }; + const expected = check.expected; + const off = (key) => actual[key] - expected[key]; + const position = ['x', 'y'].filter((key) => Math.abs(off(key)) > tolerance); + const size = ['width', 'height'].filter((key) => Math.abs(off(key)) > tolerance); + const evidence = { actual: rounded(actual), expected, tolerancePx: tolerance }; + if (position.length === 0 && size.length === 0) { + return { status: 'pass', reason: 'size and position within tolerance', measured: evidence }; + } + const parts = [ + ...position.map((key) => `${key} off by ${round(off(key))}px`), + ...size.map((key) => `${key} off by ${round(off(key))}px`), + ]; + return { + status: 'fail', + reason: `${position.length ? 'position' : 'size'}: ${parts.join(', ')}`, + measured: { ...evidence, positionOff: position, sizeOff: size }, + }; +} + +/** Text cut off by its own box: clipped overflow, not an intended ellipsis or line clamp. */ +export function evaluateClippedText(observed) { + return observed.clipped.length === 0 + ? { status: 'pass', reason: 'no text is cut off by its box', measured: {} } + : { + status: 'fail', + reason: `text is cut off: ${observed.clipped.slice(0, 3).map((item) => `"${item.text}" in <${item.tag}>`).join(', ')}`, + measured: { clipped: observed.clipped }, + }; +} + +/** Computed style properties a hover or focus state changes. */ +export const STATE_PROPERTIES = [ + 'color', 'backgroundColor', 'borderColor', 'boxShadow', 'outlineStyle', 'outlineWidth', + 'outlineColor', 'textDecorationLine', 'transform', 'opacity', +]; + +/** + * A hover or focus-visible state must change something visible. Without a + * subject the check swept every visible link and button: observed.items + * holds each one, and any that changes nothing fails the check. + */ +export function evaluateInteraction(check, observed) { + if (Array.isArray(observed.items)) { + const changes = (item) => STATE_PROPERTIES.some((property) => item.before[property] !== item.after[property]); + const flat = observed.items.filter((item) => !changes(item)); + if (observed.items.length === 0) return { status: 'pass', reason: 'no links or buttons to check', measured: { checked: 0 } }; + return flat.length === 0 + ? { status: 'pass', reason: `${check.state} changes all ${observed.items.length} links and buttons`, measured: { checked: observed.items.length } } + : { + status: 'fail', + reason: `${check.state} changes nothing on ${flat.length} of ${observed.items.length}: ${flat.slice(0, 5).map((item) => `<${item.tag}> "${item.name}"`).join(', ')}`, + measured: { checked: observed.items.length, unchanged: flat.map((item) => ({ tag: item.tag, name: item.name })) }, + }; + } + const subjectProblem = missing('subject', observed.subjectCount); + if (subjectProblem) return { status: 'inconclusive', reason: subjectProblem, measured: {} }; + const changed = STATE_PROPERTIES.filter((property) => observed.before[property] !== observed.after[property]); + return changed.length > 0 + ? { status: 'pass', reason: `${check.state} changes ${changed.join(', ')}`, measured: { changed } } + : { status: 'fail', reason: `${check.state} changes nothing visible`, measured: { before: observed.before } }; +} + /** Counts recomputed from items, never taken from a declaration. */ /** * A view-transition check: following the subject link from one route to diff --git a/harness/runtime-audit/fixtures/broken.html b/harness/runtime-audit/fixtures/broken.html new file mode 100644 index 00000000..8ea360bc --- /dev/null +++ b/harness/runtime-audit/fixtures/broken.html @@ -0,0 +1,5 @@ + + +broken +

This page couldn't load

+ diff --git a/harness/runtime-audit/fixtures/layout.html b/harness/runtime-audit/fixtures/layout.html new file mode 100644 index 00000000..6c7c8521 --- /dev/null +++ b/harness/runtime-audit/fixtures/layout.html @@ -0,0 +1,32 @@ + + +layout + + +
+
+
+
+ +
+
+ + Plain link +

Text that is far too long

+

Text that is far too long

+
+
+ + diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index a7c77ce2..f34d6226 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -15,7 +15,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { release } from 'node:os'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { evaluateCheck, evaluateDimensions, evaluateViewTransition, summarize } from './checks.mjs'; +import { + ERROR_PAGE_TEXT, STATE_PROPERTIES, evaluateBounds, evaluateCheck, evaluateClippedText, evaluateDimensions, + evaluateInteraction, evaluatePageHealth, evaluateViewTransition, summarize, +} from './checks.mjs'; const PLAN_KIND = 'devup-runtime-audit-plan'; const REPORT_KIND = 'devup-runtime-audit-report'; @@ -170,6 +173,79 @@ async function observeViewTransition(page, check, timeout) { return { subjectCount, supported, navigated, calls, from, to: new URL(page.url()).pathname }; } +// Text an element cuts off itself: overflow hidden or clipped past its box, +// not an intended ellipsis or line clamp. +async function clippedText(page, subject) { + const scope = subject ? locate(page, subject) : page.locator('body'); + if ((await scope.count()) !== 1) return { clipped: [] }; + return scope.evaluate((root) => { + const clipped = []; + for (const element of [root, ...root.querySelectorAll('*')]) { + if (clipped.length >= 10) break; + const own = [...element.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent.trim()).join(' '); + if (!own) continue; + const style = getComputedStyle(element); + if (style.textOverflow === 'ellipsis' || (style.webkitLineClamp && style.webkitLineClamp !== 'none')) continue; + const clips = (value) => value === 'hidden' || value === 'clip'; + const wide = clips(style.overflowX) && element.scrollWidth > element.clientWidth + 1; + const tall = clips(style.overflowY) && element.scrollHeight > element.clientHeight + 1; + if (wide || tall) clipped.push({ text: own.slice(0, 60), tag: element.tagName.toLowerCase() }); + } + return { clipped }; + }); +} + +async function stateStyles(locator) { + return locator.evaluate((element, properties) => { + const style = getComputedStyle(element); + return Object.fromEntries(properties.map((property) => [property, style[property]])); + }, STATE_PROPERTIES); +} + +// Hover moves the mouse over the subject; focus sets keyboard modality first +// so :focus-visible applies, as it does for a keyboard user. Both wait out +// the shared transition before reading the styles. +async function enterState(page, target, state, timeout) { + const before = await stateStyles(target); + if (state === 'focus') { + await page.keyboard.press('Shift'); + await target.focus({ timeout }); + } else { + await target.hover({ timeout }); + } + await page.waitForTimeout(450); + const after = await stateStyles(target); + if (state === 'focus') await target.evaluate((element) => element.blur()); + await page.mouse.move(0, 0); + await page.waitForTimeout(state === 'focus' ? 0 : 300); + return { before, after }; +} + +async function observeInteraction(page, check, timeout) { + if (!check.subject) { + const all = page.locator('a[href], button, [role="button"]'); + const items = []; + const count = Math.min(await all.count(), 40); + for (let index = 0; index < count; index += 1) { + const target = all.nth(index); + if (!(await target.isVisible())) continue; + const box = await target.boundingBox(); + if (!box || box.width === 0 || box.height === 0) continue; + const described = await target.evaluate((element) => ({ + tag: element.tagName.toLowerCase(), + name: (element.getAttribute('aria-label') || element.textContent || '').trim().slice(0, 40), + })); + const states = await enterState(page, target, check.state, timeout).catch(() => null); + if (states) items.push({ ...described, ...states }); + } + return { items }; + } + const target = locate(page, check.subject); + const subjectCount = await target.count(); + if (subjectCount !== 1) return { subjectCount }; + return { subjectCount, ...(await enterState(page, target, check.state, timeout)) }; +} + async function pageMetrics(page) { return page.evaluate(() => { const viewportWidth = window.innerWidth; @@ -181,6 +257,19 @@ async function pageMetrics(page) { if (element.checkVisibility && !element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; const rect = element.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) continue; + // Content inside a horizontal scroll container (a carousel) scrolls + // there; it is not page overflow. The container itself still counts. + let scroller = element.parentElement; + let inScroller = false; + while (scroller && scroller !== document.body) { + const overflowX = getComputedStyle(scroller).overflowX; + if (overflowX === 'auto' || overflowX === 'scroll') { + inScroller = true; + break; + } + scroller = scroller.parentElement; + } + if (inScroller) continue; const right = rect.right + window.scrollX; if (right > maxRight) { maxRight = right; @@ -275,6 +364,7 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { for (const screen of plan.screens) { const record = { id: screen.id, status: 'completed', checks: [] }; run.screens.push(record); + const errorsAtStart = environmentErrors.length; const notMeasured = (status, reason) => { record.checks = screen.checks.map((check, index) => ({ index, kind: check.kind, label: check.label, status, reason, measured: {} })); }; @@ -326,11 +416,45 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { // A view-transition check navigates away, so it runs after every // other check and the screenshot have seen this screen. if (check.kind === 'view-transition') continue; + if (Array.isArray(check.widths) && check.widths.length && !check.widths.includes(viewport.width)) continue; + const item = { index, kind: check.kind, label: check.label }; + if (check.kind === 'page-health') { + const text = await page.evaluate(() => (document.body ? document.body.innerText : '')); + 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 }) }); + continue; + } + if (check.kind === 'no-clipped-text') { + record.checks.push({ ...item, ...evaluateClippedText(await clippedText(page, check.subject)) }); + continue; + } + if (check.kind === 'interaction') { + const observed = await observeInteraction(page, check, stepTimeout).catch((error) => ({ subjectCount: 1, error: String(error?.message ?? error) })); + record.checks.push({ ...item, ...(observed.error + ? { status: 'inconclusive', reason: `the subject could not be ${check.state === 'focus' ? 'focused' : 'hovered'}: ${observed.error.split('\n')[0]}`, measured: {} } + : evaluateInteraction(check, observed)) }); + continue; + } + if (check.kind === 'capture') { + const target = locate(page, check.subject); + const count = await target.count(); + if (count !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: count === 0 ? 'subject matched no element' : `subject matched ${count} elements; a check needs exactly one`, measured: {} }); + continue; + } + const shot = `${viewport.width}x${viewport.height}@${viewport.deviceScaleFactor ?? 1}-${safeName(screen.id)}-c${index}.png`; + const bytes = await target.screenshot({ animations: 'disabled', caret: 'hide' }); + await writeFile(join(out, shot), bytes); + record.checks.push({ ...item, status: 'pass', reason: 'captured for the server to compare with its Figma render', measured: {}, screenshot: { path: shot, sha256: sha256(bytes), bytes: bytes.length } }); + continue; + } const subject = await measureBox(page, check.subject, check.measure); const container = await measureBox(page, check.container); const result = subject.error ? { status: 'inconclusive', reason: subject.error, measured: {} } - : (check.kind === 'dimensions' ? evaluateDimensions : evaluateCheck)(check, { + : (check.kind === 'dimensions' ? evaluateDimensions : check.kind === 'bounds' ? evaluateBounds : evaluateCheck)(check, { page: metrics, subject: subject.box, subjectCount: subject.count, @@ -351,6 +475,8 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { record.screenshot = { path: name, sha256: sha256(bytes), bytes: bytes.length }; for (const [index, check] of screen.checks.entries()) { if (check.kind !== 'view-transition') continue; + if (Array.isArray(check.widths) && check.widths.length && !check.widths.includes(viewport.width)) continue; + const returnTo = page.url(); const observed = await observeViewTransition(page, check, stepTimeout).catch((error) => ({ subjectCount: 1, supported: true, navigated: false, calls: 0, error: String(error?.message ?? error), })); @@ -358,6 +484,12 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { ? { status: 'inconclusive', reason: `the subject could not be followed: ${observed.error.split('\n')[0]}`, measured: {} } : evaluateViewTransition(observed); record.checks.push({ index, kind: check.kind, label: check.label, ...result }); + // Back to where the check started, so a following screen reached by + // steps starts from this screen whatever order the plan has. + if (page.url() !== returnTo) { + await page.goto(returnTo, { waitUntil: 'load', timeout: stepTimeout }).catch(() => {}); + await waitReady(page, plan, stepTimeout).catch(() => {}); + } } record.checks.sort((a, b) => a.index - b.index); } diff --git a/harness/runtime-audit/test/browser.test.mjs b/harness/runtime-audit/test/browser.test.mjs index 5839b7c4..83f420a0 100644 --- a/harness/runtime-audit/test/browser.test.mjs +++ b/harness/runtime-audit/test/browser.test.mjs @@ -256,3 +256,60 @@ test('a view-transition check sees same-document and cross-document transitions, await rm(out, { recursive: true, force: true }); } }); + +test('page health, bounds, clipped text, interaction states and captures in a real browser', async () => { + const server = await serve(); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const out = await mkdtemp(join(tmpdir(), 'devup-runtime-audit-kinds-')); + try { + const planPath = join(out, 'plan.json'); + const node = (id) => ({ css: `[data-figma-node-id="${id}"]` }); + await writeFile(planPath, JSON.stringify({ + schemaVersion: 1, + kind: 'devup-runtime-audit-plan', + planId: 'browser-kinds', + plan: { + baseUrl, + allowedOrigins: [baseUrl], + viewports: [{ width: 1200, height: 800, deviceScaleFactor: 1 }, { width: 800, height: 800, deviceScaleFactor: 1 }], + screens: [ + { id: 'layout', routePath: '/layout.html', checks: [ + { kind: 'page-health', label: 'health' }, + { kind: 'bounds', label: 'card 1', subject: node('1:3'), container: node('1:2'), expected: { x: 20, y: 20, width: 160, height: 100 }, tolerancePx: 2 }, + { kind: 'bounds', label: 'card 2', subject: node('1:4'), container: node('1:2'), expected: { x: 200, y: 20, width: 160, height: 100 }, tolerancePx: 2 }, + { kind: 'no-clipped-text', label: 'text', subject: node('1:6') }, + { kind: 'interaction', label: 'cta hover', state: 'hover', subject: { role: 'button', name: 'Send' } }, + { kind: 'interaction', label: 'cta focus', state: 'focus', subject: { role: 'button', name: 'Send' } }, + { kind: 'interaction', label: 'flat hover', state: 'hover', subject: { role: 'link', name: 'Plain link' } }, + { kind: 'capture', label: 'icon', subject: node('1:5'), widths: [1200] }, + { kind: 'interaction', label: 'sweep', state: 'hover' }, + ] }, + { id: 'broken', routePath: '/broken.html', checks: [{ kind: 'page-health', label: 'health' }] }, + ], + locale: null, + timezone: null, + readiness: null, + stepTimeoutMs: null, + }, + })); + const report = await runPlan({ plan: planPath, out, maxMinutes: 5 }); + const [wide, narrow] = report.runs; + const statuses = (screen) => Object.fromEntries(screen.checks.map((item) => [item.label, item.status])); + assert.deepEqual(statuses(wide.screens[0]), { + health: 'pass', 'card 1': 'pass', 'card 2': 'fail', text: 'fail', + 'cta hover': 'pass', 'cta focus': 'pass', 'flat hover': 'fail', icon: 'pass', sweep: 'fail', + }, JSON.stringify(wide.screens[0].checks, null, 2)); + const card = wide.screens[0].checks.find((item) => item.label === 'card 2'); + assert.match(card.reason, /^position: /); + const sweep = wide.screens[0].checks.find((item) => item.label === 'sweep'); + assert.match(sweep.reason, /nothing on 1 of 2: "Plain link"/); + const icon = wide.screens[0].checks.find((item) => item.label === 'icon'); + assert.ok(icon.screenshot.path.endsWith('-c7.png')); + assert.ok(!narrow.screens[0].checks.some((item) => item.label === 'icon'), 'a capture runs only at its widths'); + assert.equal(statuses(wide.screens[1]).health, 'fail'); + assert.match(wide.screens[1].checks[0].reason, /couldn't load/); + } finally { + server.close(); + await rm(out, { recursive: true, force: true }); + } +}); diff --git a/harness/runtime-audit/test/checks.test.mjs b/harness/runtime-audit/test/checks.test.mjs index 22e142d5..45d6d174 100644 --- a/harness/runtime-audit/test/checks.test.mjs +++ b/harness/runtime-audit/test/checks.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { evaluateCheck, evaluateDimensions, evaluateViewTransition, summarize } from '../checks.mjs'; +import { + STATE_PROPERTIES, evaluateBounds, evaluateCheck, evaluateClippedText, evaluateDimensions, evaluateInteraction, + evaluatePageHealth, evaluateViewTransition, summarize, +} from '../checks.mjs'; import { validateDimensionsPlan } from '../run.mjs'; const page = { viewportWidth: 360, scrollWidth: 360, maxRight: 340, overflowHidden: false }; @@ -88,3 +91,45 @@ test('a view-transition check passes only when following the link ran one', () = assert.equal(evaluateViewTransition({ ...observed, supported: false }).status, 'inconclusive'); assert.equal(evaluateViewTransition({ subjectCount: 0 }).status, 'inconclusive'); }); + +test('page health fails on an error page, a page error or a console error', () => { + assert.equal(evaluatePageHealth({ errorTexts: [], errors: [] }).status, 'pass'); + const broken = evaluatePageHealth({ errorTexts: ["This page couldn't load"], errors: [{ kind: 'page-error', message: 'boom\nstack' }] }); + assert.equal(broken.status, 'fail'); + assert.match(broken.reason, /couldn't load.*page-error: boom$/); +}); + +test('bounds judge position and size relative to the container', () => { + const container = box(100, 50, 600, 400); + const check = { kind: 'bounds', expected: { x: 20, y: 20, width: 160, height: 100 }, tolerancePx: 2 }; + const at = (x, y, width, height) => ({ subjectCount: 1, containerCount: 1, subject: box(100 + x, 50 + y, width, height), container }); + assert.equal(evaluateBounds(check, at(21, 19, 160, 100)).status, 'pass'); + const moved = evaluateBounds(check, at(20, 80, 160, 100)); + assert.equal(moved.status, 'fail'); + assert.match(moved.reason, /^position: y off by 60px/); + assert.match(evaluateBounds(check, at(20, 20, 300, 100)).reason, /^size: width off by 140px/); + assert.equal(evaluateBounds(check, { ...at(0, 0, 1, 1), containerCount: 0 }).status, 'inconclusive'); +}); + +test('clipped text fails; nothing clipped passes', () => { + assert.equal(evaluateClippedText({ clipped: [] }).status, 'pass'); + assert.equal(evaluateClippedText({ clipped: [{ text: 'Long', tag: 'p' }] }).status, 'fail'); +}); + +test('an interaction state passes only when something visible changes', () => { + const before = Object.fromEntries(STATE_PROPERTIES.map((property) => [property, 'a'])); + assert.equal(evaluateInteraction({ state: 'hover' }, { subjectCount: 1, before, after: { ...before, color: 'b' } }).status, 'pass'); + assert.equal(evaluateInteraction({ state: 'hover' }, { subjectCount: 1, before, after: before }).status, 'fail'); +}); + +test('an interaction sweep fails on the links and buttons that change nothing', () => { + const before = Object.fromEntries(STATE_PROPERTIES.map((property) => [property, 'a'])); + const items = [ + { tag: 'a', name: 'Home', before, after: { ...before, color: 'b' } }, + { tag: 'button', name: 'Send', before, after: before }, + ]; + const result = evaluateInteraction({ state: 'hover' }, { items }); + assert.equal(result.status, 'fail'); + assert.match(result.reason, /1 of 2: