From 61d38e7b878047146e035ad5456b60386b469d66 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 8 Sep 2026 19:01:47 +0900 Subject: [PATCH] feat(figma): 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. That 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 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's own artifactId and a candidate actually in the list, so the next step is a call to run rather than a shape to assemble. Taken from #10, which also re-implemented the multi-root cursor handling that #9 already landed; only the selection-list half is here. --- ...gepack_log_section_selection_guidance.json | 8 ++++ README.md | 2 + .../src/scripts/section_index.js | 35 ++++++++++++++++ crates/devup-mcp-figma/src/section.rs | 3 ++ crates/devup-mcp-figma/tests/collector.rs | 1 + .../tests/explore_script_behavior.mjs | 42 +++++++++++++++++++ crates/devup-mcp-figma/tests/section.rs | 1 + crates/devup-mcp/src/server/mod.rs | 2 +- crates/devup-mcp/src/server/projection.rs | 31 ++++++++++++-- crates/devup-mcp/tests/section_export.rs | 31 ++++++++++++-- 10 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 .changepacks/changepack_log_section_selection_guidance.json 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");