diff --git a/.changepacks/changepack_log_section_selection_guidance.json b/.changepacks/changepack_log_section_selection_guidance.json new file mode 100644 index 00000000..c6a4e527 --- /dev/null +++ b/.changepacks/changepack_log_section_selection_guidance.json @@ -0,0 +1,8 @@ +{ + "changes": { + "crates/devup-mcp-figma/Cargo.toml": "Patch", + "crates/devup-mcp/Cargo.toml": "Patch" + }, + "note": "Make a Section selection list something a caller can choose from. A Section link answers with candidates rather than screens, and the list gave a name, a type and a URL - which is not enough to tell two frames apart when a designer named them alike, so the choice was a guess and the wrong guess costs a full export. Each candidate now carries a preview of the visible text under it, bounded to 120 characters per candidate, 64 nodes walked and 2 KiB across the whole list, so the index stays compact; an empty or short preview means the walk found little text, not that the screen is empty. The list also says how complete it is: selection.status and selection.count, with truncated read from the index itself rather than inferred from a round count of 100, which called a Section holding exactly that many partial. And nextAction carries an example built from this call own artifactId and a candidate actually in the list, so the next step is a call to run rather than a shape to assemble.", + "date": "2026-09-08T16:00:00+09:00" +} diff --git a/README.md b/README.md index 16901ecb..e9a47c5f 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,8 @@ asset의 파일 이름은 기본적으로 **레이어 이름**입니다 — 플 Section 링크는 전체 subtree를 직접 변환하지 않습니다. `selection_required.nextAction`에 따라 후보를 확인한 뒤 `frameIds` 또는 `allScreens: true`로 화면별 export를 계속하며, 일부 화면 수집이 실패하면 성공한 화면은 유지하고 실패한 node는 `failures`에 보고합니다. +SECTION 기본 응답은 화면 아티팩트가 아닌 선택 목록입니다. `selection.status`와 `selection.count`로 목록 조회 상태와 후보 수를 알 수 있으며, 최상위 `status: "selection_required"`는 아직 화면을 선택해야 한다는 뜻입니다. `selection.candidates[]`는 `node.name`, `node.nodeType`, `node.textPreview`, `canonicalUrl`로 서로 구분할 수 있습니다. 미리보기는 보이는 텍스트만 모아 최대 120자, 전체 최대 2KB로 제한하므로 비어 있거나 짧아도 실제 화면 내용이 없다는 뜻은 아닙니다. `nextAction.example`에는 첫 후보를 선택하는 `devup_figma_export` 호출 예시가 들어 있습니다. 예시의 `frameIds`를 검토한 후보 ID로 바꿔 호출하면 선택한 화면만 수집합니다. + ### 한 화면의 여러 폭 — 반응형 모듈 Section 안의 frame이 `mobile` / `tablet` / `desktop`처럼 **breakpoint 이름**을 가지면, 그 frame 하나를 요청해도 같은 이름 규칙의 형제 frame이 함께 수집됩니다(Section 자체는 수집 범위 밖이며, 그 이름은 각 frame의 `parentName`으로 전달됩니다). 이때 `tsx`나 `responsiveTsx`를 요청하면 결과에 `responsiveTsx`가 추가됩니다 — 세 폭을 하나의 트리로 접고 폭마다 다른 값을 devup-ui 반응형 배열 `[mobile, sm, tablet, lg, pc]`로 쓴 모듈입니다. 각 폭이 놓이는 slot은 frame **이름이 아니라 폭**으로 정해집니다(`≤480 / ≤768 / ≤992 / ≤1280 / 그 이상`). 컴포넌트 이름은 `componentName`이 우선이고, 없으면 Section 이름의 PascalCase에 `Page`를 붙입니다(`about` → `AboutPage`). diff --git a/crates/devup-mcp-figma/src/scripts/section_index.js b/crates/devup-mcp-figma/src/scripts/section_index.js index e7091991..4e5da88a 100644 --- a/crates/devup-mcp-figma/src/scripts/section_index.js +++ b/crates/devup-mcp-figma/src/scripts/section_index.js @@ -4,6 +4,40 @@ if (section.type !== "SECTION") throw new Error("DEVUP_SECTION_REQUIRED"); const MAX_CANDIDATES = 100; const MAX_TRAVERSED_NODES = 20000; +const MAX_PREVIEW_CHARACTERS = 120; +const MAX_PREVIEW_NODES = 64; +let remainingPreviewBytes = 2048; + +// The menu needs enough copy to distinguish similarly named frames, without +// returning their descendants or letting previews dominate the compact index. +function textPreview(root) { + const queue = [root]; + let preview = ""; + let characters = 0; + let bytes = 0; + for (let index = 0; index < queue.length; index += 1) { + const node = queue[index]; + if (node.visible === false) continue; + if (node.type === "TEXT" && typeof node.characters === "string") { + const text = node.characters.replace(/\s+/g, " ").trim(); + for (const character of (preview && text ? " " : "") + text) { + const size = utf8ByteLength(character); + if (characters >= MAX_PREVIEW_CHARACTERS || bytes + size > remainingPreviewBytes) { + remainingPreviewBytes -= bytes; + return preview.trimEnd(); + } + preview += character; + characters += 1; + bytes += size; + } + } + if ("children" in node && queue.length < MAX_PREVIEW_NODES) { + queue.push(...node.children.slice(0, MAX_PREVIEW_NODES - queue.length)); + } + } + remainingPreviewBytes -= bytes; + return preview; +} function bounds(node) { const value = node.absoluteBoundingBox || { @@ -163,6 +197,7 @@ const candidates = selected.map(({ node, box }) => { visible: node.visible !== false, breadcrumb: breadcrumb(node), directChildCount: "children" in node ? node.children.length : 0, + textPreview: textPreview(node), subtreeNodeCount: estimate.subtreeNodeCount, estimatedSerializedBytes: estimate.estimatedSerializedBytes, selectionReasons: ["screen-like", "inside-section"], diff --git a/crates/devup-mcp-figma/src/section.rs b/crates/devup-mcp-figma/src/section.rs index 55d396ff..b8d8df90 100644 --- a/crates/devup-mcp-figma/src/section.rs +++ b/crates/devup-mcp-figma/src/section.rs @@ -20,6 +20,8 @@ pub struct SectionCandidate { pub node_id: String, pub name: String, pub node_type: String, + #[serde(default)] + pub text_preview: String, pub visible: bool, pub bounds: ExploreBounds, pub parent_id: Option, @@ -239,6 +241,7 @@ pub fn build_section_index( node_id: node.node_id, name: node.name, node_type: node.node_type, + text_preview: node.text_preview, visible: node.visible, bounds: node.bounds, parent_id: node.parent_id, diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index 5e7f93b9..8f0ca4ea 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -2081,6 +2081,7 @@ fn section_index_with_node_counts(node_counts: &[usize]) -> SectionIndex { node_id: format!("root-{index}"), name: format!("Root {index}"), node_type: "FRAME".to_owned(), + text_preview: String::new(), visible: true, bounds: ExploreBounds { y: index as f64 * 200.0, diff --git a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs index 778e62af..9b9e3c8a 100644 --- a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs +++ b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs @@ -7,6 +7,48 @@ const scriptPath = fileURLToPath(new URL("../src/scripts/explore.js", import.met const exploreSource = (await readFile(scriptPath, "utf8")).replace(/\r\n/g, "\n"); const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const sectionSource = await readFile(new URL("../src/scripts/section_index.js", import.meta.url), "utf8"); + +async function executeSection(section) { + return new AsyncFunction("figma", sectionSource.replace('"__DEVUP_NODE_ID__"', JSON.stringify(section.id)))({ + fileKey: "fixture-file", + getNodeByIdAsync: async () => section, + }); +} + +test("Section list previews visible text without exporting descendants", async () => { + const hidden = sceneNode({ id: "hidden", type: "TEXT" }); + hidden.characters = "hidden draft"; + const hiddenGroup = sceneNode({ id: "hidden-group", children: [hidden] }); + hiddenGroup.visible = false; + const text = sceneNode({ id: "copy", type: "TEXT" }); + text.characters = " Upload\n guidance TIP "; + const frame = sceneNode({ id: "frame", type: "FRAME", children: [hiddenGroup, text] }); + const empty = sceneNode({ id: "empty", type: "FRAME" }); + const section = sceneNode({ id: "section", type: "SECTION", children: [frame, empty] }); + pageWith(section); + const result = await executeSection(section); + assert.equal(result.nodes.find(n => n.id === "frame").fields.textPreview, "Upload guidance TIP"); + assert.equal(result.nodes.find(n => n.id === "empty").fields.textPreview, ""); + assert.deepEqual(new Set(result.nodes.map(n => n.id)), new Set(["section", "frame", "empty"])); +}); + +test("Section preview text has per-candidate and aggregate limits", async () => { + const frames = Array.from({ length: 30 }, (_, i) => { + const text = sceneNode({ id: `copy-${i}`, type: "TEXT" }); + text.characters = "안내😀".repeat(300); + return sceneNode({ id: `frame-${i}`, type: "FRAME", children: [text] }); + }); + const section = sceneNode({ id: "section", type: "SECTION", children: frames }); + pageWith(section); + const result = await executeSection(section); + const previews = result.nodes.slice(1).map(n => n.fields.textPreview); + assert.equal(Array.from(previews[0]).length, 120); + assert.ok(previews.every(value => Array.from(value).length <= 120)); + assert.ok(previews.reduce((sum, value) => sum + Buffer.byteLength(value), 0) <= 2048); + assert.ok(previews.every(value => value.isWellFormed())); +}); + function sceneNode({ id, type = "GROUP", diff --git a/crates/devup-mcp-figma/tests/section.rs b/crates/devup-mcp-figma/tests/section.rs index 56c67f4f..e86c3170 100644 --- a/crates/devup-mcp-figma/tests/section.rs +++ b/crates/devup-mcp-figma/tests/section.rs @@ -232,6 +232,7 @@ fn packing_index(weights: &[usize]) -> SectionIndex { node_id: format!("root-{index}"), name: format!("Root {index}"), node_type: "FRAME".to_owned(), + text_preview: String::new(), visible: true, bounds: ExploreBounds { y: index as f64 * 120.0, diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 7d795988..1e0233c0 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -883,7 +883,7 @@ fn section_candidate_as_explore(candidate: &SectionCandidate) -> ExploreCandidat node_type: candidate.node_type.clone(), bounds: candidate.bounds, child_count: candidate.direct_child_count, - text_preview: String::new(), + text_preview: candidate.text_preview.clone(), parent_id: candidate.parent_id.clone(), kind: ExploreKind::Screen, visible: candidate.visible, diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 5f0bee3b..8d3e7e65 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -617,6 +617,13 @@ pub(super) async fn complete_operation( && frame_ids.is_empty() && !all_screens { + // Whether the list is short because the Section is, or because + // the walk stopped. Guessing it from a round count of 100 says + // "partial" for a Section that happens to hold exactly that + // many, and the index already knows the answer. + let truncated = payload_section_index + .as_ref() + .map_or(candidates.len() == 100, |index| index.truncated); let quality = OutputQuality { acquisition: acquisition_quality(&completeness_report, false), projection: projection_quality(false, &[]), @@ -629,18 +636,36 @@ pub(super) async fn complete_operation( "selection".to_owned(), json!({ "kind": "screen-frame", + "status": if truncated { "partial" } else { "complete" }, + "count": candidates.len(), "candidates": candidates, - "truncated": candidates.len() == 100 + "truncated": truncated }), ); result.insert( "nextAction".to_owned(), json!({ - "why": "This link is a Section and holds several screens inside. Collecting them all at once exceeds the size limit.", - "how": "Call again with the target screen's canonicalUrl from screens[], or use allScreens:true if you need every screen.", + "why": "This is a Section candidate list. Screen artifacts have not been exported yet.", + "how": "Review selection.candidates using name, nodeType and textPreview. Call devup_figma_export with frameIds to export selected screens, use a candidate's canonicalUrl for one screen, or allScreens:true for every candidate in a complete list.", "doNot": "Do not try to collect the whole Section at once." }), ); + // An example built from this call's own artifact and a real + // candidate, so the next step is a call to run rather than a + // shape to assemble. + if let Some(candidate) = candidates.first() { + result + .get_mut("nextAction") + .expect("nextAction was inserted")["example"] = json!({ + "tool": "devup_figma_export", + "arguments": { + "artifactId": artifact.artifact_id, + "frameIds": [candidate.node.node_id], + "outputs": outputs, + "delivery": "resource" + } + }); + } return Ok(Value::Object(result)); } diff --git a/crates/devup-mcp/tests/section_export.rs b/crates/devup-mcp/tests/section_export.rs index 349ab827..639ed5cd 100644 --- a/crates/devup-mcp/tests/section_export.rs +++ b/crates/devup-mcp/tests/section_export.rs @@ -94,18 +94,41 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .collect::>(), ["10:3", "10:2"] ); + // The list says how complete it is rather than leaving the caller to + // infer it from a round count, and every candidate carries enough to be + // told apart from its neighbours. + assert_eq!(selection["selection"]["status"], "complete"); + assert_eq!(selection["selection"]["count"], 2); + assert_eq!(selection["selection"]["truncated"], false); + for candidate in selection["selection"]["candidates"].as_array().unwrap() { + assert!(candidate["node"]["name"].is_string()); + assert!(candidate["node"]["nodeType"].is_string()); + assert!(candidate["canonicalUrl"].is_string()); + } assert_eq!( selection["nextAction"]["why"], - "This link is a Section and holds several screens inside. Collecting them all at once exceeds the size limit." + "This is a Section candidate list. Screen artifacts have not been exported yet." ); - assert_eq!( - selection["nextAction"]["how"], - "Call again with the target screen's canonicalUrl from screens[], or use allScreens:true if you need every screen." + assert!( + selection["nextAction"]["how"] + .as_str() + .unwrap() + .contains("textPreview") ); assert_eq!( selection["nextAction"]["doNot"], "Do not try to collect the whole Section at once." ); + // The next step is a call to run, not a shape to assemble: it names this + // artifact and a candidate that is actually in the list above. + let example = &selection["nextAction"]["example"]; + assert_eq!(example["tool"], "devup_figma_export"); + assert_eq!( + example["arguments"]["artifactId"], + selection["cache"]["artifactId"] + ); + assert_eq!(example["arguments"]["frameIds"], json!(["10:3"])); + assert_eq!(example["arguments"]["outputs"], json!(["tsx"])); assert_eq!(upstream.0.load(Ordering::SeqCst), 1); let artifact_id = selection["cache"]["artifactId"].as_str().unwrap(); assert_eq!(selection["cache"]["capabilities"]["kind"], "section-index");