Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_section_selection_guidance.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
35 changes: 35 additions & 0 deletions crates/devup-mcp-figma/src/scripts/section_index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {
Expand Down Expand Up @@ -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"],
Expand Down
3 changes: 3 additions & 0 deletions crates/devup-mcp-figma/src/section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/devup-mcp-figma/tests/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions crates/devup-mcp-figma/tests/explore_script_behavior.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions crates/devup-mcp-figma/tests/section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/devup-mcp/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions crates/devup-mcp/src/server/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &[]),
Expand All @@ -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));
}

Expand Down
31 changes: 27 additions & 4 deletions crates/devup-mcp/tests/section_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,41 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o
.collect::<Vec<_>>(),
["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");
Expand Down