Skip to content
Closed
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_export_pagination.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": "Follow fast snapshot pagination for explicitly selected Section frames. Preserve the Section envelope root and selected frame IDs on continuation calls, validate cursor progress and completion, and merge nodes and tokens from every page. Compact Section selection menus retain their existing behavior.",
"date": "2026-09-08T15:00:00+09:00"
}
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": "Improve Section selection lists with bounded visible-text previews, explicit list status and count, and an executable frameIds export example. Preserve compact discovery and complete selected-screen export.",
"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 @@ -306,6 +306,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
54 changes: 52 additions & 2 deletions crates/devup-mcp-figma/src/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1166,19 +1166,45 @@ impl CollectorSession {
let ReadToolCall::Snapshot {
script: BuiltinScript::MultiRootSnapshotEnvelope,
root_ids: Some(root_ids),
snapshot: options,
..
} = &planned.call
else {
return Err(invalid_call("multi-root snapshot call format is invalid."));
};
let payload = match decode_fast_multi_snapshot(&result, &self.request.target, root_ids) {
let mut payload = match decode_fast_multi_snapshot(&result, &self.request.target, root_ids)
{
Ok(payload) => payload,
Err(error) if fast_call_fallback_allowed(&error) => {
self.fallback_multi_root_batch(planned, fallback_category(&error))?;
return Ok(());
}
Err(error) => return Err(error),
};
let cursor = take_snapshot_cursor(&mut payload.snapshot)?;
let options = options.clone().unwrap_or_default();
if let Some(cursor) = cursor {
let expected_next = options
.offset
.checked_add(payload.snapshot.nodes.len())
.ok_or_else(|| invalid_call("Figma snapshot cursor offset overflowed."))?;
if cursor.offset != options.offset
|| cursor.next_offset != expected_next
|| cursor.next_offset > cursor.total_nodes
{
return Err(invalid_call(
"Figma snapshot cursor does not match the collected node range.",
));
}
if cursor.complete != (cursor.next_offset == cursor.total_nodes) {
return Err(invalid_call(
"Figma snapshot cursor completion state does not match the node count.",
));
}
if !cursor.complete && cursor.next_offset <= cursor.offset {
return Err(invalid_call("Figma snapshot cursor did not advance."));
}
}
if let (Some(existing), Some(incoming)) = (&self.source_version, &payload.snapshot.version)
&& existing != incoming
{
Expand All @@ -1194,7 +1220,13 @@ impl CollectorSession {
self.fast_multi_has_large_values |= !descriptors_in_chunk(&payload.snapshot)?.is_empty();
merge_fast_resources(&mut self.fast_multi_resources, payload.resources)?;
self.stats.transport = if self.section_fallback_roots.is_empty() {
payload.stats.transport
if self.stats.transport == "text-paginated"
|| cursor.is_some_and(|cursor| !cursor.complete || cursor.offset > 0)
{
"text-paginated"
} else {
payload.stats.transport
}
} else {
"hybrid-multi-root-cursor"
}
Expand All @@ -1209,6 +1241,24 @@ impl CollectorSession {
.envelope_chunks
.saturating_add(payload.stats.chunk_count);
self.record_snapshot_chunk(order, payload.snapshot)?;
if let Some(cursor) = cursor
&& !cursor.complete
{
// Continue this selection, preserving the Section envelope root and
// the selected frame IDs. The compact index is not a snapshot page.
let mut next = planned.call.clone();
if let ReadToolCall::Snapshot { snapshot, .. } = &mut next {
*snapshot = Some(SnapshotReadOptions {
offset: cursor.next_offset,
..options
});
}
self.enqueue(
next,
planned.expected_node_id.clone(),
CallKind::FastMultiRoot,
);
}
Ok(())
}

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
Loading