From a289d90f0cce98953666990296e2978b681f4059 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 8 Sep 2026 14:54:09 +0900 Subject: [PATCH 1/2] fix(figma): follow pagination for selected section frames --- ...ngepack_log_section_export_pagination.json | 8 + crates/devup-mcp-figma/src/collector.rs | 54 ++++- crates/devup-mcp-figma/tests/collector.rs | 221 ++++++++++++++++++ .../section-export-pagination-verification.md | 100 ++++++++ .../2026-09-08-section-export-pagination.md | 28 +++ 5 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 .changepacks/changepack_log_section_export_pagination.json create mode 100644 docs/section-export-pagination-verification.md create mode 100644 docs/superpowers/plans/2026-09-08-section-export-pagination.md diff --git a/.changepacks/changepack_log_section_export_pagination.json b/.changepacks/changepack_log_section_export_pagination.json new file mode 100644 index 0000000..825034b --- /dev/null +++ b/.changepacks/changepack_log_section_export_pagination.json @@ -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" +} diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index 34035bf..769aa9a 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -1166,12 +1166,14 @@ 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))?; @@ -1179,6 +1181,30 @@ impl CollectorSession { } 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 { @@ -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" } @@ -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(()) } diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index 20f142b..6ffeae6 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -2113,6 +2113,227 @@ fn multi_root_ids(call: &ReadToolCall) -> Vec<&str> { root_ids.iter().map(String::as_str).collect() } +// Explicit SECTION selection asks for full screen artifacts, unlike the compact +// index/menu. A first page with unresolved child edges must not finish collection. +#[test] +fn selected_section_roots_follow_pages_and_merge_tokens() { + for selected in [vec!["root-0"], vec!["root-1", "root-0"]] { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: selected.iter().map(|id| (*id).to_owned()).collect(), + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[3, 3])); + let mut collector = CollectorSession::new(request); + let roots = if selected.len() == 1 { + vec!["root-0"] + } else { + vec!["root-0", "root-1"] + }; + let width = roots.len(); + for page in 0..3 { + let mut calls = Vec::new(); + for _ in &roots { + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!("selected frames need page {page} before collection can complete") + }; + assert_eq!(multi_root_ids(&call.call).len(), 1); + assert_eq!(call.expected_node_id.as_deref(), Some("10:1")); + let ReadToolCall::Snapshot { snapshot, .. } = &call.call else { + unreachable!() + }; + assert_eq!(snapshot.as_ref().map_or(0, |options| options.offset), page); + calls.push(call); + } + let mut page_roots = calls + .iter() + .map(|call| multi_root_ids(&call.call)[0]) + .collect::>(); + page_roots.sort(); + assert_eq!(page_roots, roots); + // Responses and continuations may arrive in a different order from + // the visual index. The resulting artifact must retain index order. + for call in calls.into_iter().rev() { + let root = multi_root_ids(&call.call)[0]; + collector + .accept(&call.id, selected_section_page(&[root], page)) + .unwrap(); + } + } + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("the final cursor must end collection without another call") + }; + assert_eq!(parts.stats.transport, "text-paginated"); + assert!(!parts.stats.fallback_used); + assert_eq!(parts.stats.figma_tool_calls, width * 3); + assert_eq!(parts.stats.node_count, width * 3); + let resources = &parts.variables.as_ref().unwrap().raw; + assert_eq!(resources["variables"].as_array().unwrap().len(), 2); + assert_eq!(resources["usedVariableIds"], json!(["late", "shared"])); + let snapshot = merge_chunks(parts.snapshot_chunks).unwrap(); + assert_eq!(snapshot.roots, roots); + assert_eq!(snapshot.nodes.len(), width * 3); + for root in roots { + assert_eq!( + snapshot.nodes[&format!("{root}-text")].fields["characters"], + "Upload guidance / TIP" + ); + assert!(snapshot.nodes.contains_key(&format!("{root}-body"))); + } + assert!(!snapshot.nodes.contains_key("__DEVUP_SNAPSHOT_CURSOR__")); + } +} + +#[test] +fn selected_section_continuation_rejects_replayed_and_empty_pages() { + for replay in [true, false] { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".to_owned()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[3])); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&first.id, selected_section_page(&["root-0"], 0)) + .unwrap(); + let CollectorStep::Call(next) = collector.advance().unwrap() else { + panic!("continuation required") + }; + let result = if replay { + selected_section_page(&["root-0"], 0) + } else { + let mut envelope: Value = serde_json::from_str( + selected_section_page(&["root-0"], 1).raw["content"][0]["text"] + .as_str() + .unwrap(), + ) + .unwrap(); + envelope["snapshot"]["nodes"] + .as_array_mut() + .unwrap() + .remove(0); + envelope["snapshot"]["nodes"][0]["fields"]["nextOffset"] = json!(1); + envelope["integrity"]["nodeCount"] = json!(1); + envelope["integrity"]["variableRefCount"] = json!(0); + encode_section_page(envelope) + }; + let error = collector + .accept(&next.id, result) + .expect_err("continuation must advance"); + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); + } +} + +#[test] +fn selected_section_cursor_rejects_wrong_ranges_and_nonadvancing_pages() { + for (label, offset, next, complete, total) in [ + ("wrong request offset", 1, 2, false, 3), + ("skipped nodes", 0, 2, false, 3), + ("does not advance", 0, 0, false, 3), + ("past total", 0, 4, false, 3), + ("premature completion", 0, 1, true, 3), + ("unterminated final page", 0, 1, false, 1), + ] { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".to_owned()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[3])); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!() + }; + // A leaf keeps envelope containment valid even for malformed completion. + let mut envelope: Value = serde_json::from_str( + selected_section_page(&["root-0"], 0).raw["content"][0]["text"] + .as_str() + .unwrap(), + ) + .unwrap(); + envelope["snapshot"]["nodes"][0]["fields"]["childrenIds"] = json!([]); + envelope["snapshot"]["nodes"][1]["fields"] = json!({ + "offset": offset, "nextOffset": next, "complete": complete, "totalNodes": total + }); + let error = collector + .accept(&call.id, encode_section_page(envelope)) + .expect_err(label); + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid, "{label}"); + } +} + +fn selected_section_page(roots: &[&str], page: usize) -> UpstreamResult { + let mut envelope: Value = serde_json::from_str( + fast_multi_envelope_result(&["root-0"], &["shared"]).raw["content"][0]["text"] + .as_str() + .unwrap(), + ) + .unwrap(); + let variable = if page == 2 { "late" } else { "shared" }; + let nodes = roots + .iter() + .map(|root| { + let (id, parent, children, kind) = match page { + 0 => ( + root.to_string(), + "10:1".to_owned(), + vec![format!("{root}-body")], + "FRAME", + ), + 1 => ( + format!("{root}-body"), + root.to_string(), + vec![format!("{root}-text")], + "FRAME", + ), + _ => ( + format!("{root}-text"), + format!("{root}-body"), + vec![], + "TEXT", + ), + }; + json!({"id": id, "type": kind, "fields": { + "name": id, "parentId": parent, "childrenIds": children, + "characters": "Upload guidance / TIP", + "boundVariables": {"fills": [{"type": "VARIABLE_ALIAS", "id": variable}]} + }, "extra": {}, "fieldErrors": {}}) + }) + .chain(std::iter::once(json!({ + "id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", + "fields": {"offset": page * roots.len(), "nextOffset": (page + 1) * roots.len(), + "complete": page == 2, "totalNodes": roots.len() * 3}, + "extra": {}, "fieldErrors": {} + }))) + .collect::>(); + envelope["snapshot"]["rootIds"] = json!(roots); + envelope["snapshot"]["nodes"] = json!(nodes); + envelope["integrity"]["nodeCount"] = json!(nodes.len()); + envelope["resources"]["variables"] = json!([{"id": variable, "name": variable}]); + envelope["resources"]["usedVariableIds"] = json!([variable]); + encode_section_page(envelope) +} + +fn encode_section_page(mut envelope: Value) -> UpstreamResult { + loop { + let size = serde_json::to_vec(&envelope).unwrap().len(); + if envelope["integrity"]["utf8Bytes"] == size { + break; + } + envelope["integrity"]["utf8Bytes"] = json!(size); + } + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), + } +} + fn legacy_root_id(call: &ReadToolCall) -> &str { let ReadToolCall::Snapshot { node_id, diff --git a/docs/section-export-pagination-verification.md b/docs/section-export-pagination-verification.md new file mode 100644 index 0000000..dbe4ebe --- /dev/null +++ b/docs/section-export-pagination-verification.md @@ -0,0 +1,100 @@ +# SECTION frame selection pagination verification + +Verified locally on 2026-09-08 against base commit `1e8b0b6` (0.2.1). + +## Contract and diagnosis + +The compact SECTION index, explore candidates and `selection_required` response +are intentional menus. Their node counts do not establish an export defect. +README's Section export instructions, public tool instruction 7, and +`section_requires_selection_then_exports_requested_or_all_screens_from_one_artifact` +establish that explicit `frameIds` / `allScreens` requests per-screen artifacts. +Following each canonical URL is an alternative, and a separate requirement for +`referencePng`; it is not required to finish a selected TSX export. + +The observed selected export was partial, with 20 preserved nodes and 32 missing +child edges. This alone was distinguished from the pagination hypothesis. +Executing the original read-only `fast_snapshot.js` for selected FRAME +`3831:10548`, with SECTION envelope root `4279:7811`, returned the raw marker: + +```json +{"offset":0,"nextOffset":10,"complete":false,"totalNodes":144} +``` + +`decode_fast_multi_snapshot` legitimately accepts that partial page. +`accept_fast_multi_root` previously recorded it without scheduling its successor. +The single-frame path already followed cursors. The fix continues the existing +multi-root call with its original SECTION/root IDs and updated snapshot offset, +validates the returned range and completion, strips the internal marker, and +uses the existing node/resource merge. Completeness checks remain unchanged. + +## Regression evidence + +Before production changes, `cargo test -p devup-mcp-figma --test collector +selected_section_ -- --nocapture` failed with: + +- `selected frames need page 1 before collection can complete` +- `wrong request offset: ()` (invalid cursor was accepted) + +The final three regression tests pass. They cover one/two explicit selections, +three pages, late text and tokens, resource deduplication, reversed response +order and visual root order, successful termination, incorrect offset/range, +premature/absent completion, replayed pages and empty nonadvancing pages. +Existing no-cursor complete-envelope fixtures remain supported. + +## Live comparison + +File: `85CgSws3o5XsLv7aAwWJyS`; SECTION: `4279:7811`. +Requests used `refresh: true`, `delivery: resource`, direct Figma acquisition. +Resource manifests were read and every output's byte count and SHA-256 checked. +No canvas changes were made. + +| Request | Status | Nodes | Declared/exported child edges | Missing edges | Figma calls | +| --- | --- | ---: | --- | ---: | ---: | +| Original SECTION, both frameIds | partial | 20 | 50 / 18 | 32 | 3 | +| Fixed SECTION, both frameIds | complete | 288 | 286 / 286 | 0 | 31 | +| Fixed SECTION, frameId 3831:10548 | complete | 144 | 143 / 143 | 0 | 16 | +| Direct FRAME 3831:10548 | complete | 144 | 143 / 143 | 0 | 15 | +| Direct FRAME 3831:10741 | complete | 144 | 143 / 143 | 0 | 15 | + +For both screens, all 144 node objects (including every field) match the direct +FRAME export exactly. Collections, variables, styles and remote-variable values +also match by ID. Each generated TSX is byte-identical to its direct counterpart: +14,304 bytes, versus 1,074 bytes before the fix. Both include the high-resolution +upload guidance (minimum 1000px on the long side) and TIP. There are 26 actual TEXT +nodes per frame; the existing fidelity report counts 31 text items. +The merged selection contains 12 variables and 11 styles, versus 5 and 1 before. + +The single-frame selection matches direct acquisition too. All fixed requests +have acquisition `complete`, projection `exact`, and `missingChildren: []`. +Transport changes from `text` to `text-paginated` as pages are followed. + +The unselected SECTION remains `selection_required` with a compact index (7 nodes, +6 candidates). Planning notes `3831:10542`, `3831:10885` and Alert `3831:10547` +were acquired through explicit SECTION selection: complete, 13 nodes, 5 calls, +no missing children. The notes describe advance upload guidance, revised warning +copy and moving TIP above the body/title. No implementation or design was edited. + +## Validation and local artifacts + +- `cargo fmt --all -- --check`: passed. +- `node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs`: 4 passed. +- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: passed. +- `cargo insta test --workspace --all-features --check`: 473 passed, 2 existing + manual/live tests ignored; no snapshots to review. Includes `stdio_smoke` and + `section_export` integration tests. +- Debug build and real Figma comparisons: passed. +- `cargo build --workspace --release -j 1`: passed. The initial parallel + release build failed with Windows disk-full error 112 while creating an + archive; the serial retry completed. No unrelated caches were deleted. + MSVC emitted its informational import-library linker message as a warning; + Clippy's warnings-as-errors check passed separately. +- Independent read-only code review: no findings requiring changes. + +Local-only evidence is under `target/section-pagination/`: raw first-page response, +before/after exports, comparison JSON, verification drivers and command logs. +`devup-mcp-fixed.exe` is the debug binary used for live verification; it is a local +copy, not an installed replacement. Global MCP configuration was not modified. +`devup-mcp-fixed-release.exe` is also available locally from the successful +optimized build. Live comparisons above used the debug binary. +No push, PR creation, release or deployment was performed. diff --git a/docs/superpowers/plans/2026-09-08-section-export-pagination.md b/docs/superpowers/plans/2026-09-08-section-export-pagination.md new file mode 100644 index 0000000..befae1b --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-section-export-pagination.md @@ -0,0 +1,28 @@ +# Selected SECTION export pagination + +**Goal:** Preserve all selected frame descendants across fast snapshot pages. + +**Contract:** README.md (Section export), the public tool instruction 7, and +`section_requires_selection_then_exports_requested_or_all_screens_from_one_artifact` +distinguish the compact selection menu from selected per-screen artifacts. +The menu's node/candidate count is not evidence of a bug. Explicit `frameIds` +and `allScreens` request screen exports. Canonical URL recapture is an alternative; +it is required separately for `referencePng`. + +**Scope:** Only the SECTION multi-root collector continuation and its regression +tests. Preserve strict completeness checks, index selection and visual root order. +No design edits, parent workspace edits, global MCP changes, push, PR or release. + +- [x] Verify the public contract before treating the observed partial export as a bug. +- [x] Reproduce the installed build's partial result and inspect the original script cursor. +- [x] Add collector regression tests for one/many selected roots, multiple pages, + resource merge/deduplication, cursor progress/termination and root ordering. +- [x] Run the new tests before production changes and retain the actual failure. +- [x] Continue the same multi-root call at nextOffset; validate its range and + completion against the requested offset before recording the page. +- [x] Run the regression tests, existing SECTION integration tests and CI checks: + cargo fmt, Node script tests, stdio_smoke, workspace clippy, cargo insta and + release build. Distinguish tool/environment failures from code failures. +- [x] Run the local fixed binary against the real SECTION with one and two frameIds; + compare nodes, text and tokens with direct frame exports; inspect planning notes. +- [x] Record measurements, update the Orca comment and prepare the local commit. From 60713ac7d26f90ae3a1e8f085dce7a660d4dc336 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 8 Sep 2026 15:09:17 +0900 Subject: [PATCH 2/2] feat(figma): clarify section selection lists and export guidance Add bounded visible-text previews and a runnable frameIds example while preserving selected-screen exports. Verified live index and unchanged example execution; Node 6, Section integration 3, fmt and clippy pass. Full workspace tests and release build blocked by disk exhaustion. --- ...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 | 24 +++++++++-- crates/devup-mcp/tests/section_export.rs | 38 ++++++++--------- .../section-export-pagination-verification.md | 19 +++++++++ 11 files changed, 152 insertions(+), 23 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 0000000..6001841 --- /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": "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" +} diff --git a/README.md b/README.md index a59a6ac..e3b617f 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/crates/devup-mcp-figma/src/scripts/section_index.js b/crates/devup-mcp-figma/src/scripts/section_index.js index e709199..4e5da88 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 55d396f..b8d8df9 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 6ffeae6..dbf0e14 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -2080,6 +2080,7 @@ fn section_index_with_node_counts(node_counts: &[usize]) -> SectionIndex { .map(|(index, node_count)| SectionCandidate { node_id: format!("root-{index}"), name: format!("Root {index}"), + text_preview: String::new(), node_type: "FRAME".to_owned(), visible: true, bounds: ExploreBounds { diff --git a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs index 778e62a..9b9e3c8 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 56c67f4..e86c317 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 734cb27..0f48920 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -969,7 +969,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 2c71066..9a9743d 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -768,6 +768,9 @@ pub(super) async fn complete_operation( && frame_ids.is_empty() && !all_screens { + 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, &[]), @@ -780,18 +783,33 @@ 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." }), ); + 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 65e46e0..3583daa 100644 --- a/crates/devup-mcp/tests/section_export.rs +++ b/crates/devup-mcp/tests/section_export.rs @@ -98,31 +98,30 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .collect::>(), ["10:3", "10:2"] ); + assert_eq!(selection["selection"]["status"], "complete"); + assert_eq!(selection["selection"]["count"], 2); assert_eq!( - selection["nextAction"]["why"], - "This link is a Section and holds several screens inside. Collecting them all at once exceeds the size limit." - ); - 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_eq!( - selection["nextAction"]["doNot"], - "Do not try to collect the whole Section at once." + selection["selection"]["candidates"][0]["node"]["textPreview"], + "Upload guidance TIP" ); 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"); + assert_eq!( + selection["nextAction"]["example"]["tool"], + "devup_figma_export" + ); + let mut selected_arguments = selection["nextAction"]["example"]["arguments"].clone(); + assert_eq!(selected_arguments["artifactId"], artifact_id); + assert_eq!(selected_arguments["frameIds"], json!(["10:3"])); + assert_eq!(selected_arguments["outputs"], json!(["tsx"])); + assert_eq!(selected_arguments["delivery"], "resource"); + // Follow the returned example, selecting two reviewed candidates. + selected_arguments["frameIds"] = json!(["10:2", "10:3"]); + selected_arguments["outputs"] = json!(["tsx", "sourceMap"]); + selected_arguments["delivery"] = json!("inline"); - let selected = call( - &client, - json!({ - "artifactId": artifact_id, - "outputs": ["tsx", "sourceMap"], - "frameIds": ["10:2", "10:3"] - }), - ) - .await?; + let selected = call(&client, selected_arguments).await?; assert_eq!(selected["status"], "complete"); assert_eq!( selected["frames"] @@ -314,6 +313,7 @@ fn compact_section_index_result() -> UpstreamResult { }, "extra": {}, "fieldErrors": {}}, {"id": "10:3", "type": "FRAME", "fields": { "name": "First", "parentId": "10:1", "childrenIds": [], "visible": true, + "textPreview": "Upload guidance TIP", "directChildCount": 0, "subtreeNodeCount": 1, "estimatedSerializedBytes": 1000, "absoluteBoundingBox": {"x": 100, "y": 120, "width": 360, "height": 740} }, "extra": {}, "fieldErrors": {}} diff --git a/docs/section-export-pagination-verification.md b/docs/section-export-pagination-verification.md index dbe4ebe..a371d75 100644 --- a/docs/section-export-pagination-verification.md +++ b/docs/section-export-pagination-verification.md @@ -98,3 +98,22 @@ copy, not an installed replacement. Global MCP configuration was not modified. `devup-mcp-fixed-release.exe` is also available locally from the successful optimized build. Live comparisons above used the debug binary. No push, PR creation, release or deployment was performed. + +## Selection-list follow-up + +The subsequent list improvement preserves the compact selection flow and adds +visible-text previews, list status/count, and a concrete `nextAction.example`. +On the same live SECTION it still returns 6 candidates / 7 summary nodes, now +with upload guidance, TIP and planning-note previews. Executing the returned +example unchanged in the same MCP session successfully exports its selected +candidate with `status: complete`. + +For this follow-up, Node behavior tests (6), SECTION integration tests (3), +format and workspace Clippy passed. Independent review found no functional +issues. The full workspace test build failed with Windows error 112, and the +release build failed with LLVM `no space on device`. These are disk-capacity +limitations; the earlier full-suite/release success above applies to the +pagination commit, not this follow-up. The live-verified follow-up debug binary +is `target/section-pagination/devup-mcp-selection.exe`; no installation was +replaced. Follow-up logs and live responses use the `selection-*` and +`improved-index*` names in that local evidence directory.