diff --git a/.changepacks/changepack_log_direct_oauth_path.json b/.changepacks/changepack_log_direct_oauth_path.json new file mode 100644 index 00000000..f971ed75 --- /dev/null +++ b/.changepacks/changepack_log_direct_oauth_path.json @@ -0,0 +1,10 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor", + "crates/devup-mcp-figma/Cargo.toml": "Minor", + "crates/devup-mcp-devup-ui/Cargo.toml": "Patch", + "crates/devup-mcp-visual/Cargo.toml": "Patch" + }, + "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var. Fixes Section targets on the direct path: the fast snapshot script throws DEVUP_TARGET_IS_SECTION and MCP delivers a thrown error as a successful call carrying isError, which the direct path handed to accept and then failed with snapshot data not found, so a Section link had no way to reveal the screens inside it. It is now rejected exactly as the handoff path already did, so the collector switches to the section index and answers with selectable screens. Fixes SVG asset export, which failed for every request while PNG worked: Figma's remote MCP returns a written PNG as an image attachment but does not return a written .svg at all, so the bytes never reached devup-mcp. SVG is now exported as a string and carried inline beside the descriptor under a bounded size, and the payload search steps through the JSON encoding of a text block and accepts a text payload as well as base64. The missing-payload error now reports which content shapes and mime types the response actually carried, so an absent attachment, a wrong mime type and an unread field stay distinguishable. Adds server instructions covering that the generated component name and asset paths are starting points rather than contracts, that a fixed asset must be exported through assetRequests with an outputPath instead of referenced by a path that does not exist, and that resource delivery should be preferred over inlining bytes.", + "date": "2026-09-03T17:20:00+09:00" +} diff --git a/.changepacks/changepack_log_groundtruth_tools.json b/.changepacks/changepack_log_groundtruth_tools.json new file mode 100644 index 00000000..e617b52f --- /dev/null +++ b/.changepacks/changepack_log_groundtruth_tools.json @@ -0,0 +1,8 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor", + "crates/devup-mcp-devup-ui/Cargo.toml": "Minor" + }, + "note": "Add three read-only ground-truth tools so an agent can never fabricate a project identifier it never verified: devup_project_context reads a project's real devup.json theme tokens, openapi.json endpoints/schemas, or Vespertide models/*.json tables/columns/enums fresh on every call (no session cache), returning a shared {found:false,guardrail:{action:'stop-and-report',...}} envelope instead of guessing when the target file is missing; devup_ui_validate parses DevupUI TSX with the existing oxc_parser/oxc_allocator/oxc_span stack via a new oxc_ast_visit-based walker and flags unknown $token references (with edit-distance-suggested existing tokens), hardcoded hex colors/px lengths that match an existing token, unknown props on Box/Flex/Text/Center/Grid/Image (checked against the published devup-ui Style Props API reference, not invented), and non-static values inside css()/globalCss()/keyframes() calls specifically -- verified against devup-ui's own docs and css-utils-literal-only ESLint rule that plain JSX style props (bg={dynamic}) are valid devup-ui and must not be flagged; devup_stack_diff detects drift across vespertide model -> sea-orm entity -> vespera route -> openapi.json -> devup-api client with every finding carrying an explicit low/medium confidence since none of the checks is a real compiler front end. Regression-tested against the exact incident that motivated this work: three agents independently inventing a $gray100 color token, a 16px bubble radius, and a 36px avatar size that did not exist in the real project devup.json.", + "date": "2026-09-02T00:00:00+09:00" +} diff --git a/.changepacks/changepack_log_render_harness.json b/.changepacks/changepack_log_render_harness.json new file mode 100644 index 00000000..f37521a1 --- /dev/null +++ b/.changepacks/changepack_log_render_harness.json @@ -0,0 +1,9 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor", + "crates/devup-mcp-devup-ui/Cargo.toml": "Minor", + "crates/devup-mcp-figma/Cargo.toml": "Patch" + }, + "note": "Render the generated screens against Figma's own PNGs and fix what that turned up. A render harness (harness/render) builds each acquired screen with devup-ui at the frame's own size and compares the capture with Figma's render of the same frame, each screen under its own node-scope theme and @devup-ui/reset-css, holding every screen to a recorded threshold so a change that moves it further from Figma fails the run. Measured against that, the notice screen went from 29% to 2% different at 1920, and every fix below keeps the plugin corpus at 268 of 268. devup_figma_export gains assetNamesPerNode, on by default: named after their layer, as the plugin names them, one file served every node sharing that name, so eight nodes on one screen claimed a file holding five different drawings and a photograph drawn at three widths kept whichever width was exported last and was stretched into the other two; named per node each gets its own file, while assetNamesPerNode false and the CodegenOptions library default keep the plugin's naming byte for byte. Where two different drawings still claim one file the first is written and the rest are reported as DEVUP_ASSET_NAME_SHARED rather than the export being refused. A layer name no file system takes, such as one carrying a colon, is made writable at delivery with the generated code and the manifest renamed together so the two cannot drift apart. The asset manifest now lists the image fills a layout container paints itself, which the code refers to as a background and nothing could previously deliver, and names them through image_fill_path; a fully transparent node is no longer advertised as exportable, since Figma refuses it as it refuses a hidden one; and a frame that binds no variable and uses no style now yields an empty theme instead of a refusal. A cropped image fill honours its imageTransform instead of being painted center/cover. Two layout facts CSS cannot reach on its own are now written: a node filling its parent's main axis while holding a positioned child, which CSS would otherwise hug to its text, and the pinned height of a positioned frame that fills its parent's width, which CSS would otherwise size by its children - the first put a hero picture 143px low, the second put a header row 18px high. Three invariants guard the result: a per-screen regression gate on rendered distance from Figma, a test that every picture the generated code points at is one the manifest lists, and a check that the words the page prints are the words the design says, which uses the browser as the oracle for JSX whitespace rather than reimplementing its rules.", + "date": "2026-09-08T18:00:00+09:00" +} diff --git a/.changepacks/config.json b/.changepacks/config.json index f54c6c6f..3816df6b 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -1,5 +1,5 @@ { "ignore": ["**", "!/crates/*/Cargo.toml"], "baseBranch": "main", - "latestPackage": null + "latestPackage": "crates/devup-mcp/Cargo.toml" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff3157ff..56cb121d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,89 @@ name: CI +# One workflow, matching devup-ui and the other org projects: verification, +# changepacks version management, binary builds and release publication all +# live here rather than in a second file that can drift out of step. +# +# Release flow (driven by changepacks/action, not by hand): +# 1. A pull request touching crates/ must carry a changepack. `changepacks` +# comments the detected packs; `changepack-required` makes it a gate. +# 2. On push to main with pending changepacks, the action opens an +# "Update Versions" pull request that runs `changepacks update`. +# 3. Merging that PR leaves no changepacks, so the action cuts tags and +# *draft* releases and reports them in `pending_releases`. +# 4. `build` compiles every MCP binary for all three platforms and uploads +# them onto those drafts. +# 5. `finalize` publishes the drafts, but only once the uploads succeeded — +# so a release is never visible without its binaries attached. + on: - pull_request: push: branches: [main] + pull_request: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: + # The action comments the changepack status on a pull request but does not + # fail it. A crate change that ships without a changepack never moves the + # version, so it never releases — this turns that silent outcome into a + # red check with the command to fix it. + changepack-required: + name: changepack required + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Require a changepack for crate changes + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + base="$(git merge-base "$BASE_SHA" "$HEAD_SHA")" + changed="$(git diff --name-only "$base" "$HEAD_SHA")" + + crate_changes="$(printf '%s\n' "$changed" | grep -E '^crates/' || true)" + if [ -z "$crate_changes" ]; then + echo "No crate sources touched; a changepack is not required." + exit 0 + fi + + log_changes="$(printf '%s\n' "$changed" \ + | grep -E '^\.changepacks/changepack_log_.*\.json$' || true)" + if [ -n "$log_changes" ]; then + echo "Changepack present:" + printf ' %s\n' $log_changes + exit 0 + fi + + { + echo "This pull request changes crate sources but adds no changepack log." + echo + echo "Without one the workspace version never moves, so the change" + echo "ships to main and is never released." + echo + echo " cargo install changepacks" + echo " changepacks" + echo + echo "Pick the affected crates, choose Major/Minor/Patch, and write" + echo "the release note, then commit the generated" + echo ".changepacks/changepack_log_*.json alongside your change." + echo + echo "--- crate files changed without a changepack ---" + printf ' %s\n' $crate_changes + } >&2 + exit 1 + verify: strategy: matrix: @@ -27,3 +105,129 @@ jobs: - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo insta test --workspace --all-features --check - run: cargo build --workspace --release + + changepacks: + name: changepacks + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # changepacks diffs HEAD against the previous release commit; the + # default shallow fetch grafts away every parent, so that lookup + # fails and the release never publishes. + fetch-depth: 0 + fetch-tags: true + - uses: changepacks/action@main + id: changepacks + with: + token: ${{ secrets.GITHUB_TOKEN }} + create_release: true + outputs: + changepacks: ${{ steps.changepacks.outputs.changepacks }} + release_assets_urls: ${{ steps.changepacks.outputs.release_assets_urls }} + pending_releases: ${{ steps.changepacks.outputs.pending_releases }} + + build: + name: build (${{ matrix.os }}) + needs: changepacks + # Only when a draft release is actually waiting for assets. On a pull + # request, or on a push that merely opened the Update Versions PR, there + # is nothing to attach to. + if: >- + needs.changepacks.outputs.pending_releases != '' + && needs.changepacks.outputs.pending_releases != '{}' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + targets: x86_64-unknown-linux-gnu + suffix: linux-x86_64 + ext: "" + - os: windows-latest + targets: x86_64-pc-windows-msvc + suffix: windows-x86_64 + ext: ".exe" + - os: macos-latest + # Fused into one universal binary so a single macOS asset runs on + # both Apple Silicon and Intel. + targets: aarch64-apple-darwin x86_64-apple-darwin + suffix: macos-universal + ext: "" + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.98.0 + - uses: Swatinem/rust-cache@v2 + - name: Build release binaries + shell: bash + env: + TARGETS: ${{ matrix.targets }} + SUFFIX: ${{ matrix.suffix }} + EXT: ${{ matrix.ext }} + OS: ${{ matrix.os }} + run: | + set -euo pipefail + for target in $TARGETS; do + rustup target add "$target" + cargo build --release --target "$target" -p devup-mcp -p devup-mcp-visual + done + mkdir -p dist + for bin in devup-mcp devup-mcp-visual; do + out="dist/${bin}-${SUFFIX}${EXT}" + if [ "$OS" = "macos-latest" ]; then + lipo -create -output "$out" \ + "target/aarch64-apple-darwin/release/${bin}" \ + "target/x86_64-apple-darwin/release/${bin}" + file "$out" + else + set -- $TARGETS + cp "target/$1/release/${bin}${EXT}" "$out" + fi + done + ls -l dist + - name: Upload binaries onto the draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }} + run: | + set -euo pipefail + # release_assets_urls maps project path -> asset upload URL. The + # binaries belong to the devup-mcp crate; the library crates get + # their own releases with no assets. + upload="$(printf '%s' "$ASSET_URLS" \ + | jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')" + if [ -z "$upload" ]; then + echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2 + printf '%s\n' "$ASSET_URLS" >&2 + exit 1 + fi + # Drop the RFC 6570 template suffix, e.g. "{?name,label}". + upload="${upload%%\{*}" + for file in dist/*; do + name="$(basename "$file")" + echo "uploading $name" + curl --fail-with-body -sS -X POST \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"$file" \ + "${upload}?name=${name}" >/dev/null + done + + finalize: + name: finalize release + needs: [changepacks, build] + if: >- + needs.changepacks.outputs.pending_releases != '' + && needs.changepacks.outputs.pending_releases != '{}' + runs-on: ubuntu-latest + steps: + # Finalize-only: the action neither installs changepacks nor touches the + # repository here, so no checkout is needed. Running it after `build` + # is what guarantees a published release always has its binaries. + - uses: changepacks/action@main + with: + token: ${{ secrets.GITHUB_TOKEN }} + finalize_releases: ${{ needs.changepacks.outputs.pending_releases }} diff --git a/.gitignore b/.gitignore index 0743a8d4..547c6ad4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,23 @@ .env.* !.env.example +# Agent session state (oh-my-claudecode / omo), local to a working copy +.omc/ +.omo/ + +# Snapshots captured from a live Figma file to iterate on codegen without +# spending the tool-call allowance. Scratch, not ground truth: the pinned +# corpus under fixtures/devup-figma-plugin is what decides correctness. +/fixtures/local-screens/ +/fixtures/local-components/ + +# Individual Figma reads banked by DEVUP_FIGMA_CALL_CACHE so that a capture too +# large for one day's allowance can finish across several attempts. Each entry +# is allowance already spent, which is why it is kept out of the temp directory +# — and out of git, because it is a copy of someone's file, not a fixture. +/fixtures/local-call-bank/ + +# The capture and stitch drivers. Kept beside the bank rather than in the temp +# directory, which was cleared overnight and took the previous copies with it. +/fixtures/local-tools/ + diff --git a/Cargo.lock b/Cargo.lock index ab65617d..6abc7e8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -712,6 +712,8 @@ dependencies = [ "devup-mcp-figma", "insta", "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", "oxc_parser", "oxc_span", "pretty_assertions", @@ -1923,6 +1925,18 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "oxc_ast_visit" +version = "0.148.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ec60272a8dead7c6fb21dd9709f66e4033194399296603a2c9f85dc63b5540" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + [[package]] name = "oxc_data_structures" version = "0.148.0" diff --git a/Cargo.toml b/Cargo.toml index 12d46c4c..82232e74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ keyring = "4.2" insta = { version = "1.48.0", features = ["glob", "json"] } image = { version = "=0.25.10", default-features = false, features = ["png"] } oxc_allocator = "=0.148.0" +oxc_ast = "=0.148.0" +oxc_ast_visit = "=0.148.0" oxc_parser = "=0.148.0" oxc_span = "=0.148.0" rand = "0.10" diff --git a/README.md b/README.md index 98e3040c..a9d154b0 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ Rust-native MCP server that reads Figma designs and generates DevupUI artifacts. - `devup_figma_export`: Figma를 한 번 수집해 TSX, `devup.json`, raw snapshot, source map, asset manifest와 선택적 reference PNG를 함께 생성하거나 같은 artifact를 재사용 - `devup_figma_search`: 파일 전체의 page, section, frame, component를 이름으로 탐색 - `devup_figma_explore`: 링크된 요구사항/라벨 주변의 실제 화면 후보를 공간 순서로 탐색 -- `devup_figma_continue`: host가 실행한 공식 Figma MCP read 결과로 중단된 변환을 재개 - Figma Plugin API의 readable data property를 raw JSON으로 보존하고, 알려지지 않은 runtime field는 `extra`, 실패한 getter는 `fieldErrors`로 유지 -host handoff 경로에는 Figma PAT, 사용자가 만든 OAuth app, 내장 client secret이 필요하지 않습니다. direct 경로는 Figma Remote MCP의 OAuth discovery, Dynamic Client Registration, PKCE S256과 일시적인 `127.0.0.1` callback을 구현하지만, Figma는 현재 MCP Catalog에 승인된 client의 registration만 허용합니다. private build에서는 이미 인증된 공식 Figma MCP를 사용하는 `auto` 또는 `host`가 기본 경로입니다. +devup-mcp는 Figma Remote MCP에 직접 붙습니다 — OAuth discovery, Dynamic Client Registration, PKCE S256, 일시적인 `127.0.0.1` callback을 구현합니다. Figma는 MCP Catalog에 승인된 client의 registration만 허용하므로 등록은 allowlist에 있는 `client_name`으로 이루어집니다(기본값 `Codex`). Figma PAT나 사용자가 만든 OAuth app은 필요하지 않습니다. ## 빌드와 설치 @@ -83,25 +82,36 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. { "status": "disconnected", "paths": { - "direct": { "available": false, "reason": "저장된 자격증명 없음. ..." }, - "localDevMode": { "endpoint": "http://127.0.0.1:3845/mcp", "reachable": false, "hint": "..." }, - "hostHandoff": { "expectedTool": "use_figma", "note": "..." } + "direct": { + "available": false, + "credentialSource": "none", + "tokenState": "absent", + "callbackPort": { "port": null, "free": null }, + "reason": "저장된 자격증명 없음. ..." + }, }, - "clientSetup": { "constraints": { ... }, "opencode": { ... }, "claudeCode": "...", "codex": "...", "localDevMode": { ... } } + "clientSetup": { "constraints": { ... }, "opencode": { ... }, "claudeCode": "...", "codex": "..." } } ``` -`paths.localDevMode.reachable`은 `127.0.0.1:3845`에 대한 300ms 이내 로컬 TCP 연결 확인 결과이며 실패해도 오류를 던지지 않습니다. `needs_figma` 응답에도 같은 프로브 결과가 `hostRequirement.localDevMode`로 포함됩니다. 자세한 제약과 3가지 연결 경로는 아래 "Figma 연결 설정" 절을 참고하세요. +`doctor`는 네트워크 호출을 전혀 하지 않습니다. `paths.direct.credentialSource`는 `cli-arg`, `env`, `credential-store`, `none` 중 하나이고, `tokenState`는 `valid`, `expired`, `absent` 중 하나이며, `callbackPort`는 `--figma-callback-port`를 지정했을 때만 실측한 `port`/`free`를 담습니다. 자세한 제약과 두 연결 경로는 아래 "Figma 연결 설정" 절을 참고하세요. -## Figma 연결 설정 +### direct 경로에 사전 등록된 client 자격증명 주입하기 + +Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급받은 client)의 `client_id`/`client_secret`을 이미 가지고 있다면, devup-mcp에 다음 세 가지 방법 중 하나로 주입해 Dynamic Client Registration을 완전히 건너뛸 수 있습니다. 우선순위는 시작 인자 > 환경변수 > `configure`로 저장한 값입니다. + +- **시작 인자**: `devup-mcp --figma-client-id --figma-client-secret ` +- **환경변수**: `DEVUP_FIGMA_CLIENT_ID`, `DEVUP_FIGMA_CLIENT_SECRET` +- **도구**: `devup_figma_auth { "action": "configure", "clientId": "...", "clientSecret": "..." }` — OS credential store(시작 인자/환경변수와는 별도 항목)에 저장되어 프로세스를 재시작해도 유지됩니다. + +자격증명이 해석되면 `devup_figma_auth { "action": "login" }`은 registration 엔드포인트를 전혀 호출하지 않고 바로 authorization_code + PKCE 흐름으로 진입합니다. 자격증명이 없으면 DCR을 시도하고, 403이면 그대로 보고합니다. DCR 요청의 `client_name` 기본값은 `"Codex"`입니다(`DEFAULT_CLIENT_NAME`). allowlist는 이름을 정확히 일치시켜 판정하고 `"devup-mcp"`는 거기에 없으므로, 그 이름으로 보내면 등록이 403으로 거절되어 direct 경로 자체가 성립하지 않습니다. 이 등록은 Figma에게 devup-mcp가 아니라 Codex로 기록됩니다. 본인 client가 카탈로그에 승인되면 `--figma-client-name` 또는 `DEVUP_FIGMA_CLIENT_NAME`으로 그 이름을 넘기세요. `client_secret`은 로그, 에러, MCP 응답, `doctor` 출력 어디에도 노출되지 않으며 `doctor`는 `credentialSource`로 존재 여부만 보고합니다. -devup-mcp가 Figma에 붙는 경로는 세 가지입니다. +## Figma 연결 설정 -1. **원격 OAuth (`direct`)** — `devup_figma_auth { action: "login" }`으로 브라우저 인증. Figma MCP Catalog에 승인된 client만 등록할 수 있습니다. -2. **로컬 Dev Mode MCP (`http://127.0.0.1:3845/mcp`)** — Figma 데스크톱 앱의 Dev Mode MCP 서버. OAuth가 필요 없고 어떤 MCP 클라이언트에서도 동일하게 동작하지만, Figma 데스크톱 앱에서 켜야 하고 Dev/Full 시트가 있는 유료 플랜이 필요합니다. -3. **호스트 핸드오프 (`host`)** — devup-mcp가 직접 Figma에 붙지 않고, 호스트에 이미 등록된 공식 Figma MCP가 `needs_figma` 응답의 `calls`를 대신 실행하도록 위임합니다. `auto` 정책의 기본 fallback 경로입니다. +devup-mcp가 Figma에 붙는 경로는 하나입니다 — **원격 OAuth (`direct`)**. `devup_figma_auth { action: "login" }`으로 브라우저 인증. Figma MCP Catalog에 승인된 client만 등록할 수 있습니다. +현재 사용 가능한지는 `devup_figma_auth { action: "doctor" }`로 확인하세요. -세 경로 중 무엇이 지금 사용 가능한지는 `devup_figma_auth { action: "doctor" }`로 확인하세요. +Figma 데스크톱 앱의 로컬 Dev Mode MCP(`http://127.0.0.1:3845/mcp`)는 세 번째 경로로 안내했으나 제거했습니다. 읽기 도구 6개(`get_design_context`, `get_variable_defs`, `get_screenshot`, `get_motion_context`, `get_metadata`, `get_figjam`)만 제공하고 그중에 `use_figma`가 없습니다. devup-mcp의 수집은 snapshot·explore·section index·theme 모두 `use_figma`로 스크립트를 실행하므로 로컬에서는 실행할 도구 자체가 없습니다. 도구들이 `fileKey`를 받지 않고 데스크톱 앱에 열려 있는 파일만 가리키는 것도 같은 이유로 맞지 않습니다. "OAuth 없이 바로 쓸 수 있다"는 안내는 확신에 차서 틀린 안내였고, 믿은 쪽이 한 턴을 버린 뒤에야 알게 됩니다. ### 원격 OAuth 등록 제약 (실측) @@ -110,7 +120,7 @@ Figma Remote MCP 등록 엔드포인트는 `POST https://api.figma.com/v1/oauth/ | client_name | 결과 | |---|---| | `Codex` | 200 (client_id + client_secret 발급) | -| `Claude Code` | 200 | +| `Claude Code` | **403** (2026-09-06 실측; 이전 표에는 200으로 적혀 있었음) | | `OpenCode` | 403 | | `opencode` | 403 | | `Cursor` | 403 | @@ -132,6 +142,8 @@ Figma Remote MCP 등록 엔드포인트는 `POST https://api.figma.com/v1/oauth/ 로컬 OAuth 콜백이 쓰는 포트를 OS나 보안 소프트웨어(예: 사내 보안 에이전트)가 이미 점유하고 있으면, 브라우저는 리다이렉트에 "성공"한 것처럼 보이지만 그 요청은 다른 프로세스로 전달됩니다. 클라이언트는 **아무 에러 없이** `Waiting for authorization...` 상태로 영원히 남습니다. 로그인이 멈춘 것처럼 보이면 가장 먼저 콜백 포트를 다른 프로세스가 쓰고 있지 않은지 확인하세요. +기본값은 OS가 매번 빈 임시 포트를 골라주므로(`0`) 이 충돌을 피합니다. 사전 등록한 client의 `redirect_uri`가 고정 포트로 등록되어 있어 특정 포트를 고정해야 한다면 `devup-mcp --figma-callback-port `를 지정하세요. 이 경우 devup-mcp는 그 포트가 이미 사용 중이면 **연결을 기다리지 않고** `DEVUP_FIGMA_CALLBACK_PORT_IN_USE` 오류를 즉시 반환합니다. `devup_figma_auth { "action": "doctor" }`의 `paths.direct.callbackPort.free`에서도 지정한 포트가 실제로 비어 있는지 실측한 값을 확인할 수 있습니다. + ### opencode에서 direct 경로 미리 설정하기 Dynamic Client Registration을 건너뛰려면 `mcp..oauth`에 이미 발급받은 `clientId`/`clientSecret`을 직접 지정합니다. @@ -154,7 +166,14 @@ Dynamic Client Registration을 건너뛰려면 `mcp..oauth`에 이미 발 } ``` -Claude Code와 Codex는 allowlist에 있어 별도 설정 없이 등록할 수 있습니다. +Codex는 allowlist에 있어 별도 설정 없이 등록할 수 있습니다. `Claude Code`는 한때 +200이었으나 2026-09-06 실측에서 403으로 거절됐습니다 — allowlist는 Figma가 바꿀 수 +있으며, 위 표는 측정 시점의 기록입니다. + +devup-mcp는 **직접 경로만** 씁니다. 호스트(Codex)에 등록된 공식 Figma MCP를 빌리는 +우회 경로는 만들지 않습니다 — devup-mcp가 스스로 `Codex`로 등록해 Figma 원격 MCP에 +붙고, 수집에 필요한 `use_figma`를 그 연결로 직접 부릅니다. 호스트에 Figma MCP를 +따로 설정할 필요가 없고, 설정돼 있어도 devup-mcp는 그것을 쓰지 않습니다. ```bash claude mcp add --transport http figma https://mcp.figma.com/mcp @@ -200,7 +219,7 @@ codex mcp add figma --url https://mcp.figma.com/mcp ```json { "url": "https://www.figma.com/design//?node-id=1-2", - "outputs": ["tsx", "devupJson", "sourceMap", "assetManifest", "referencePng"], + "outputs": ["tsx", "responsiveTsx", "devupJson", "rawSnapshot", "rawPayload", "sourceMap", "assetManifest", "referencePng"], "scope": "node", "strict": true, "refresh": false, @@ -213,7 +232,7 @@ codex mcp add figma --url https://mcp.figma.com/mcp `delivery`는 `auto | inline | resource`입니다. `auto`는 JSON escape, base64와 structured/text 이중 표현을 포함한 실제 MCP wire 크기를 계산해 개별 256 KiB·합계 1 MiB 이하만 inline으로 반환하고, 그보다 큰 결과는 native MCP `ResourceLink`와 `devup://artifact/...` URI로 바꿉니다. 링크 URI는 JSON manifest를 가리키므로 link MIME은 `application/json`이고 payload MIME·길이·SHA-256은 `payload*` metadata로 분리합니다. `resource`는 크기와 무관하게 TSX/JSON/PNG를 bounded chunk resource로 제공하며, binary chunk는 base64 MCP blob입니다. asset manifest는 binary를 내장하지 않고 각 asset의 독립 resource URI·MIME·길이·SHA-256을 참조하므로 `resources/read`로 원본 bytes를 정확히 재구성할 수 있습니다. 같은 artifact와 정규화한 projection은 content hash가 같은 resource를 재사용합니다. 파일 출력과 새 resource publication을 함께 요청하면 resource 조회를 reservation 동안 차단한 하나의 transaction으로 다루며, 파일 commit이 전부 성공한 뒤에만 resource와 LRU 변경을 공개합니다. 실패하면 원래 파일을 fingerprint로 검증해 복원하고 복원 불능 backup 경로를 구조화해 보고합니다. 현재 transaction이 만든 temp는 정상 종료·rollback에서 직접 제거하지만, 소유권을 증명할 수 없는 pre-existing temp나 crash·rollback recovery backup은 자동 삭제하지 않습니다. -`referencePng`는 선택했을 때만 공식 read-only `get_screenshot`을 정확히 한 번 추가 호출합니다. 결과는 정확히 하나의 top-level `image/png` content block이어야 하며, JSON/text에 숨긴 image나 다중 image는 거절합니다. 16 MiB compressed, 8192px, 64 MiB decoded 상한 안에서 PNG 전체를 실제 decode한 뒤 byte length와 SHA-256을 확인해 artifact에 보존하며, 단일 링크 node에만 적용됩니다. Section의 여러 Frame은 먼저 반환된 canonical URL별로 수집해야 합니다. PNG bytes는 log·통계·cache key에 포함되지 않으며 `outputPaths.referencePng`를 명시하지 않으면 디스크에 기록하지 않습니다. +`referencePng`는 선택했을 때만 공식 read-only `get_screenshot`을 정확히 한 번 추가 호출합니다. 공식 도구는 기본으로 PNG의 URL과 curl 안내를 text로만 돌려주고 긴 변을 1024px로 줄이므로, `enableBase64Response: true`와 `maxDimension: 8192`로 호출해 node 원래 크기의 PNG를 inline으로 받습니다. 결과의 image block은 정확히 하나여야 하며(곁의 text block은 읽지 않음), JSON/text에 숨긴 image나 다중 image는 거절합니다. 16 MiB compressed, 8192px, 64 MiB decoded 상한 안에서 PNG 전체를 실제 decode한 뒤 byte length와 SHA-256을 확인해 artifact에 보존하며, 단일 링크 node에만 적용됩니다. Section의 여러 Frame은 먼저 반환된 canonical URL별로 수집해야 합니다. PNG bytes는 log·통계·cache key에 포함되지 않으며 `outputPaths.referencePng`를 명시하지 않으면 디스크에 기록하지 않습니다. 모든 완료 응답에는 다음처럼 요청한 산출물별 `quality`가 포함됩니다. @@ -235,8 +254,62 @@ codex mcp add figma --url https://mcp.figma.com/mcp 브라우저 시각 회귀는 MCP 서버가 임의 명령을 실행하지 않고 소비자 repository가 실제 font/asset/DevupUI 환경으로 `actual.png`를 만든 뒤 순수 Rust `devup-mcp-visual`로 비교합니다. renderer pinning, 기본 0.5% threshold, diff PNG와 개인정보 취급 계약은 [`docs/visual-renderer-contract.md`](docs/visual-renderer-contract.md)에 있습니다. +### 렌더링 하네스 — 생성 코드를 Figma가 그린 PNG와 비교 + +생성 코드를 플러그인의 답안과 줄 단위로 맞춰보면 둘이 일치한다는 것까지는 알 수 있지만, **둘 중 어느 쪽도 Figma가 그리는 그림과 같은지는 말해주지 못합니다.** `harness/render`는 그 질문에 답합니다 — 각 화면을 devup-ui로 빌드해 프레임 크기 그대로 열고, Figma가 같은 프레임을 렌더한 PNG와 픽셀 비교합니다. + +```bash +cd harness/render && npm install +python scripts/acquire.py # 모듈·테마·에셋·기준 PNG를 실행 중인 devup-mcp에서 가져옴 +node scripts/render.mjs # 빌드·캡처·비교, 화면별 임계값 초과 시 exit 1 +``` + +`acquire.py`는 Figma 호출을 `fixtures/local-call-bank`에 적립하므로 재실행은 이미 지불한 만큼 무료입니다. 화면마다 **자기 테마를 node scope로** 받습니다 — 파일 하나에 여러 브랜드 컬렉션이 섞이면 `primary` 같은 토큰이 서로 덮어써서, 공지 화면이 Figma가 파랑으로 그리는 자리를 보라색으로 그렸습니다. devup-ui는 테마를 빌드 시점에 굽기 때문에 화면들은 필요한 테마별로 묶여 그룹마다 한 번씩 빌드됩니다. 리셋은 생성 코드가 전제하는 `@devup-ui/reset-css` 그대로입니다. + +`thresholds.json`이 화면별로 Figma와 벌어져도 되는 최대치를 들고 있습니다. 초과하면 실패하고, 밑돌면 그렇다고 알려줍니다(= 수치를 조일 차례). 측정값: + +| 화면 | 1920 | 992 / 768 | 360 / 390 | +|---|---|---|---| +| popup | **0.83%** | 2.19% | 3.59% | +| popup (플러그인 답안) | 21.06% | 7.14% | 12.02% | +| notice | **2.33%** | 4.19% | 8.29% | +| about | 4.54% | 6.94% | 11.26% | +| report **1.87%** · grid 2.96% · keyframes 6.71% | | | | + +차이가 **어디** 있는지는 보조 도구가 답합니다 — `bands.mjs`(가장 많이 어긋난 구간), `drift.mjs`(단순 이동인지 실제 차이인지), `crop.mjs`(구간을 기준/캡처 나란히), `boxes.mjs`(DOM 상자를 Figma 좌표와 대조), `elements.mjs`(그림이 실제로 몇 픽셀로 나왔는지). 긴 화면을 통째로 줄인 스크린샷은 아무것도 보여주지 않습니다. + +`text-check.mjs`는 픽셀이 아니라 **글자**를 봅니다. 생성기는 텍스트 노드의 `characters`를 JSX에 쓰는데, JSX는 공백에 자기 규칙이 있습니다 — 한 문장이 소스 두 줄로 나뉘면 사이에 공백 하나가 들어갑니다. 디자인에 그 공백이 없으면 화면은 디자인에 없는 단어를 찍고, 문단은 Figma가 끊지 않는 자리에서 감깁니다. JSX를 읽어 무엇이 그려질지 추론하는 건 그 규칙을 다시 구현하는 일이고, 그렇게 넘겨짚으면 없는 결함을 만들어냅니다 — 그래서 **브라우저가 실제로 찍은 글자**를 `characters`와 대조합니다. 현재 245개 텍스트 중 3개(같은 문단의 세 폭)가 디자인대로 찍히지 않습니다. + +캡처·테마·에셋·빌드 산출물은 커밋하지 않습니다(`harness/render/.gitignore`). 이 하네스가 찾아낸 결함은 테마 스코프, 컨테이너가 칠하는 그림의 매니페스트 누락, 잘린 fill의 crop 행렬, 파일시스템이 못 받는 레이어 이름, 폭마다 크기가 다른 사진의 파일 공유, 투명도 0 노드의 export 거부, 그리고 positioned child 너머로 CSS가 못 미치는 높이입니다. + Section 링크에서 TSX를 요청하면 먼저 내부 screen frame 후보와 canonical URL을 `selection_required`로 반환합니다. `frameIds`로 검토한 frame만 고르거나 `allScreens: true`로 모든 화면을 시각 순서대로 batch export할 수 있으며 두 옵션은 동시에 사용할 수 없습니다. `sourceMap`은 생성 TSX/devup.json의 output 위치를 Figma node, variable, style, asset ID에 연결하는 sidecar입니다. `assetManifest`는 image hash/vector/export provenance를 항상 열거하고, `assetRequests`로 명시한 항목만 최대 16개·scale 1~4 범위에서 read-only SVG/PNG export합니다. `outputPath`를 지정하면 binary를 해당 파일로 디코딩하고 응답의 base64를 제거하며, 생략하면 후속 소비를 위해 base64가 memory-only artifact와 해당 MCP 응답에 남을 수 있습니다. +asset의 파일 이름은 기본적으로 **레이어 이름**입니다 — 플러그인이 그렇게 짓기 때문입니다. 그래서 디자이너가 같은 이름을 준 노드들은 파일 하나를 공유합니다. 같은 그림이면 맞지만 아니면 손실입니다. 한 화면에서 여덟 노드가 `Logo.svg` 하나를 주장하는데 실제로는 서로 다른 그림 다섯 개였고, 폭마다 그려진 사진은 마지막으로 export된 폭의 파일만 남아 다른 폭에서는 상자와 크기가 어긋난 채 늘어납니다(파일이 상자와 같은 크기이면 `object-fit`이 무엇이든 결과가 같으므로, 플러그인에서는 이 문제가 드러나지 않습니다). + +`assetNamesPerNode`는 각 asset을 **그 노드**의 이름으로 지어(`Logo-422-6921.svg`, `Frame 269-422-3392.png`) 둘을 함께 없앱니다. **기본값은 `true`입니다** — 렌더링해 보면 이쪽이 Figma가 그리는 그림에 가깝고(공지 화면 992폭 6.48% → 4.19%, about 992폭 10.79% → 6.94%), 플러그인 golden 268개는 그대로 통과합니다. golden은 `CodegenOptions`를 직접 쓰고 그 **라이브러리 기본값은 여전히 플러그인과 동일**하기 때문입니다. 플러그인과 byte 단위로 같은 이름이 필요하면 `assetNamesPerNode: false`로 끄십시오. + +끈 상태에서 서로 다른 그림이 한 파일을 계속 주장하면 첫 번째만 기록하고 나머지는 `DEVUP_ASSET_NAME_SHARED` diagnostic으로 보고합니다 — 조용히 덮어쓰지 않습니다. + +레이어 이름이 파일시스템이 받지 못하는 이름일 때(`ic:round-arrow-left`처럼 콜론이 든 이름은 Windows가 만들지 못합니다) 전달 시점에 생성 코드와 manifest를 **함께** 개명해 둘이 어긋나지 않게 합니다. 생성기 자체는 플러그인의 이름을 그대로 쓰므로 golden parity는 유지됩니다. + +Section 링크는 전체 subtree를 직접 변환하지 않습니다. `selection_required.nextAction`에 따라 후보를 확인한 뒤 `frameIds` 또는 `allScreens: true`로 화면별 export를 계속하며, 일부 화면 수집이 실패하면 성공한 화면은 유지하고 실패한 node는 `failures`에 보고합니다. + +### 한 화면의 여러 폭 — 반응형 모듈 + +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`). + +한 폭에만 있는 노드는 다른 폭에서 `display: none`으로 숨긴 복사본과 병합되며, 이때 복사본은 **Section 레이어 순서상 첫 폭**의 값을 가집니다 — 그래서 배열의 첫 slot에 desktop 값이 놓일 수 있습니다. 폭마다 줄바꿈 위치만 다른 텍스트는 ``로 쓰고, 컴포넌트 인스턴스의 variant prop이 폭마다 다르면 배열로 쓸 수 없으므로 가장 넓은 폭의 값을 쓰고 `responsiveUnrepresented`에 보고합니다. 함께 반환되는 `responsiveSlots`, `responsiveImports`, `responsiveComponents`가 slot과 import 목록입니다. + +`rawPayload`는 `rawSnapshot`이 node 트리만 쓰는 것과 달리 수집 전체(variables, styles, stats, assets 포함, `referencePng` 제외)를 씁니다. 캡처를 fixture로 보관해 오프라인에서 서버와 같은 토큰 이름(`$gray200`, `typography="h4"`)으로 변환하려면 이것이 필요합니다. + +### 시간 트리거 Smart Animate — CSS keyframes + +frame에 `After delay` 트리거로 다른 frame에 **Smart animate**하는 reaction이 있고, 그 frame이 다시 다음 frame으로 이어지면 하나의 체인입니다(처음 frame으로 돌아오면 루프). 체인의 frame들은 요청한 node의 subtree 밖에 있는 형제 frame이므로, 요청 루트가 하나일 때 snapshot 스크립트가 체인을 따라가며 추가 루트로 함께 수집합니다(다중 루트 요청은 루트 목록을 그대로 둡니다). + +변환기는 플러그인의 `getReactionProps` 규칙대로 frame 사이에서 바뀌는 것 — 위치, 크기, opacity, 첫 fill, 회전(누적 delta) — 을 이름이 같은 자식에서 먼저 찾아 자식마다 `animationName={keyframes({...})}` / `animationDuration` / `animationTimingFunction` / `animationFillMode` / `animationIterationCount`(루프면 `infinite`)로 쓰고, 바뀌는 자식이 없을 때만 frame 자체에 씁니다. `0%`는 시작 frame, 각 단계는 도착 시점의 퍼센트에 직전 keyframe과 다른 속성만, 루프는 `100%`에서 시작 값으로 닫히며 duration은 되돌아가는 구간까지 셉니다. 10ms 미만 timeout은 delay로 쓰지 않습니다. `keyframes`가 쓰이면 `@devup-ui/react`에서 import됩니다. + +snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 버리지 않고 `DEVUP_CODEGEN_ANIMATION_UNREACHABLE` diagnostic으로 보고합니다. + ### Figma 이름 검색 ```json @@ -268,9 +341,9 @@ Section 링크에서 TSX를 요청하면 먼저 내부 screen frame 후보와 ca 탐색과 검색은 변수 catalog를 수집하지 않습니다. 정확한 UI 변환 단계에서 선택 subtree의 모든 보존 필드에 있는 `VARIABLE_ALIAS`와 paint/text/effect/grid style ID를 재귀적으로 스캔하고, 실제 사용된 ID만 공식 Figma API로 조회합니다. `devup_figma_to_json`만 file 전체 로컬 catalog를 수집합니다. -`sourcePolicy`는 `auto`, `direct`, `host` 중 하나입니다. `needs_figma` 응답의 read-only call을 host의 공식 Figma MCP에서 실행한 뒤 원본 result를 `devup_figma_continue`의 `sessionId`, `callId`, `result`로 전달하면 동일한 Rust collector가 이어서 처리합니다. session은 메모리에만 최대 10분 유지되며 완료·오류·만료 시 제거됩니다. direct 경로는 연결과 read-only capability catalog 조회를 각각 30초, 개별 tool 호출을 5분으로 제한합니다. deadline을 넘기면 해당 remote session을 폐기하고 디자인 원문 없이 `retryable` timeout 단계만 반환합니다. +`sourcePolicy`는 `auto` 또는 `direct`입니다 — 둘 다 direct 연결을 쓰며, 남겨둔 이유는 하위호환뿐입니다. direct 경로는 연결과 read-only capability catalog 조회를 각각 30초, 개별 tool 호출을 5분으로 제한합니다. deadline을 넘기면 해당 remote session을 폐기하고 디자인 원문 없이 `retryable` timeout 단계만 반환합니다. -정확한 node 링크의 UI 변환은 우선 하나의 공식 `use_figma` 호출 안에서 subtree 전체와 실제 사용 리소스를 수집합니다. JSON envelope를 512 KiB 단위로 나누고 각 조각을 CRC가 있는 1×1 PNG에 담아 MCP 응답 크기 제한을 피하며, Rust는 MIME·base64·PNG 구조·청크 순서·schema·대상 ID·node graph·리소스 참조를 모두 검증한 뒤에만 결과를 채택합니다. 한 항목이라도 불일치하면 fast 결과 전체를 버리고 기존 cursor 수집을 0부터 재시작합니다. Section multi-root에서는 성공한 root와 resource는 그대로 보존하고 실패하거나 상한을 넘은 root만 legacy로 다시 수집한 뒤 원래 시각 순서로 합칩니다. direct upstream은 연결과 read-only tool catalog를 한 session에서 재사용하고 30초 TTL, 연결 종료 또는 transport 오류 때만 재연결·재검증합니다. 결과의 `stats`에는 `figmaToolCalls`, `transport`, `fallbackUsed`, node/variable/style 수와 byte/청크 수만 포함되며 원본 디자인이나 인증 정보는 포함되지 않습니다. +정확한 node 링크의 UI 변환은 하나 이상의 공식 `use_figma` 호출 안에서 subtree와 실제 사용 리소스를 수집합니다. 수집 스크립트는 checked-in manifest(devup-ui 변환기가 실제로 읽는 필드만)만 확인하고 — 프로토타입 체인 전체를 훑거나 미분류 필드를 `extra`에 담지 않습니다 — `null`/빈 배열/미바인딩 style ID 같은 기본값은 봉투에서 생략합니다. 결과는 항상 텍스트(`devupFastSnapshotEnvelope`)이며 PNG 같은 바이너리 transport는 없습니다. 한 subtree가 15KB 텍스트 한도를 넘으면 같은 스크립트를 `offset`을 옮겨 다시 호출하는 방식으로 텍스트 페이지네이션합니다 — 각 라운드는 그 라운드가 보낸 node에서만 리소스를 스캔해 자기 완결적이며, Rust가 여러 라운드의 node와 리소스를 병합합니다. Rust는 schema·대상 ID·node graph·리소스 참조·(페이지 중이 아닐 때의) 자식 완전성을 모두 검증한 뒤에만 결과를 채택합니다. 한 항목이라도 불일치하면 fast 결과 전체를 버리고 기존 cursor 수집을 0부터 재시작합니다. Section multi-root에서는 성공한 root와 resource는 그대로 보존하고 실패하거나 상한을 넘은 root만 legacy로 다시 수집한 뒤 원래 시각 순서로 합칩니다. direct upstream은 연결과 read-only tool catalog를 한 session에서 재사용하고 30초 TTL, 연결 종료 또는 transport 오류 때만 재연결·재검증합니다. 결과의 `stats`에는 `figmaToolCalls`, `transport`(`text` | `text-paginated` | `legacy-cursor`), `fallbackUsed`, node/variable/style 수와 byte 수만 포함되며 원본 디자인이나 인증 정보는 포함되지 않습니다. 완전성 등급은 다음과 같습니다. @@ -281,7 +354,7 @@ Section 링크에서 TSX를 요청하면 먼저 내부 screen frame 후보와 ca ## 읽기 전용·개인정보 보호 - upstream 호출은 `get_metadata`, `get_variable_defs`, `get_design_context`, `get_code_connect_map`, `get_screenshot`과 내장된 read-only `use_figma` script로 닫혀 있습니다. -- 사용자 입력 JavaScript를 받지 않으며 Figma document mutation API를 호출하지 않습니다. `figma.io.write`는 공식 MCP 응답으로 검증 가능한 1×1 PNG를 반환하는 transport에만 사용하며 Figma 파일을 변경하지 않습니다. +- 사용자 입력 JavaScript를 받지 않으며 Figma document mutation API를 호출하지 않습니다. `figma.io.write`는 asset export(`devup_figma_export`의 `assetRequests`)에만 read-only로 사용하며 Figma 파일을 변경하지 않습니다. fast snapshot/theme envelope는 항상 텍스트로만 반환되며 바이너리 transport를 쓰지 않습니다. - stdout에는 MCP frame만 출력하고 trace는 stderr로 보냅니다. - access token, refresh token, OAuth code, PKCE verifier는 Debug, trace와 MCP error에 포함하지 않습니다. - Figma snapshot과 screenshot을 기본적으로 디스크에 저장하지 않습니다. @@ -298,7 +371,7 @@ Section 링크에서 TSX를 요청하면 먼저 내부 screen frame 후보와 ca `crates/devup-mcp-figma/tests/explore_script_behavior.mjs`는 compile-in `explore.js` 자체를 mock Figma scene graph에서 실행합니다. 두 단계 이상 중첩된 화면의 parent chain, 화면이 없는 1,000-node Section의 `projectionLimit * 8` 방문 상한, 필수 node만 남기는 14,000자 이하 fallback을 검증하며 CI의 Node 내장 test runner로 실행됩니다. 제품 binary와 기본 Cargo test에는 JavaScript runtime 의존성이 추가되지 않습니다. -legacy 경로에서 실제 확인된 공식 metadata는 XML text content envelope이며, local 변수/style은 catalog 후 resource 단위로 수집합니다. style의 `consumers`처럼 단일 field가 공식 MCP의 약 20,500자 text 상한을 넘을 수 있으므로, base field와 320개 단위의 compact consumer relation을 분리해 읽고 Rust에서 원래 exhaustive JSON shape로 재조립합니다. legacy node snapshot도 byte budget과 cursor를 사용해 같은 상한 아래에서 자동 재개합니다. range의 누락·중복이나 수집 중 목록 변경은 성공으로 숨기지 않고 오류로 처리합니다. +legacy 경로에서 실제 확인된 공식 metadata는 XML text content envelope이며, local 변수/style은 catalog 후 resource 단위로 수집합니다. style의 `consumers`처럼 단일 field가 공식 MCP의 text 상한(실측 20,480 UTF-8 바이트, 넘는 만큼 잘리고 `// truncated to 20kb`가 붙음)을 넘을 수 있으므로, base field와 320개 단위의 compact consumer relation을 분리해 읽고 Rust에서 원래 exhaustive JSON shape로 재조립합니다. legacy node snapshot도 byte budget과 cursor를 사용해 같은 상한 아래에서 자동 재개합니다. range의 누락·중복이나 수집 중 목록 변경은 성공으로 숨기지 않고 오류로 처리합니다. ### Server module ownership @@ -315,7 +388,7 @@ Figma Remote MCP에서는 `JSON_REST_V1` export가 허용되지 않으므로 hos - 공식 `get_metadata`의 file-level page 목록은 실제 page 전체보다 적게 반환될 수 있습니다. 이름 검색은 Plugin API page catalog와 per-page projection으로 우회하며 실제 13개 page 파일에서 검증했습니다. - 매우 큰 computed field(예: vector `fillGeometry`)는 현재 값 전체 대신 명시적인 byte-length marker로 보존됩니다. 모든 대용량 field 값을 lossless하게 export하는 기능은 후속 wire-format 개선 대상입니다. - exact-node fast envelope가 8 MiB 안전 상한을 넘거나 공식 MCP가 image transport를 바꾸면 자동 legacy fallback이 여러 cursor call을 사용하므로 subtree 크기에 따라 시간이 늘어날 수 있습니다. -- direct OAuth registration은 Figma MCP Catalog 승인이 없는 private client에서 거절됩니다. `auto`/`host` fallback은 host가 인증한 공식 Figma MCP로 실제 검증했습니다. +- direct OAuth registration은 Figma MCP Catalog 승인이 없는 `client_name`으로는 거절됩니다. 승인된 이름(기본값 `Codex`)으로만 등록이 성립하며, 그 등록은 Figma에게 해당 제품으로 기록됩니다. - 사용되지 않은 외부 Figma library 변수 전체는 Remote MCP가 제공하지 않을 수 있습니다. - node/page theme scope는 로컬 변수 API의 file-wide 결과를 기반으로 하며 세밀한 사용 범위 필터는 후속 보강 대상입니다. - vector, mask, image, absolute layout과 일부 effect는 diagnostics를 포함한 제한적 fallback입니다. diff --git a/crates/devup-mcp-devup-ui/Cargo.toml b/crates/devup-mcp-devup-ui/Cargo.toml index 49dc2457..9f396fed 100644 --- a/crates/devup-mcp-devup-ui/Cargo.toml +++ b/crates/devup-mcp-devup-ui/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [dependencies] devup-mcp-figma = { path = "../devup-mcp-figma" } oxc_allocator.workspace = true +oxc_ast.workspace = true +oxc_ast_visit.workspace = true oxc_parser.workspace = true oxc_span.workspace = true serde.workspace = true diff --git a/crates/devup-mcp-devup-ui/src/codegen/animation.rs b/crates/devup-mcp-devup-ui/src/codegen/animation.rs new file mode 100644 index 00000000..8d0f2907 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/animation.rs @@ -0,0 +1,624 @@ +//! A timed Smart Animate as CSS keyframes — the plugin's `getReactionProps`. +//! +//! A frame that, after a timeout, Smart-Animates to another frame, which after +//! its own timeout animates to the next, is a chain; a chain that comes back +//! to where it started is a loop. The plugin walks the chain and writes what +//! changes from one frame to the next — position, size, opacity, fill, +//! rotation — as keyframes at the moments the changes land. The changes are +//! looked for in the frame's children first, matched by name, and each child +//! that changes carries its own animation; only when no child changes does +//! the frame itself carry one. +//! +//! The frames of a chain are top-level siblings the target's subtree does not +//! hold. The snapshot script gathers them as extra roots, so they are looked +//! up here in the snapshot; a destination that is not there is reported by +//! the caller as it was before. + +use std::collections::BTreeSet; + +use devup_mcp_figma::{RawNode, Snapshot}; +use serde_json::{Map, Value}; + +use super::{ + component::{Prop, PropValue}, + layout::format_number, + style::paint_string, +}; + +/// One step of a chain: the frame the previous one becomes, and how. +struct Step<'a> { + node: &'a RawNode, + duration: f64, + easing: Option, + delay: f64, +} + +/// The reaction that starts a chain: a timed Smart Animate to another frame. +struct Start { + destination: String, + duration: f64, + easing: Option, + timeout: f64, +} + +fn timed_smart_animates(node: &RawNode) -> Vec { + let mut found = Vec::new(); + let Some(reactions) = node + .typed_view() + .value("reactions") + .and_then(Value::as_array) + else { + return found; + }; + for reaction in reactions { + let trigger = reaction.get("trigger"); + if trigger + .and_then(|trigger| trigger.get("type")) + .and_then(Value::as_str) + != Some("AFTER_TIMEOUT") + { + continue; + } + let timeout = trigger + .and_then(|trigger| trigger.get("timeout")) + .and_then(Value::as_f64) + .unwrap_or_default(); + let Some(actions) = reaction.get("actions").and_then(Value::as_array) else { + continue; + }; + for action in actions { + if action.get("type").and_then(Value::as_str) != Some("NODE") { + continue; + } + let Some(transition) = action.get("transition") else { + continue; + }; + if transition.get("type").and_then(Value::as_str) != Some("SMART_ANIMATE") { + continue; + } + let Some(destination) = action.get("destinationId").and_then(Value::as_str) else { + continue; + }; + // `transition.duration || 0.3`: a zero is the default too. + let duration = transition + .get("duration") + .and_then(Value::as_f64) + .filter(|duration| *duration != 0.0) + .unwrap_or(0.3); + found.push(Start { + destination: destination.to_owned(), + duration, + easing: transition + .get("easing") + .and_then(|easing| easing.get("type")) + .and_then(Value::as_str) + .map(str::to_owned), + timeout, + }); + } + } + found +} + +/// The plugin's `buildAnimationChain`: every frame reached from `current` +/// by timed Smart Animates, and whether the chain comes back to `start`. +fn build_chain<'a>( + snapshot: &'a Snapshot, + start_id: &str, + current: &'a RawNode, + duration: f64, + easing: Option, + delay: f64, + visited: &BTreeSet, +) -> (Vec>, bool) { + let mut chain = Vec::new(); + if current.id == start_id { + return (chain, true); + } + let mut visited = visited.clone(); + visited.insert(current.id.clone()); + chain.push(Step { + node: current, + duration, + easing, + delay, + }); + let mut is_loop = false; + for start in timed_smart_animates(current) { + if start.destination == start_id { + is_loop = true; + break; + } + if visited.contains(&start.destination) { + continue; + } + let Some(next) = snapshot.nodes.get(&start.destination) else { + continue; + }; + if matches!(next.node_type.as_str(), "DOCUMENT" | "PAGE") { + continue; + } + let (rest, looped) = build_chain( + snapshot, + start_id, + next, + start.duration, + start.easing, + start.timeout, + &visited, + ); + chain.extend(rest); + if looped { + is_loop = true; + } + } + (chain, is_loop) +} + +/// A node's parent, by its recorded `parentId` or by the node that lists it. +fn parent_of<'a>(snapshot: &'a Snapshot, node: &RawNode) -> Option<&'a RawNode> { + node.typed_view() + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .or_else(|| { + snapshot.nodes.values().find(|candidate| { + candidate + .typed_view() + .child_ids() + .any(|child| child == node.id) + }) + }) +} + +/// The plugin's `isPageRoot`: a frame that sits directly on a page, in a +/// Section or in a component set. A root carries its parent's type; a node +/// whose parent is in the snapshot is read through it. +fn is_page_root(snapshot: &Snapshot, node: &RawNode) -> bool { + if matches!(node.node_type.as_str(), "SECTION" | "PAGE") { + return false; + } + let view = node.typed_view(); + let parent_type = parent_of(snapshot, node) + .map(|parent| parent.node_type.clone()) + .or_else(|| view.string("parentType").map(str::to_owned)); + parent_type.is_some_and(|kind| matches!(kind.as_str(), "SECTION" | "PAGE" | "COMPONENT_SET")) +} + +/// The plugin's `generateSingleNodeDifferences`: what `to` has that `from` +/// does not, as the CSS that would move `from` there. Rotation is kept as a +/// delta under `rotationDelta`, to be summed along the chain. +fn differences( + snapshot: &Snapshot, + from: &RawNode, + to: &RawNode, + variable_tokens: &std::collections::BTreeMap, +) -> Map { + let (from_view, to_view) = (from.typed_view(), to.typed_view()); + let mut changes = Map::new(); + + // A frame's own place is only compared where it is not a top-level frame's + // direct child: for those the plugin skips position, as it does here. + let parent_is_page_root = + parent_of(snapshot, to).is_some_and(|parent| is_page_root(snapshot, parent)); + if !parent_is_page_root + && let (Some(from_x), Some(from_y), Some(to_x), Some(to_y)) = ( + from_view.number("x"), + from_view.number("y"), + to_view.number("x"), + to_view.number("y"), + ) + && (from_x != to_x || from_y != to_y) + { + changes.insert( + "transform".to_owned(), + Value::from(format!( + "translate({}px, {}px)", + format_number(to_x - from_x), + format_number(to_y - from_y) + )), + ); + } + + if let (Some(from_w), Some(from_h), Some(to_w), Some(to_h)) = ( + from_view.number("width"), + from_view.number("height"), + to_view.number("width"), + to_view.number("height"), + ) { + if from_w != to_w { + changes.insert( + "w".to_owned(), + Value::from(format!("{}px", format_number(to_w))), + ); + } + if from_h != to_h { + changes.insert( + "h".to_owned(), + Value::from(format!("{}px", format_number(to_h))), + ); + } + } + + if let (Some(from_opacity), Some(to_opacity)) = + (from_view.number("opacity"), to_view.number("opacity")) + && from_opacity != to_opacity + { + changes.insert("opacity".to_owned(), Value::from(format_number(to_opacity))); + } + + if let (Some(from_fill), Some(to_fill)) = ( + from_view + .value("fills") + .and_then(Value::as_array) + .and_then(|fills| fills.first()), + to_view + .value("fills") + .and_then(Value::as_array) + .and_then(|fills| fills.first()), + ) && from_fill.get("type").and_then(Value::as_str) == Some("SOLID") + && to_fill.get("type").and_then(Value::as_str) == Some("SOLID") + && !same_color(from_fill.get("color"), to_fill.get("color")) + && let Some(color) = paint_string(to_fill, Some(variable_tokens)) + { + changes.insert("bg".to_owned(), Value::from(color)); + } + + if let (Some(from_rotation), Some(to_rotation)) = + (from_view.number("rotation"), to_view.number("rotation")) + && from_rotation != to_rotation + { + let mut delta = to_rotation - from_rotation; + if delta > 180.0 { + delta -= 360.0; + } else if delta < -180.0 { + delta += 360.0; + } + // Figma turns clockwise-negative. + changes.insert("rotationDelta".to_owned(), Value::from(-delta)); + } + changes +} + +fn same_color(left: Option<&Value>, right: Option<&Value>) -> bool { + let channel = |color: Option<&Value>, name: &str| { + color + .and_then(|color| color.get(name)) + .and_then(Value::as_f64) + .unwrap_or_default() + }; + ["r", "g", "b"] + .iter() + .all(|name| (channel(left, name) - channel(right, name)).abs() < 0.01) +} + +fn easing_function(easing: Option<&str>) -> &'static str { + match easing { + Some("EASE_IN") => "ease-in", + Some("EASE_OUT") => "ease-out", + Some("EASE_IN_AND_OUT") => "ease-in-out", + _ => "linear", + } +} + +/// The plugin's `fmtDuration`: three decimals at most, no trailing zeros. +fn format_duration(seconds: f64) -> String { + let rounded = (seconds * 1000.0).round() / 1000.0; + let text = format!("{rounded:.3}"); + let text = text.trim_end_matches('0'); + text.trim_end_matches('.').to_owned() +} + +/// Keyframes from the changes along a chain, as the plugin assembles them, +/// with its rules kept: a property is written at 0% only where it takes more +/// than one value along the chain; a step writes a property only where it +/// differs from what the last keyframe left in effect; a property that +/// appears once is written that once; and a loop ends at 100% back where it +/// began, a rotation completing its turn. `None` when nothing changes. +fn keyframe_props( + changes: &[Map], + starting: &Map, + chain: &[Step<'_>], + is_loop: bool, + delay: f64, +) -> Option> { + let total: f64 = chain.iter().map(|step| step.duration).sum(); + let effective_total = if is_loop { + total + chain[0].duration + } else { + total + }; + + let mut animated: Vec = Vec::new(); + for step_changes in changes { + for key in step_changes.keys() { + let key = if key == "rotationDelta" { + "transform" + } else { + key.as_str() + }; + if !animated.iter().any(|known| known == key) { + animated.push(key.to_owned()); + } + } + } + let has_rotation = changes + .iter() + .any(|step| step.contains_key("rotationDelta")); + let values_of = |key: &str| { + let mut values = BTreeSet::new(); + let mut occurrences = 0; + for step in changes { + if let Some(value) = step.get(key) { + values.insert(value.to_string()); + occurrences += 1; + } + } + (values.len(), occurrences) + }; + + let mut initial = Map::new(); + if !changes.is_empty() && !animated.is_empty() { + for key in &animated { + let needs_initial = (key == "transform" && has_rotation) || values_of(key).0 > 1; + if !needs_initial { + continue; + } + if let Some(value) = starting.get(key) { + initial.insert(key.clone(), value.clone()); + } else if key == "transform" && has_rotation { + initial.insert(key.clone(), Value::from("rotate(0deg)")); + } + } + } + + let mut keyframes = Map::new(); + keyframes.insert("0%".to_owned(), Value::Object(initial.clone())); + + let has_multiple = |key: &str| { + if key == "transform" && has_rotation { + return true; + } + let (distinct, occurrences) = values_of(key); + distinct > 1 || occurrences == 1 + }; + + let mut accumulated = 0.0; + let mut previous = initial.clone(); + let mut cumulative_rotation = 0.0; + let mut changed = false; + for (index, step) in chain.iter().enumerate() { + accumulated += step.duration; + let percentage = (accumulated / effective_total * 100.0).round(); + let key = format!("{}%", format_number(percentage)); + let mut step_changes = changes[index].clone(); + if let Some(delta) = step_changes + .remove("rotationDelta") + .and_then(|delta| delta.as_f64()) + { + cumulative_rotation += delta; + let rotate = format!("rotate({}deg)", format_number(cumulative_rotation)); + let transform = match step_changes.get("transform").and_then(Value::as_str) { + Some(existing) if !existing.is_empty() => format!("{existing} {rotate}"), + _ => rotate, + }; + step_changes.insert("transform".to_owned(), Value::from(transform)); + } + let mut incremental = Map::new(); + for (name, value) in &step_changes { + if has_multiple(name) && previous.get(name) != Some(value) { + incremental.insert(name.clone(), value.clone()); + } + } + if !incremental.is_empty() { + for (name, value) in &incremental { + previous.insert(name.clone(), value.clone()); + } + keyframes.insert(key, Value::Object(incremental)); + changed = true; + } + } + + if is_loop && changed { + let mut last = initial.clone(); + if cumulative_rotation != 0.0 { + let full = + cumulative_rotation.signum() * (cumulative_rotation.abs() / 360.0).ceil() * 360.0; + last.insert( + "transform".to_owned(), + Value::from(format!("rotate({}deg)", format_number(full))), + ); + } + keyframes.insert("100%".to_owned(), Value::Object(last)); + } + + if !changed || keyframes.len() < 2 { + return None; + } + let easing = chain[0].easing.as_deref(); + let mut props: Vec = vec![ + ( + "animationName".to_owned(), + PropValue::String(format!( + "keyframes({})", + serde_json::to_string_pretty(&Value::Object(keyframes)).unwrap_or_default() + )), + ), + ( + "animationDuration".to_owned(), + PropValue::String(format!("{}s", format_duration(effective_total))), + ), + ( + "animationTimingFunction".to_owned(), + PropValue::String(easing_function(easing).to_owned()), + ), + ( + "animationFillMode".to_owned(), + PropValue::String("forwards".to_owned()), + ), + ]; + if delay >= 0.01 { + props.push(( + "animationDelay".to_owned(), + PropValue::String(format!("{}s", format_duration(delay))), + )); + } + if is_loop { + props.push(( + "animationIterationCount".to_owned(), + PropValue::String("infinite".to_owned()), + )); + } + Some(props) +} + +fn child_named<'a>(snapshot: &'a Snapshot, node: &RawNode, name: &str) -> Option<&'a RawNode> { + node.typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .find(|child| child.typed_view().name() == Some(name)) +} + +/// The plugin's `generateChildAnimations`: for each child of the start +/// frame, by name, the keyframes of what changes in the same-named child +/// along the chain. +fn child_animations( + snapshot: &Snapshot, + start: &RawNode, + chain: &[Step<'_>], + is_loop: bool, + variable_tokens: &std::collections::BTreeMap, +) -> Vec<(String, Vec)> { + let mut animations = Vec::new(); + let children = start + .typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .collect::>(); + for child in &children { + let Some(name) = child.typed_view().name() else { + continue; + }; + let mut changes = Vec::new(); + for (index, step) in chain.iter().enumerate() { + let previous = if index == 0 { + start + } else { + chain[index - 1].node + }; + let pair = ( + child_named(snapshot, previous, name), + child_named(snapshot, step.node, name), + ); + changes.push(match pair { + (Some(from), Some(to)) => differences(snapshot, from, to, variable_tokens), + _ => Map::new(), + }); + } + let starting = child_named(snapshot, chain[0].node, name) + .map(|first| differences(snapshot, first, child, variable_tokens)) + .unwrap_or_default(); + if let Some(props) = keyframe_props(&changes, &starting, chain, is_loop, chain[0].delay) { + animations.push((name.to_owned(), props)); + } + } + animations +} + +/// What one start frame's chain animates: its children by name, or, when +/// none of them changes, the frame itself. +enum Animated { + Children(Vec<(String, Vec)>), + Own(Vec), + Nothing, +} + +fn animated_by( + snapshot: &Snapshot, + node: &RawNode, + variable_tokens: &std::collections::BTreeMap, +) -> Animated { + for start in timed_smart_animates(node) { + let Some(destination) = snapshot.nodes.get(&start.destination) else { + continue; + }; + if matches!(destination.node_type.as_str(), "DOCUMENT" | "PAGE") { + continue; + } + let (chain, is_loop) = build_chain( + snapshot, + &node.id, + destination, + start.duration, + start.easing.clone(), + start.timeout, + &BTreeSet::new(), + ); + if chain.is_empty() { + continue; + } + let children = child_animations(snapshot, node, &chain, is_loop, variable_tokens); + if !children.is_empty() { + return Animated::Children(children); + } + let changes = chain + .iter() + .enumerate() + .map(|(index, step)| { + let previous = if index == 0 { + node + } else { + chain[index - 1].node + }; + differences(snapshot, previous, step.node, variable_tokens) + }) + .collect::>(); + let starting = differences(snapshot, chain[0].node, node, variable_tokens); + if let Some(props) = keyframe_props(&changes, &starting, &chain, is_loop, start.timeout) { + return Animated::Own(props); + } + } + Animated::Nothing +} + +/// The animation props a node carries, if any: those its parent's chain +/// gives it by name, else its own. +pub(super) fn push_animation_props( + snapshot: &Snapshot, + node: &RawNode, + variable_tokens: &std::collections::BTreeMap, + props: &mut Vec, +) { + if let Some(parent) = parent_of(snapshot, node) + && let Animated::Children(children) = animated_by(snapshot, parent, variable_tokens) + && let Some(name) = node.typed_view().name() + && let Some((_, animation)) = children.into_iter().find(|(child, _)| child == name) + { + props.extend(animation); + return; + } + if let Animated::Own(animation) = animated_by(snapshot, node, variable_tokens) { + props.extend(animation); + } +} + +/// Whether a node's timed Smart Animate points at a frame the snapshot does +/// not hold, so that nothing can be written for it. +pub(super) fn has_unreachable_destination(snapshot: &Snapshot, node: &RawNode) -> bool { + timed_smart_animates(node) + .iter() + .any(|start| !snapshot.nodes.contains_key(&start.destination)) +} + +#[cfg(test)] +mod tests { + use super::format_duration; + + #[test] + fn durations_are_written_like_the_plugin_s() { + assert_eq!(format_duration(1.6), "1.6"); + assert_eq!(format_duration(0.2), "0.2"); + assert_eq!(format_duration(1.0), "1"); + assert_eq!(format_duration(0.1234), "0.123"); + assert_eq!(format_duration(0.30000001), "0.3"); + } +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/compat.rs b/crates/devup-mcp-devup-ui/src/codegen/compat.rs index d858b514..09d961a1 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/compat.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/compat.rs @@ -357,18 +357,17 @@ pub fn render_viewport_component(input: &Value) -> Option { prop.clone() }; let extra = if asset_components { - let color = children + let paint = children .first()? .get("children")? .as_array()? .first()? .get("fills")? .as_array()? - .first()? - .get("color")?; + .first()?; format!( " bg=\"{}\"\n maskImage={{{{\n{variant_lines}\n }}[{index}]}}\n maskPos=\"center\"\n maskRepeat=\"no-repeat\"\n maskSize=\"contain\"", - color_hex(color)? + color_hex(paint)? ) } else { format!(" src={{{{\n{variant_lines}\n }}[{index}]}}") @@ -516,8 +515,8 @@ pub fn render_responsive_component_mock(input: &Value) -> Option { continue; } let variant = variants.get(&variant_key)?.as_str()?; - let color = child.get("fills")?.as_array()?.first()?.get("color")?; - colors.insert(variant.to_owned(), Value::String(color_hex(color)?)); + let paint = child.get("fills")?.as_array()?.first()?; + colors.insert(variant.to_owned(), Value::String(color_hex(paint)?)); } root_props.insert( selector.to_owned(), @@ -559,14 +558,28 @@ fn normalize_prop_name(value: &str) -> String { result } -fn color_hex(color: &Value) -> Option { +/// Formats a Figma **paint** (not a bare colour) as CSS hex. +/// +/// Takes the whole paint because Figma splits a translucent solid across +/// `color.a` and the paint's own `opacity`; the effective alpha is the product. +/// Reading `color` alone drops `opacity` and renders the fill opaque. +fn color_hex(paint: &Value) -> Option { + let color = paint.get("color")?; let channel = |name: &str| Some((color.get(name)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8); - let value = format!( + let alpha = color.get("a").and_then(Value::as_f64).unwrap_or(1.0) + * paint.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); + let mut value = format!( "#{:02X}{:02X}{:02X}", channel("r")?, channel("g")?, channel("b")? ); + if alpha < 1.0 { + value.push_str(&format!( + "{:02X}", + (alpha.clamp(0.0, 1.0) * 255.0).round() as u8 + )); + } Some(value) } diff --git a/crates/devup-mcp-devup-ui/src/codegen/component.rs b/crates/devup-mcp-devup-ui/src/codegen/component.rs index 1384c6f7..dc9381b7 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component.rs @@ -5,12 +5,12 @@ use devup_mcp_figma::{ }; use serde::{Deserialize, Serialize}; -use super::{layout, style, text, variant}; +use super::{animation, layout, style, text, variant}; use crate::provenance::{ FidelityReport, ProjectionTrace, SourceMap, build_projection_trace, finalize_tsx, mark_node, validate_fidelity, }; -use crate::theme::{normalize_token, variable_token}; +use crate::theme::variable_token; use crate::validation::validate_tsx; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -29,12 +29,34 @@ pub struct CodegenOptions { pub text_style_tokens: std::collections::BTreeMap, pub variable_tokens: std::collections::BTreeMap, pub root_layout: RootLayout, + /// Name every asset after the node it came from, rather than after its + /// layer. Off by default, which is how the plugin names them: a layer + /// name is the file name, and two nodes named alike share a file. + /// + /// That sharing is a loss wherever the two are not the same picture. A + /// designer names three logos `Logo`, and only one of them can be + /// written; a photograph drawn at three widths is one file, so at two of + /// them the file is the wrong size for the box and the picture is + /// stretched into it. Named per node, each gets a file of its own. + pub asset_names_per_node: bool, } impl CodegenOptions { - pub fn with_payload_tokens(mut self, payload: &CollectedPayload) -> Self { - self.text_style_tokens = named_tokens(payload.styles.as_ref(), "styles"); - self.variable_tokens = named_tokens(payload.variables.as_ref(), "variables"); + pub fn with_payload_tokens(self, payload: &CollectedPayload) -> Self { + self.with_resource_results(payload.variables.as_ref(), payload.styles.as_ref()) + } + + /// The two collected resources on their own, for a caller that kept them + /// without the rest of the payload — a capture replayed from a fixture + /// has its variables and styles but no live target, stats or assets to + /// rebuild a `CollectedPayload` around them. + pub fn with_resource_results( + mut self, + variables: Option<&UpstreamResult>, + styles: Option<&UpstreamResult>, + ) -> Self { + self.text_style_tokens = named_tokens(styles, "styles"); + self.variable_tokens = named_tokens(variables, "variables"); self } } @@ -59,7 +81,7 @@ pub fn generate_component( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma snapshot에서 변환할 node를 찾지 못했습니다.", + "Node to convert was not found in the Figma snapshot.", false, ) })?; @@ -75,9 +97,19 @@ pub fn generate_component( .collect::>() .join("\n"); let mut tsx = format!( - "import {{ {} }} from \"@devup-ui/react\";\n\n", + "import {{ {} }} from \"@devup-ui/react\";\n", generated.imports.join(", ") ); + // Naming a component without importing it produces code that reads well and + // does not compile. When instances are left as references, whatever they + // refer to has to be resolvable, and the project convention is one named + // export per file under `@/components`. + for name in referenced_components(&generated.tsx) { + tsx.push_str(&format!( + "import {{ {name} }} from \"@/components/{name}\";\n" + )); + } + tsx.push('\n'); tsx.push_str(&format!( "export function {component_name}() {{\n return (\n{body}\n );\n}}\n" )); @@ -98,7 +130,7 @@ pub fn generate_legacy_component( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma snapshot에서 변환할 node를 찾지 못했습니다.", + "Node to convert was not found in the Figma snapshot.", false, ) })?; @@ -179,7 +211,7 @@ pub fn generate_component_set_target( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma snapshot에서 component set을 찾지 못했습니다.", + "Component set was not found in the Figma snapshot.", false, ) })?; @@ -229,7 +261,7 @@ pub fn generate_component_set_target( .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("component set에서 '{target_name}' 출력을 찾지 못했습니다."), + format!("Output '{target_name}' was not found in the component set."), false, ) })?; @@ -282,14 +314,14 @@ pub fn generate_inlined_component_instance( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "inline instance root를 찾지 못했습니다.", + "Inline instance root was not found.", false, ) })?; let instance = snapshot.nodes.get(instance_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "inline할 component instance를 찾지 못했습니다.", + "Component instance to inline was not found.", false, ) })?; @@ -320,7 +352,7 @@ pub fn generate_inlined_component_instance( .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("'{name}' component set을 찾지 못했습니다."), + format!("Component set '{name}' was not found."), false, ) })?; @@ -341,7 +373,7 @@ pub fn generate_inlined_component_instance( .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("'{name}' instance variant를 찾지 못했습니다."), + format!("Instance variant '{name}' was not found."), false, ) })?; @@ -431,7 +463,7 @@ pub fn render_component_registration_snapshot( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "component registration root를 찾지 못했습니다.", + "Component registration root was not found.", false, ) })?; @@ -453,7 +485,7 @@ pub fn render_component_registration_snapshot( .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("registration 대상 '{target_name}'을 찾지 못했습니다."), + format!("Registration target '{target_name}' was not found."), false, ) })? @@ -963,7 +995,7 @@ fn generate_node_marked( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma snapshot에서 변환할 node를 찾지 못했습니다.", + "Node to convert was not found in the Figma snapshot.", false, ) })?; @@ -977,6 +1009,7 @@ fn generate_node_marked( root }; let mut context = Context { + asset_names_per_node: options.asset_names_per_node, inline_instances: options.inline_instances, text_style_tokens: options.text_style_tokens.clone(), variable_tokens: options.variable_tokens.clone(), @@ -1019,6 +1052,7 @@ fn finalize_codegen_output( #[derive(Default)] struct Context { + asset_names_per_node: bool, imports: BTreeSet, used_tokens: BTreeSet, diagnostics: Vec, @@ -1046,7 +1080,7 @@ fn render_node( if !visiting.insert(node.id.clone()) { return Err(DevupError::new( ErrorCode::DevupCodegenFailed, - "Figma node 트리에 순환 참조가 있습니다.", + "Figma node tree contains a circular reference.", false, )); } @@ -1163,6 +1197,8 @@ fn render_node( "Image" } else if view.node_type() == "TEXT" { "Text" + } else if layout::centres_its_only_child(snapshot, node) { + "Center" } else { match inferred_mode { Some("GRID") => "Grid", @@ -1188,10 +1224,33 @@ fn render_node( context.root_layout, depth == 0, ); + // A frame with no auto-layout places its children itself, and this keeps + // them resolvable. Once the gap around them is measurable it is emitted as + // padding instead, which puts them where they belong on its own — so the + // anchor is only still needed where nothing could be measured, as when the + // child fills the frame exactly or carries no position of its own. + // A page root is not anchored either: the plugin's `getPositionProps` + // leaves `pos: relative` off a frame that sits directly on a page or in + // a Section, and its positioned children resolve against the page. + let page_root = snapshot + .nodes + .values() + .find(|candidate| { + candidate + .typed_view() + .child_ids() + .any(|child| child == node.id) + }) + .map(|parent| parent.typed_view().node_type()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); if !(depth == 0 && context.root_layout == RootLayout::Embedded) + && !page_root && asset.is_none() && view.value("inferredAutoLayout").is_none() && view.string("layoutPositioning") == Some("AUTO") + && layout::derived_padding(snapshot, node).is_none() + && !layout::centres_its_only_child(snapshot, node) && view.child_ids().any(|child| { snapshot .nodes @@ -1208,7 +1267,10 @@ fn render_node( asset, &mut props, &mut context.used_tokens, - &context.variable_tokens, + style::StyleOptions { + variable_tokens: &context.variable_tokens, + asset_names_per_node: context.asset_names_per_node, + }, ); text::push_text_props( &view, @@ -1217,6 +1279,14 @@ fn render_node( &mut context.used_tokens, &mut props, ); + // Last, as the plugin merges `getReactionProps` last: what a timed Smart + // Animate changes becomes keyframes on the child it changes, or on the + // frame itself. + let before = props.len(); + animation::push_animation_props(snapshot, node, &context.variable_tokens, &mut props); + if props.len() > before { + context.imports.insert("keyframes".to_owned()); + } if asset.is_some() { props.retain(|(name, _)| { !matches!( @@ -1329,6 +1399,12 @@ fn render_node( Ok(mark_node(&node.id, rendered)) } +/// A style name without a leading group that is only a number. +/// +/// `0/` and `3/` in front of a style name are how a Figma library is made to +/// sort in the picker; they are not part of what the style is called, and the +/// reference does not carry them into the token. A group that names something +/// (`typography/`) is part of the name and stays. fn named_tokens(result: Option<&UpstreamResult>, collection: &str) -> BTreeMap { fn visit(value: &serde_json::Value, collection: &str, tokens: &mut BTreeMap) { if let Some(values) = value.get(collection).and_then(serde_json::Value::as_array) { @@ -1347,7 +1423,12 @@ fn named_tokens(result: Option<&UpstreamResult>, collection: &str) -> BTreeMap (String, bool) { PropValue::String(value) => render_static_attribute(&name, &value), }) .collect::>(); - let multiline = rendered.len() >= 5; + // Five props, or one that spans lines — a `keyframes({...})` — and the + // props go one to a line, which is the plugin's `propsToString` rule. + let multiline = + rendered.len() >= 5 || rendered.iter().any(|attribute| attribute.contains('\n')); if multiline { let prefix = " ".repeat(depth + 1); + let padded = rendered + .iter() + .map(|attribute| attribute.replace('\n', &format!("\n{prefix}"))) + .collect::>(); ( - format!("\n{prefix}{}", rendered.join(&format!("\n{prefix}"))), + format!("\n{prefix}{}", padded.join(&format!("\n{prefix}"))), true, ) } else { @@ -1398,7 +1486,14 @@ fn render_props(props: &[Prop], depth: usize) -> (String, bool) { } } +/// A prop as JSX. A value is a quoted string, except `animationName` holding +/// a `keyframes({...})` call, which is the expression itself — the plugin's +/// `propsToString` makes the same exception, and it is how devup-ui's +/// `keyframes` is meant to be written. pub(super) fn render_static_attribute(name: &str, value: &str) -> String { + if name == "animationName" && value.starts_with("keyframes(") { + return format!("{name}={{{value}}}"); + } let mut escaped = String::with_capacity(value.len()); for character in value.chars() { match character { @@ -1420,22 +1515,29 @@ fn add_fallback_diagnostics(snapshot: &Snapshot, node: &RawNode, context: &mut C ( view.bool("isMask") == Some(true), "DEVUP_CODEGEN_MASK_FALLBACK", - "Mask는 기본 Box 렌더링으로 보존됩니다.", + "Mask is preserved as a plain Box rendering.", FidelityImpact::Lossy, ), ( view.string("layoutPositioning") == Some("ABSOLUTE") && !layout::absolute_layout_is_exact(snapshot, node), "DEVUP_CODEGEN_ABSOLUTE_FALLBACK", - "절대 배치는 position props로 제한적으로 변환됩니다.", + "Absolute positioning is converted to position props with limited fidelity.", FidelityImpact::Approximated, ), ( view.value("effects") .and_then(serde_json::Value::as_array) - .is_some_and(|effects| !effects.is_empty()), + .is_some_and(|effects| !effects.is_empty()) + && !style::effects_are_exact(&view), "DEVUP_CODEGEN_EFFECT_FALLBACK", - "일부 Figma effect는 계산된 CSS로 변환되지 않을 수 있습니다.", + "Some Figma effects may not be converted into computed CSS.", + FidelityImpact::Lossy, + ), + ( + animation::has_unreachable_destination(snapshot, node), + "DEVUP_CODEGEN_ANIMATION_UNREACHABLE", + "A timed Smart Animate points at a frame that was not collected, so no keyframes are written for it.", FidelityImpact::Lossy, ), ]; @@ -1491,3 +1593,30 @@ pub fn normalize_component_name(input: &str) -> String { } result } + +/// The custom components a rendered body refers to, in the order a reader meets +/// them, deduplicated. A devup-ui primitive is imported from the library and is +/// not one of these; anything else opening in PascalCase is. +fn referenced_components(body: &str) -> Vec { + const PRIMITIVES: [&str; 8] = [ + "Box", "Center", "Flex", "Grid", "Image", "Text", "VStack", "Input", + ]; + let mut seen = BTreeSet::new(); + let mut found = Vec::new(); + for (index, _) in body.match_indices('<') { + let rest = &body[index + 1..]; + let name = rest + .chars() + .take_while(|character| character.is_ascii_alphanumeric() || *character == '_') + .collect::(); + if name.is_empty() + || !name.starts_with(|character: char| character.is_ascii_uppercase()) + || PRIMITIVES.contains(&name.as_str()) + || !seen.insert(name.clone()) + { + continue; + } + found.push(name); + } + found +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index f194575f..e67efceb 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -1,8 +1,79 @@ -use devup_mcp_figma::{RawNode, Snapshot}; +use devup_mcp_figma::{RawNode, Snapshot, TypedNode}; use serde_json::Value; use super::component::{Prop, PropValue, RootLayout}; +/// A box in a parent's coordinates: left, top, width, height. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct Box4 { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +fn box4(value: Option<&Value>) -> Option { + let value = value?; + Some(Box4 { + x: value.get("x")?.as_f64()?, + y: value.get("y")?.as_f64()?, + w: value.get("width")?.as_f64()?, + h: value.get("height")?.as_f64()?, + }) +} + +/// What Figma exports for an asset node, in its parent's coordinates: the +/// node's render bounds, which frame its SVG and PNG exports - measured +/// against `exportAsync` on the official server, 2026-09-07: an instance of +/// 1373x98 whose vector sits inside it exports as 952x104, and a group of +/// 686x735 rotated four degrees exports as 759x585 with the rotation drawn +/// into the paths. `None` where the snapshot does not carry the bounds. +pub(super) fn export_box(snapshot: &Snapshot, node: &RawNode) -> Option { + let view = node.typed_view(); + let render = box4(view.value("absoluteRenderBounds"))?; + let parent = view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id))?; + let parent_box = box4(parent.typed_view().value("absoluteBoundingBox"))?; + Some(Box4 { + x: render.x - parent_box.x, + y: render.y - parent_box.y, + w: render.w, + h: render.h, + }) +} + +/// Where an in-flow asset's export sits inside the box the layout gives it, +/// and how large it is: the render bounds against the bounding box. `None` +/// when they coincide, which is every plain icon, or when the snapshot does +/// not carry them. +pub(super) fn export_offset(node: &RawNode) -> Option { + let view = node.typed_view(); + let render = box4(view.value("absoluteRenderBounds"))?; + let bounds = box4(view.value("absoluteBoundingBox"))?; + let close = |left: f64, right: f64| (left - right).abs() < 0.5; + if close(render.x, bounds.x) + && close(render.y, bounds.y) + && close(render.w, bounds.w) + && close(render.h, bounds.h) + { + return None; + } + Some(Box4 { + x: render.x - bounds.x, + y: render.y - bounds.y, + w: render.w, + h: render.h, + }) +} + +/// The box the layout gives an asset that is not positioned: its bounding +/// box, which is its own box unless it is rotated, when it is the box the +/// rotation sweeps - the box Figma's own layout gives it. +pub(super) fn layout_box(node: &RawNode) -> Option { + box4(node.typed_view().value("absoluteBoundingBox")) +} + pub(super) fn push_layout_props( snapshot: &Snapshot, node: &RawNode, @@ -19,17 +90,21 @@ pub(super) fn push_layout_props( .any(|child| child == node.id) }); let is_root = snapshot.roots.iter().any(|root| root == &node.id); - let is_page_root = parent.is_some_and(|parent| { - matches!( - parent.typed_view().node_type(), - "SECTION" | "PAGE" | "COMPONENT_SET" - ) - }); + // The parent of a collected root sits outside the collected subtree, so it + // cannot be looked up and the node's recorded parent type is the only + // account of it. Without that fallback a screen read as having no parent at + // all and its canvas width was emitted as a real constraint, pinning the + // result to a device size that does not exist. + let is_page_root = parent + .map(|parent| parent.typed_view().node_type()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); let fixed_w = view.string("layoutSizingHorizontal") == Some("FIXED"); let fixed_h = view.string("layoutSizingVertical") == Some("FIXED"); let fill_w = view.string("layoutSizingHorizontal") == Some("FILL"); let fill_h = view.string("layoutSizingVertical") == Some("FILL"); - let absolute = view.string("layoutPositioning") == Some("ABSOLUTE"); + let absolute = view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, node, parent, is_page_root); let embedded_root = is_render_root && root_layout == RootLayout::Embedded; let mut width = None; let mut height = None; @@ -39,44 +114,203 @@ pub(super) fn push_layout_props( // Preserve its visual/layout semantics, but do not constrain the host // with Figma canvas geometry or root positioning. } else if absolute { - push_absolute(node, parent, props); - if matches!(component, "Image" | "Text") { - width = view.number("width").map(px); - height = Some("100%".to_owned()); - } else if view.child_ids().next().is_some() { - width = match ( - view.number("width"), - parent.and_then(|parent| parent.typed_view().number("width")), - ) { - (Some(width), Some(parent_width)) if width >= parent_width => Some("100%".into()), - _ => None, - }; - height = None; - } else if view.node_type() == "FRAME" - && let Some(parent) = parent + let is_asset = super::style::asset_kind(snapshot, node).is_some(); + // An exported asset is placed by what the export frames. The plugin + // places it by the node's own box and rotates it again, and the + // report section's rotated illustration landed 120px low, squeezed + // into 686x735 where its export is 759x585. + let export = is_asset.then(|| export_box(snapshot, node)).flatten(); + push_absolute(snapshot, node, parent, props, export); + // Figma paints children in order, so a pinned picture drawn before + // its siblings sits behind them. CSS paints a positioned element + // after every in-flow sibling whatever the order, so the landing + // page's hero picture came out over its headline and the join-us + // badges over their buttons. Sent behind with `zIndex="-1"` - inside + // the stacking context the parent opens for it, below - it sits + // where Figma has it: above the parent's own background, under the + // content. + // + // Only under a parent that opens that context. A page root is not + // told `relative` and opens none, and `-1` under it would fall + // behind the root's own background instead of resting on it. + if let Some(parent) = parent + && !is_page_root_node(snapshot, parent) + && sits_behind_in_flow_siblings(snapshot, parent, &node.id) { - width = match (view.number("width"), parent.typed_view().number("width")) { - (Some(width), Some(parent_width)) if width == parent_width => Some("100%".into()), - (Some(width), _) => Some(px(width)), - _ => None, - }; - height = match (view.number("height"), parent.typed_view().number("height")) { - (Some(height), Some(parent_height)) if height == parent_height => { - Some("100%".into()) - } - (Some(height), _) => Some(px(height)), - _ => None, + string_prop(props, "zIndex", "-1"); + } + if let Some(export) = export { + width = Some(px(export.w)); + height = Some(px(export.h)); + } + // The plugin's `_getLayoutProps` for a positioned node, as one rule + // rather than a branch per kind of node. Its width is its own only + // while the parent is wider and it is an asset or an empty frame; + // spilling past the parent it is `100%`, and a frame with children in + // it has no width said at all, its children being what sizes it. Its + // height is `100%` for a shape, its own for an empty frame, and unsaid + // once it has children. So the about hero picture, 418px in a 320px + // column, is `boxSize="100%"`, and a hidden 1920px frame in a 992px + // one is `w="100%" h="667px"`. + // + // One departure, on purpose. An asset folds its children away and is + // drawn at a size, and the plugin still says no height for it: the + // 465px puzzle icon comes out `w="465px"` alone, a mask with nothing + // to mask. Here it keeps its height — unless it is wider than its + // parent, where the pinned corpus wants `w="100%"` and no height, and + // `provenance` expects the same. Text keeps its own width and `100%`: + // the plugin says nothing for a positioned text and the corpus wants + // the size written. + let own_width = view.number("width"); + let parent_width = parent.and_then(|parent| parent.typed_view().number("width")); + if export.is_some() { + // Sized above, by the export. + } else if component == "Text" { + width = own_width.map(px); + height = Some("100%".to_owned()); + } else if view.node_type() == "INSTANCE" && is_asset { + // The plugin puts a positioned instance in a wrapper `Box` that + // carries the position, and draws the instance from its main + // component, which sizes itself by its own layout. An instance + // that folds to an asset is one element here, so it takes that + // size: a 651px logo pinned in a 360px banner is + // `w="651px" h="46px"`, not `w="100%"`. An instance kept as a + // component reference is sized as the wrapper is, below. + if fixed_w { + width = own_width.map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + } else { + let parent_wider = matches!( + (own_width, parent_width), + (Some(width), Some(parent_width)) if parent_width > width + ); + let has_children = view.child_ids().next().is_some(); + let holds_children = matches!( + view.node_type(), + "FRAME" + | "GROUP" + | "INSTANCE" + | "COMPONENT" + | "COMPONENT_SET" + | "BOOLEAN_OPERATION" + ); + let empty_frame = holds_children && !has_children; + width = if parent_wider { + (is_asset || empty_frame) + .then(|| own_width.map(px)) + .flatten() + } else { + Some("100%".to_owned()) }; - } else if let Some(parent) = parent { - width = match (view.number("width"), parent.typed_view().number("width")) { - (Some(width), Some(parent_width)) if width == parent_width => Some("100%".into()), - _ => None, + let wider_than_parent = matches!( + (own_width, parent_width), + (Some(width), Some(parent_width)) if width >= parent_width + ); + // A shape with nothing in it is as big as itself. `100%` is + // only the same thing when it covers its parent - the popup's + // dim overlay - and a 220px circle pinned in an 1,102px group + // said `h="100%"` and no width at all, which is no circle. + // + // Only inside a group. The plugin's rule says `100%` and no + // width for a small shape pinned in a frame as well, and the + // pinned corpus holds four such shapes; that is as wrong there, + // but no rendered screen in the corpus shows it, so it keeps + // byte parity until one does. A group is a different case in + // any event: it draws nothing and lays nothing out, so a shape + // in it can only ever be its own size. + let own_height = view.number("height"); + let parent_height = parent.and_then(|parent| parent.typed_view().number("height")); + let parent_taller = matches!( + (own_height, parent_height), + (Some(height), Some(parent_height)) if parent_height > height + ); + let in_a_group = + parent.is_some_and(|parent| parent.typed_view().node_type() == "GROUP"); + let leaf_shape = !has_children && !holds_children && !is_asset && in_a_group; + if leaf_shape && parent_wider { + width = own_width.map(px); + } + height = if has_children { + (is_asset && !wider_than_parent) + .then(|| own_height.map(px)) + .flatten() + } else if empty_frame || (leaf_shape && parent_taller) { + own_height.map(px) + } else { + Some("100%".to_owned()) }; - height = Some("100%".to_owned()); + } + // An absolutely positioned node is out of flow, so nothing constrains + // it from the outside and the branches above may leave it sizeless, + // expecting its children to define the box. That is wrong whenever + // Figma pinned the size and nothing else accounts for it — a folded + // asset has no children left to measure at all. Where the gap around + // the children became padding, though, that padding and the content + // already add back up to the frame, and restating the size only says + // it twice. + // + // Both sides or neither, on purpose. An absolute asset wider than its + // parent gets w="100%" and no height above, and the pinned corpus + // wants exactly that — two goldens carry a full-width rotated + // background mask with no h, and restoring the height there breaks + // byte parity. The box has no height and draws by its mask alone; + // that is the reference's rule, and it is matched rather than fixed. + // + // This is a departure from the plugin, which says no size for a + // positioned frame with children and lets them size it. A 12px box + // holding a 2px dot at its centre then collapses to the dot, and the + // dot lands 5px off; the pinned size is a layout fact, and it is kept. + if fixed_w + && fixed_h + && width.is_none() + && height.is_none() + && derived_padding(snapshot, node).is_none() + { + width = view.number("width").map(px); + height = view.number("height").map(px); + } + // The same fact on the height alone, for a frame that fills its + // parent's width and so was given one above. Its children size it in + // CSS where Figma pinned it: the notice header is 60 tall around a + // 24px row of logo and menu, and centring them in 24 rather than 60 + // put them 18px high of where Figma draws them. + // + // An asset is left out, as it is above: it has no children left to + // measure, and the two goldens carrying a full-width rotated mask + // want their height unsaid. So is a frame whose spare room became + // padding, which already adds back up to the pinned height. + if fixed_h + && height.is_none() + && !is_asset + && view.child_ids().next().is_some() + && derived_padding(snapshot, node).is_none() + { + height = view.number("height").map(px); } } else if is_page_root { // Figma page roots define the component canvas; their editor dimensions - // are not emitted as runtime constraints. + // are not emitted as runtime constraints: a root's width is the + // viewport's, and a root that lays its children out is as tall as + // they are, at any width. + // + // A root that lays nothing out has nothing in flow to give it a + // height — its children are placed absolutely, or its one child is + // centred in it. The plugin leaves it sizeless too, and the popup + // overlay, a 390×800 frame dimmed behind one centred card, comes out + // a box of no height whose dim is never drawn; the answer it wrote + // gave the height back as padding, `py="211.5px"` around a 377px + // card. The drawn height is the design, and it is kept, one value per + // width. The width is still the viewport's. This departs from the + // plugin on purpose. + if lays_nothing_out(node) + && view.child_ids().next().is_some() + && derived_padding(snapshot, node).is_none() + { + height = view.number("height").map(px); + } } else if fixed_w || fixed_h { if fixed_w { width = view.number("width").map(px); @@ -84,6 +318,22 @@ pub(super) fn push_layout_props( if fixed_h { height = view.number("height").map(px); } + // A rotated asset in flow takes the box its rotation sweeps, which + // is the box Figma's layout gives it and the box its export fills; + // its own width and height are the picture's before the turn. + if view + .number("rotation") + .is_some_and(|rotation| rotation.abs() > 0.01) + && super::style::asset_kind(snapshot, node).is_some() + && let Some(bounds) = layout_box(node) + { + if fixed_w { + width = Some(px(bounds.w)); + } + if fixed_h { + height = Some(px(bounds.h)); + } + } if fill_w && (view.value("maxWidth") != Some(&Value::Null) || parent.is_some_and(|parent| child_shrinker(parent, "width"))) @@ -165,6 +415,10 @@ pub(super) fn push_layout_props( } } + // Whether the height was said outright, which decides below whether the + // node still needs to be told to take the space its parent leaves. + let wrote_height = height.is_some(); + let wrote_width = width.is_some(); if let (Some(width), Some(height)) = (&width, &height) && width == height { @@ -178,7 +432,12 @@ pub(super) fn push_layout_props( } } - if let Some(aspect) = view.value("targetAspectRatio").and_then(Value::as_object) + // A positioned node has its size said outright — the plugin's absolute + // branch of `_getLayoutProps` never writes `aspectRatio` — so the ratio + // is only for a node in flow, where it stands in for a side that is not + // written. The about hero picture is `boxSize="100%"` with no ratio. + if !absolute + && let Some(aspect) = view.value("targetAspectRatio").and_then(Value::as_object) && let (Some(x), Some(y)) = ( aspect.get("x").and_then(Value::as_f64), aspect.get("y").and_then(Value::as_f64), @@ -203,6 +462,11 @@ pub(super) fn push_layout_props( } if view.string("parentId").is_some() && let Some(parent) = parent + // A component set's grid is how Figma lays its variants out on the + // canvas, not how the component is built. A variant is drawn on its + // own wherever it is used, so carrying the cell it sat in would place + // every button at the coordinates of its row in the sheet. + && parent.typed_view().node_type() != "COMPONENT_SET" && parent .typed_view() .value("inferredAutoLayout") @@ -213,6 +477,13 @@ pub(super) fn push_layout_props( { let column = view.number("gridColumnAnchorIndex").unwrap_or(-1.0); let row = view.number("gridRowAnchorIndex").unwrap_or(-1.0); + // How many tracks the child covers. The plugin writes `span 1` for + // every child, and a picture drawn across two columns came out in one, + // its neighbour pushed a row down and every `1fr` row stretched to + // the tallest picture: a 1086px grid rendered 2191px tall. The spans + // are Figma's own, absent from the snapshot when they are 1. + let column_span = view.number("gridColumnSpan").unwrap_or(1.0).max(1.0); + let row_span = view.number("gridRowSpan").unwrap_or(1.0).max(1.0); let column_count = parent.typed_view().number("gridColumnCount").unwrap_or(0.0); let current = column + row * column_count; let natural = parent @@ -220,43 +491,216 @@ pub(super) fn push_layout_props( .child_ids() .position(|child| child == node.id) .map(|index| index as f64); - if column >= 0.0 && row >= 0.0 && natural != Some(current) { + // A child at its natural cell needs no placement - unless it spans, + // which flow alone would not give it. + if column >= 0.0 + && row >= 0.0 + && (natural != Some(current) || column_span > 1.0 || row_span > 1.0) + { string_prop( props, "gridColumn", - format!("{} / span 1", format_number(column + 1.0)), + format!( + "{} / span {}", + format_number(column + 1.0), + format_number(column_span) + ), ); string_prop( props, "gridRow", - format!("{} / span 1", format_number(row + 1.0)), + format!( + "{} / span {}", + format_number(row + 1.0), + format_number(row_span) + ), ); } } if fill_w && parent .is_some_and(|parent| parent.typed_view().string("layoutMode") == Some("HORIZONTAL")) + { + string_prop(props, "flex", "1"); + // `flex: 1` shares out the room left over, and Figma gives a filling + // child exactly that - here 123px - and lets anything wider inside it + // spill out. CSS will not shrink a flex item under its content + // (`min-width: auto`), so a 132px row inside a 123px share widened + // the share to 132 and took the 9px from the hugging sibling, whose + // `Devup-ui` then broke across two lines. `minW="0"` lets the share + // be the share, and the content spill as Figma draws it. Written + // only where the snapshot shows content wider than the box, which is + // the only place the two layouts part. + if let Some(own) = view.number("width") + && view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + && child.number("width").is_some_and(|width| width > own + 0.5) + }) + { + string_prop(props, "minW", "0"); + } + } + // The same along the other axis, for the one node CSS cannot size on its + // own. A node set to fill its parent's main axis is stretched by Figma to + // the space left over; said nothing about, CSS lets it hug its content + // instead. That usually agrees - a column of in-flow children adds up to + // the height Figma gave it - but a positioned child adds nothing to the + // height of what holds it, so hugging can never reach it. The about + // page's hero column is 440 tall in a 520 tall section and came out 155, + // the height of its text alone; the section then centred that, pushing it + // 143px down and dropping the picture hung off it over the heading it is + // meant to sit above. + let holds_a_positioned_child = view.child_ids().any(|child_id| { + snapshot + .nodes + .get(child_id) + .is_some_and(|child| child.typed_view().string("layoutPositioning") == Some("ABSOLUTE")) + }); + if fill_h + && !wrote_height + && holds_a_positioned_child + && parent.is_some_and(|parent| parent.typed_view().string("layoutMode") == Some("VERTICAL")) { string_prop(props, "flex", "1"); } + // A child Figma never shrinks, in a line that does not fit. Figma keeps a + // fixed size and lets the row spill past its parent, which clips it; CSS + // shrinks flex children to fit instead. The devup-ui landing page's + // comparison row is seven 240px cards in a 912px frame - 1,800px of + // content - and every one of them was squeezed to about 120px, their + // labels wrapped to two lines, and the row came out 58px taller than the + // design, carrying everything below it down with it. + // + // Only where the line actually overflows. Children that fit are not + // shrunk by CSS either, and saying so for every fixed child in the file + // would be noise. + if !absolute + && let Some(parent) = parent + && let Some(axis) = parent.typed_view().string("layoutMode") + && matches!(axis, "VERTICAL" | "HORIZONTAL") + { + let (sizing, size, near, far) = if axis == "HORIZONTAL" { + ( + "layoutSizingHorizontal", + "width", + "paddingLeft", + "paddingRight", + ) + } else { + ( + "layoutSizingVertical", + "height", + "paddingTop", + "paddingBottom", + ) + }; + if view.string(sizing) == Some("FIXED") && line_overflows(snapshot, parent, size, near, far) + { + string_prop(props, "flexShrink", "0"); + } + } + // A child that hugs across its parent's axis, in a parent that packs its + // children to the start, drawn narrower than the room it has, and drawn + // differently for being stretched into it. + // + // Figma's default counter-axis alignment is MIN, which it writes by + // leaving the field out - and leaving it out of the code too means CSS + // applies its own default, `align-items: stretch`, which is the + // opposite. The devup-ui landing page's `Get started` button, 247px wide + // in the 1360px column that holds it, was drawn 1360px wide. + // + // Most hugging children do not care. A line of left-aligned text in a + // box that paints nothing is the same picture at any width, and writing + // an alignment for every one of them - the notice desktop alone holds + // twenty - says nothing while burying the few that matter. So it is + // written where the wider box would show: where the node paints across + // it, or places its own content by it. + if !absolute + && let Some(parent) = parent + && let Some(axis) = parent.typed_view().string("layoutMode") + && matches!(axis, "VERTICAL" | "HORIZONTAL") + && matches!( + parent.typed_view().string("counterAxisAlignItems"), + None | Some("MIN") + ) + { + let across_is_horizontal = axis == "VERTICAL"; + let (sizing, size, near, far, wrote) = if across_is_horizontal { + ( + "layoutSizingHorizontal", + "width", + "paddingLeft", + "paddingRight", + wrote_width, + ) + } else { + ( + "layoutSizingVertical", + "height", + "paddingTop", + "paddingBottom", + wrote_height, + ) + }; + if view.string(sizing) == Some("HUG") + && !wrote + && let (Some(own), Some(room)) = + (view.number(size), inner_extent(parent, size, near, far)) + && own + 0.5 < room + && a_wider_box_would_show(&view, across_is_horizontal) + { + string_prop(props, "alignSelf", "flex-start"); + } + } - push_auto_layout(node, component, props); - push_padding(node, props); + push_auto_layout(snapshot, node, component, props); + push_padding(snapshot, node, props); if view.bool("clipsContent") == Some(true) { string_prop(props, "overflow", "hidden"); } + // An absolutely positioned child needs a positioned ancestor to resolve + // against — but a node folded into a single asset has no children left in + // the output, so there is nothing to anchor and the containing block would + // exist for no one. + // + // A node that is itself positioned is already that ancestor, and saying + // `relative` over its `absolute` would put it back in flow: the join-us + // group of circles, pinned at -277,-187, took 1,102px of the page. if !embedded_root && !is_page_root + && !absolute + && super::style::asset_kind(snapshot, node).is_none() && view.child_ids().any(|child| { snapshot.nodes.get(child).is_some_and(|child| { child.typed_view().string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, child, Some(node), false) }) }) { string_prop(props, "pos", "relative"); + // A child sent behind its siblings with `zIndex="-1"` would fall + // behind this node's own background too, unless this node is the + // stacking context it is placed in. `zIndex="0"` makes it one. + if view + .child_ids() + .any(|child| sits_behind_in_flow_siblings(snapshot, node, child)) + { + string_prop(props, "zIndex", "0"); + } } + // An export is drawn with its rotation in it, so an asset whose bounds + // the snapshot carries is not rotated again; without the bounds it is + // placed as the plugin places it, rotation and all. + let exported = super::style::asset_kind(snapshot, node).is_some() + && view.value("absoluteRenderBounds").is_some(); if let Some(rotation) = view.number("rotation") && rotation.abs() > 0.01 + && !exported { string_prop( props, @@ -292,6 +736,17 @@ pub(super) fn absolute_layout_is_exact(snapshot: &Snapshot, node: &RawNode) -> b }; let no_rotation = view.number("rotation").is_none_or(|value| value == 0.0); let exact_size = parent.is_some_and(|parent| { + // A node pinned on both axes now emits those exact dimensions even + // when it has children, because the absolute branch of + // `push_layout_props` restates them rather than letting the children + // define the box. Keep this in step with that branch: judging such a + // node approximated would report a loss the output no longer has. + if view.string("layoutSizingHorizontal") == Some("FIXED") + && view.string("layoutSizingVertical") == Some("FIXED") + && view.child_ids().next().is_some() + { + return true; + } if view.node_type() != "FRAME" { return false; } @@ -338,7 +793,40 @@ fn child_shrinker(parent: &RawNode, dimension: &str) -> bool { } } -fn push_auto_layout(node: &RawNode, component: &str, props: &mut Vec) { +/// A grid's tracks as CSS, from Figma's track sizes: a `FLEX` track is its +/// share in `fr`, a `FIXED` one its pixels, a `HUG` one `fit-content(100%)`, +/// as Figma's own documentation maps them. Tracks all alike fold to +/// `repeat(n, …)`. The plugin writes `repeat(n, 1fr)` for every grid, which +/// is right only while every track is one flexible share; without the sizes +/// in the snapshot that is what this falls back to. +fn grid_template(sizes: Option<&Value>, count: f64) -> String { + let tracks = sizes + .and_then(Value::as_array) + .map(|tracks| { + tracks + .iter() + .map(|track| { + let value = track.get("value").and_then(Value::as_f64); + match track.get("type").and_then(Value::as_str) { + Some("FIXED") => px(value.unwrap_or(0.0)), + Some("HUG") => "fit-content(100%)".to_owned(), + _ => format!("{}fr", format_number(value.unwrap_or(1.0))), + } + }) + .collect::>() + }) + .filter(|tracks| !tracks.is_empty()) + .unwrap_or_else(|| vec!["1fr".to_owned(); count.max(0.0) as usize]); + // `repeat(1, 1fr)` for one track too: that is the plugin's spelling, and + // the corpus holds it. + if !tracks.is_empty() && tracks.iter().all(|track| track == &tracks[0]) { + format!("repeat({}, {})", tracks.len(), tracks[0]) + } else { + tracks.join(" ") + } +} + +fn push_auto_layout(snapshot: &Snapshot, node: &RawNode, component: &str, props: &mut Vec) { let view = node.typed_view(); let Some(layout) = view.value("inferredAutoLayout").and_then(Value::as_object) else { return; @@ -351,17 +839,17 @@ fn push_auto_layout(node: &RawNode, component: &str, props: &mut Vec) { string_prop( props, "gridTemplateColumns", - format!( - "repeat({}, 1fr)", - format_number(view.number("gridColumnCount").unwrap_or(0.0)) + grid_template( + view.value("gridColumnSizes"), + view.number("gridColumnCount").unwrap_or(0.0), ), ); string_prop( props, "gridTemplateRows", - format!( - "repeat({}, 1fr)", - format_number(view.number("gridRowCount").unwrap_or(0.0)) + grid_template( + view.value("gridRowSizes"), + view.number("gridRowCount").unwrap_or(0.0), ), ); let row = view.number("gridRowGap").unwrap_or(0.0); @@ -376,11 +864,15 @@ fn push_auto_layout(node: &RawNode, component: &str, props: &mut Vec) { } return; } + // Figma's two newer distributions came to the plugin after the pinned + // corpus (9214391); they are the CSS keywords of the same name. let justify = match view.string("primaryAxisAlignItems") { Some("MIN") => None, Some("MAX") => Some("flex-end"), Some("CENTER") => Some("center"), Some("SPACE_BETWEEN") => Some("space-between"), + Some("SPACE_AROUND") => Some("space-around"), + Some("SPACE_EVENLY") => Some("space-evenly"), _ => None, }; let align = match view.string("counterAxisAlignItems") { @@ -401,8 +893,16 @@ fn push_auto_layout(node: &RawNode, component: &str, props: &mut Vec) { if component == "Center" && mode == Some("VERTICAL") { string_prop(props, "flexDir", "column"); } - if view.child_ids().count() > 1 && view.string("primaryAxisAlignItems") != Some("SPACE_BETWEEN") - { + // Spacing only means something between things that are actually there. A + // hidden child is not rendered, so a frame holding one visible child and + // one `display: none` sibling has nothing to space apart, and naming a gap + // implies a separation the design does not have. + let visible_children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)) + .count(); + if visible_children > 1 && view.string("primaryAxisAlignItems") != Some("SPACE_BETWEEN") { let gap = layout .get("itemSpacing") .and_then(Value::as_f64) @@ -413,52 +913,511 @@ fn push_auto_layout(node: &RawNode, component: &str, props: &mut Vec) { } } -fn push_padding(node: &RawNode, props: &mut Vec) { +/// The gap between a frame's edges and the box its children occupy. +/// +/// Figma reports this as the padding of the auto-layout it infers for a frame +/// that has none. When it declines to infer one the same quantity still +/// describes the frame, so measure it rather than fall back to the frame's own +/// padding fields, which linger from whenever it last had a layout and no +/// longer place anything. +/// The padding this node will actually be given from its children's placement. +/// +/// A folded asset is excluded: its children are baked into the exported image +/// and never laid out, so measuring a gap around them would describe a box +/// nothing lives in. +/// How much room a frame leaves its children across one axis: its own size +/// less the padding on that axis. `None` when the size is not recorded. +fn inner_extent(node: &RawNode, size: &str, near: &str, far: &str) -> Option { let view = node.typed_view(); let inferred = view.value("inferredAutoLayout").and_then(Value::as_object); + let padding = |name: &str| { + inferred + .and_then(|layout| layout.get(name)) + .and_then(Value::as_f64) + .or_else(|| view.number(name)) + .unwrap_or(0.0) + }; + Some(view.number(size)? - padding(near) - padding(far)) +} + +/// Whether a node drawn into a wider box than it asked for would look any +/// different: either it paints across the box - a fill, a stroke, a shadow - +/// or it places its own content by the box's far edge or centre along that +/// axis. A left-aligned line of text in a box that paints nothing does not. +fn a_wider_box_would_show(view: &TypedNode<'_>, across_is_horizontal: bool) -> bool { + let visible = |value: Option<&Value>| { + value.and_then(Value::as_array).is_some_and(|entries| { + entries.iter().any(|entry| { + entry.get("visible").and_then(Value::as_bool) != Some(false) + && entry.get("type").and_then(Value::as_str) != Some("NONE") + }) + }) + }; + // A text node's fills are its ink, not its box, and a shadow follows the + // glyphs: none of them widen with the box. A centred line would move, + // and the pinned corpus holds three such texts - but no screen in the + // corpus renders one, so there is nothing to show that writing it helps, + // and it costs byte parity with the plugin on all three. Left alone + // until a rendered screen asks for it. + if view.node_type() == "TEXT" { + return false; + } + if visible(view.value("fills")) + || visible(view.value("strokes")) + || visible(view.value("effects")) + { + return true; + } + // Along its own main axis a node is placed by `primaryAxisAlignItems`, + // across it by `counterAxisAlignItems`; which of the two answers for the + // axis being stretched depends on which way the node itself runs. + let along_its_main_axis = match view.string("layoutMode") { + Some("HORIZONTAL") => across_is_horizontal, + Some("VERTICAL") => !across_is_horizontal, + _ => return false, + }; + if along_its_main_axis { + matches!( + view.string("primaryAxisAlignItems"), + Some("CENTER" | "MAX" | "SPACE_BETWEEN" | "SPACE_AROUND" | "SPACE_EVENLY") + ) + } else { + matches!(view.string("counterAxisAlignItems"), Some("CENTER" | "MAX")) + } +} + +/// Whether a frame's children, laid end to end along its own axis with the +/// gaps between them, come to more than the room it leaves. Figma lets them +/// spill and clips; CSS shrinks them to fit, so the two only agree while +/// they fit. Children out of flow or not drawn take no room. +fn line_overflows(snapshot: &Snapshot, node: &RawNode, size: &str, near: &str, far: &str) -> bool { + let view = node.typed_view(); + let Some(room) = inner_extent(node, size, near, far) else { + return false; + }; + let children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + }) + .collect::>(); + if children.len() < 2 { + return false; + } + let gap = view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .and_then(|layout| layout.get("itemSpacing")) + .and_then(Value::as_f64) + .or_else(|| view.number("itemSpacing")) + .unwrap_or(0.0); + let mut extent = gap * (children.len() - 1) as f64; + for child in children { + let Some(own) = child.typed_view().number(size) else { + return false; + }; + extent += own; + } + extent > room + 0.5 +} + +pub(crate) fn derived_padding(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { + let view = node.typed_view(); + // Figma reports a frame it cannot infer a layout for as an explicit null, + // so presence alone does not mean there is a layout to read. + if view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .is_some() + || view.string("layoutMode") != Some("NONE") + { + return None; + } + if super::style::asset_kind(snapshot, node).is_some() { + return None; + } + // Padding places one child. Two or more at their own positions cannot be + // put back by an inset around all of them — in flow they would stack — + // so they are placed one by one instead, see `placed_by_a_free_layout`. + only_visible_child(snapshot, node)?; + // A child the designer centred is centred, not padded: see + // `centres_its_only_child`. + if centres_its_only_child(snapshot, node) { + return None; + } + children_inset(snapshot, node) +} + +/// The one visible child of a frame, when there is exactly one. +fn only_visible_child<'a>(snapshot: &'a Snapshot, node: &RawNode) -> Option<&'a RawNode> { + let view = node.typed_view(); + let mut visible = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)); + match (visible.next(), visible.next()) { + (Some(only), None) => Some(only), + _ => None, + } +} + +/// Whether a frame that lays nothing out holds one child the designer centred +/// on both axes — constraints `CENTER` / `CENTER`. +/// +/// Such a child is centred by its constraint, not by the inset it happened to +/// have at the width it was drawn: the popup card sits 36.5px in at 390px and +/// 742px in at 1920px, and a measured `pl="36.5px"` holds at the one width +/// and drifts at every other. The frame is written as a `Center` with the +/// child in flow, which centres it at any width and needs no positioned +/// ancestor; a page root keeps its drawn height so there is something to +/// centre in, see `push_layout_props`. +/// +/// This departs from the plugin, on purpose. Its `canBeAbsolute` writes the +/// child `pos="absolute" left="50%" top="50%" transform="translate(-50%, +/// -50%)"`, which resolves against whatever positioned ancestor the page is +/// given, and leaves the frame — a page root, so sizeless — a box of no +/// height whose dim is never drawn. The answer it wrote for the popup, from a +/// layout Figma has since stopped inferring, pads the card in by the inset at +/// each width and at desktop by none, which leaves the card at the left of a +/// 1920px screen. +/// +/// A child centred on one axis only, or pinned to an edge, keeps the measured +/// inset as padding, as before. +pub(crate) fn centres_its_only_child(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + if view.string("layoutMode") != Some("NONE") || !lays_nothing_out(node) { + return false; + } + if super::style::asset_kind(snapshot, node).is_some() { + return false; + } + only_visible_child(snapshot, node).is_some_and(|only| { + only.typed_view() + .value("constraints") + .and_then(Value::as_object) + .is_some_and(|constraints| { + ["horizontal", "vertical"] + .iter() + .all(|axis| constraints.get(*axis).and_then(Value::as_str) == Some("CENTER")) + }) + }) +} + +/// The plugin's `isFreelayout`: a frame in flow with no auto layout, whose +/// children sit where the designer left them. +fn lays_nothing_out(node: &RawNode) -> bool { + let view = node.typed_view(); + view.string("layoutPositioning") == Some("AUTO") + && view.number("width").is_some() + && view.number("height").is_some() + && view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .is_none() + && !matches!( + view.string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL" | "GRID") + ) +} + +pub(super) fn children_inset(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { + let view = node.typed_view(); + let (width, height) = (view.number("width")?, view.number("height")?); + let mut bounds: Option<[f64; 4]> = None; + for child in view.child_ids().filter_map(|id| snapshot.nodes.get(id)) { + let child = child.typed_view(); + if child.bool("visible") == Some(false) { + continue; + } + let (Some(x), Some(y), Some(child_width), Some(child_height)) = ( + child.number("x"), + child.number("y"), + child.number("width"), + child.number("height"), + ) else { + continue; + }; + bounds = Some(match bounds { + Some([left, top, right, bottom]) => [ + left.min(x), + top.min(y), + right.max(x + child_width), + bottom.max(y + child_height), + ], + None => [x, y, x + child_width, y + child_height], + }); + } + let [left, top, right, bottom] = bounds?; + let inset = [top, width - right, height - bottom, left]; + // Children can sit outside the frame, and a negative padding describes + // nothing. + inset.iter().all(|edge| *edge >= 0.0).then_some(inset) +} + +fn push_padding(snapshot: &Snapshot, node: &RawNode, props: &mut Vec) { + let view = node.typed_view(); + let inferred = view.value("inferredAutoLayout").and_then(Value::as_object); + let derived = derived_padding(snapshot, node); let get = |name: &str| { inferred .and_then(|value| value.get(name)) .and_then(Value::as_f64) + .or_else(|| { + derived.map(|[top, right, bottom, left]| match name { + "paddingTop" => top, + "paddingRight" => right, + "paddingBottom" => bottom, + _ => left, + }) + }) .or_else(|| view.number(name)) }; - let [Some(top), Some(right), Some(bottom), Some(left)] = [ + let [ + Some(mut top), + Some(mut right), + Some(mut bottom), + Some(mut left), + ] = [ get("paddingTop"), get("paddingRight"), get("paddingBottom"), get("paddingLeft"), - ] else { + ] + else { return; }; + // An inside stroke is painted over the padding: a card 20px in from its + // edge with a 1px stroke inside is still 20px in, stroke and all, and + // its height is the content plus 40. CSS's border is added around the + // padding instead, so a hugging card came out 2px taller - the landing + // page's four feature cards put the join-us panel 8px down - and on a + // fixed axis the content sat 1px further in. Taking the stroke out of + // the padding puts the content where Figma has it on every axis: it + // starts `p` in and, hugging, the box is content plus `2p`. + if view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" + && view.node_type() != "LINE" + && let Some(weight) = view.number("strokeWeight").filter(|weight| *weight > 0.0) + && view + .value("strokes") + .and_then(Value::as_array) + .is_some_and(|strokes| { + strokes.iter().any(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("type").and_then(Value::as_str) == Some("SOLID") + }) + }) + { + top = (top - weight).max(0.0); + bottom = (bottom - weight).max(0.0); + left = (left - weight).max(0.0); + right = (right - weight).max(0.0); + } if top == 0.0 && right == 0.0 && bottom == 0.0 && left == 0.0 { return; } - if top == right && right == bottom && bottom == left { - string_prop(props, "p", px(top)); + // A zero padding is the default, so naming it says nothing. Emitting it + // only because the other axis happened to be padded left props like + // `px="0px"` sitting next to a real `py`. + let mut push = |name: &str, value: f64| { + if value != 0.0 { + string_prop(props, name, px(value)); + } + }; + // Compare the values as they will be written. Insets measured from a + // child's position carry the arithmetic's noise — a 20px box around a + // 14.285714px child gives 2.857142686 on one side and 2.857143163 on the + // other — and those are the same padding to anyone reading the result. + // Comparing the raw floats split it into four separate sides. + let same = |left: f64, right: f64| px(left) == px(right); + if same(top, right) && same(right, bottom) && same(bottom, left) { + push("p", top); } else { - if top == bottom { - string_prop(props, "py", px(top)); + if same(top, bottom) { + push("py", top); } else { - string_prop(props, "pt", px(top)); - string_prop(props, "pb", px(bottom)); + push("pt", top); + push("pb", bottom); } - if left == right { - string_prop(props, "px", px(left)); + if same(left, right) { + push("px", left); } else { - string_prop(props, "pl", px(left)); - string_prop(props, "pr", px(right)); + push("pl", left); + push("pr", right); } } } -fn push_absolute(node: &RawNode, parent: Option<&RawNode>, props: &mut Vec) { +/// Whether a node in flow is nonetheless placed by its parent, because the +/// parent lays nothing out. +/// +/// A frame with no auto layout puts each child where the designer left it, +/// and the plugin's `canBeAbsolute` writes every such child at its +/// constraints — `pos="absolute"` with the edges it is pinned to — and gives +/// the frame `pos="relative"` to hold them. Here that was only done for a +/// child marked absolute, so the notice banner's title and its two logos, three +/// children of a free frame, were stacked in flow with no position at all. +/// +/// Two cases are kept out. A frame whose single child's inset can be measured +/// is written with that inset as padding and the child in flow, which puts it +/// in the same place and lets it size the frame; and a frame whose single +/// child is centred is written as a `Center` with the child in flow, see +/// `centres_its_only_child`. +/// Whether a node is a screen's root on the canvas: its parent is a page, a +/// section or a component set, read from the parent when it was collected +/// and from the node's own record of it when it was not. The same reading +/// `push_layout_props` makes for the node it is laying out. +fn is_page_root_node(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + view.string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .map(|parent| parent.typed_view().node_type()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")) +} + +/// Whether `child_id` is a positioned child of `parent` that Figma draws +/// under everything else in it: nothing in flow comes before it, and +/// something in flow comes after. CSS paints a positioned element after every +/// in-flow sibling whatever the order, so such a child has to be sent behind +/// on purpose. +/// +/// `zIndex="-1"` sends it behind *every* in-flow sibling, not only the later +/// ones, so it is only right when there is nothing earlier to stay above. +/// The notice page's header is pinned second, after its banner: sent behind, +/// it vanished under the banner it is meant to sit on. It is left where CSS +/// puts it, above all of them, which nothing there overlaps anyway. +fn sits_behind_in_flow_siblings(snapshot: &Snapshot, parent: &RawNode, child_id: &str) -> bool { + let ids = parent.typed_view().child_ids().collect::>(); + let Some(index) = ids.iter().position(|id| *id == child_id) else { + return false; + }; + let positioned = |node: &RawNode| { + node.typed_view().string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, node, Some(parent), false) + }; + let in_flow = + |node: &RawNode| node.typed_view().bool("visible") != Some(false) && !positioned(node); + let Some(child) = snapshot.nodes.get(child_id) else { + return false; + }; + if !positioned(child) { + return false; + } + let earlier_in_flow = ids[..index] + .iter() + .filter_map(|id| snapshot.nodes.get(*id)) + .any(in_flow); + let later_in_flow = ids[index + 1..] + .iter() + .filter_map(|id| snapshot.nodes.get(*id)) + .any(in_flow); + !earlier_in_flow && later_in_flow +} + +pub(crate) fn placed_by_a_free_layout( + snapshot: &Snapshot, + node: &RawNode, + parent: Option<&RawNode>, + is_page_root: bool, +) -> bool { + let view = node.typed_view(); + let Some(parent) = parent else { + return false; + }; + // Whether the parent is itself placed by its own parent or pinned into + // it says nothing about whether it lays its children out. The devup-ui + // landing page's join-us panel holds a group of ten circles pinned to + // the card at -277,-187; because the group is `ABSOLUTE`, its children + // were read as being in flow, lost the coordinates they carry, stacked + // from the group's corner and were clipped away - the arcs and both + // badges were not drawn at all. + // + // This is only about the parent. `lays_nothing_out` also answers for a + // node about itself, where being pinned does decide what it is, so it is + // left alone. + let parent_view = parent.typed_view(); + let parent_lays_nothing_out = matches!( + parent_view.string("layoutPositioning"), + Some("AUTO" | "ABSOLUTE") + ) && parent_view.number("width").is_some() + && parent_view.number("height").is_some() + && parent_view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .is_none() + && !matches!( + parent_view.string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL" | "GRID") + ); + !is_page_root + && view.value("constraints").is_some() + && parent_lays_nothing_out + && derived_padding(snapshot, parent).is_none() + && !centres_its_only_child(snapshot, parent) +} + +fn push_absolute( + snapshot: &Snapshot, + node: &RawNode, + parent: Option<&RawNode>, + props: &mut Vec, + placed_by: Option, +) { string_prop(props, "pos", "absolute"); let view = node.typed_view(); let Some(parent) = parent else { return; }; + // The box to place: the node's own, or the one handed in - an asset's + // export. + let mut own = Box4 { + x: view.number("x").unwrap_or(0.0), + y: view.number("y").unwrap_or(0.0), + w: view.number("width").unwrap_or(0.0), + h: view.number("height").unwrap_or(0.0), + }; + // A group's children carry their `x` and `y` in the group's parent's + // space, not the group's: the join-us panel's outermost circle, which is + // exactly the group, reads `-277,-187` - the group's own place in the + // card - where the group's own space would say `0,0`. Placed as read, + // every circle sat 277px left and 187px high of where Figma draws it. + // The absolute boxes settle it without depending on whose space a + // number is in; an asset's export already went through them and landed + // right. + if parent.typed_view().node_type() == "GROUP" { + match (layout_box(node), layout_box(parent)) { + (Some(child), Some(group)) => { + own.x = child.x - group.x; + own.y = child.y - group.y; + } + _ => { + let group = parent.typed_view(); + own.x -= group.number("x").unwrap_or(0.0); + own.y -= group.number("y").unwrap_or(0.0); + } + } + } + let placed = placed_by.unwrap_or(own); let parent = parent.typed_view(); - let constraints = view.value("constraints").and_then(Value::as_object); + // A group has no constraints of its own; the plugin's `getPositionProps` + // reads its first child's, and so does this. The report section's + // illustration is a group pinned to the bottom of its frame through its + // children, `bottom="-284.8px"`, where reading the group alone put it + // at `top`. + let constraints = view + .value("constraints") + .and_then(Value::as_object) + .or_else(|| { + view.child_ids() + .next() + .and_then(|child| snapshot.nodes.get(child))? + .typed_view() + .value("constraints") + .and_then(Value::as_object) + }); let horizontal = constraints .and_then(|value| value.get("horizontal")) .and_then(Value::as_str) @@ -467,13 +1426,12 @@ fn push_absolute(node: &RawNode, parent: Option<&RawNode>, props: &mut Vec .and_then(|value| value.get("vertical")) .and_then(Value::as_str) .unwrap_or("MIN"); - let x = view.number("x").unwrap_or(0.0); - let y = view.number("y").unwrap_or(0.0); + let (x, y) = (placed.x, placed.y); match horizontal { "MAX" => string_prop( props, "right", - px(parent.number("width").unwrap_or(0.0) - x - view.number("width").unwrap_or(0.0)), + px(parent.number("width").unwrap_or(0.0) - x - placed.w), ), "CENTER" => { string_prop(props, "left", "50%"); @@ -485,7 +1443,7 @@ fn push_absolute(node: &RawNode, parent: Option<&RawNode>, props: &mut Vec "MAX" => string_prop( props, "bottom", - px(parent.number("height").unwrap_or(0.0) - y - view.number("height").unwrap_or(0.0)), + px(parent.number("height").unwrap_or(0.0) - y - placed.h), ), "CENTER" => { string_prop(props, "top", "50%"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/mod.rs b/crates/devup-mcp-devup-ui/src/codegen/mod.rs index 60336a5e..28f53dff 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/mod.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/mod.rs @@ -1,6 +1,8 @@ +mod animation; mod compat; mod component; mod layout; +pub mod responsive; mod style; mod text; mod variant; @@ -15,3 +17,7 @@ pub use component::{ generate_inlined_component_instance, generate_legacy_component, generate_node, normalize_component_name, render_component_registration_snapshot, render_component_source, }; +pub(crate) use layout::{derived_padding, placed_by_a_free_layout}; +pub(crate) use style::asset_kind; +pub use style::{asset_path, image_fill_path}; +pub(crate) use text::escape_jsx_text; diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs new file mode 100644 index 00000000..604e2707 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs @@ -0,0 +1,1131 @@ +//! Lining up the same screen drawn at several widths. +//! +//! A responsive screen is three sibling frames in a Section, named for the +//! width they are, and the conversion wants them as one tree whose differing +//! values became arrays. That is only possible where the trees agree in shape, +//! and this module is the part that finds out: it pairs the roots up by name, +//! walks them together, and names every place they part company. +//! +//! Shape divergence is not the interesting case — it is the cost of one. Widths +//! of the same screen are meant to be the same tree three times, so a place +//! where they are not is usually a slip in the file, and the export can only +//! carry it by keeping both copies and showing each at its own widths. Saying +//! where that happened is the point of reporting it: silently keeping both +//! looks like success and hides the thing worth fixing. +//! +//! Keeping both is not a second code path, though, which is the thing reading +//! the output does not tell you. A width that does not draw a node is handed a +//! hidden copy of one that does, and the copies go through the ordinary merge; +//! the `display` array falls out of it like any other prop. `divergences` is +//! therefore a report, not a switch. +//! +//! The rules here are ported from `devup-figma-plugin` at the commit this +//! repo's corpus pins — `src/codegen/responsive/` — and `docs/ +//! responsive-merge-rules.md` says which function each came from and where +//! this repo deliberately differs. + +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_figma::{DevupError, RawNode, Snapshot}; + +use super::{ + component::CodegenOptions, + variant::{Tree, project_tree_keeping_instances, structure_signature}, +}; + +/// The widths a screen may be drawn at, narrowest first — the order devup-ui's +/// responsive arrays are written in. +pub const BREAKPOINT_NAMES: [&str; 3] = ["mobile", "tablet", "desktop"]; + +/// How many slots a devup-ui responsive array has: `[mobile, sm, tablet, lg, +/// PC]`. +pub const SLOTS: usize = 5; + +/// The width each slot runs up to, from `devup-figma-plugin`'s `BREAKPOINTS`. +/// The last slot has no bound. +const SLOT_BOUNDS: [u32; SLOTS - 1] = [480, 768, 992, 1280]; + +/// Which slot a frame of this width occupies. +/// +/// A width is placed by how wide it is, not by what its frame is called. The +/// same three names — `mobile` / `tablet` / `desktop` — land on slots 0/2/4 in +/// one screen and 0/1/4 in another, so reading the name and assuming a slot +/// puts every value of the second screen a band too wide. +pub fn slot_of_width(width: u32) -> usize { + SLOT_BOUNDS + .iter() + .position(|bound| width <= *bound) + .unwrap_or(SLOTS - 1) +} + +/// The props a disappearing value must be cleared for, from the plugin's +/// `SPECIAL_PROPS_WITH_INITIAL`. Layout, spacing and position only: a colour +/// that stops being set is left to inherit rather than reset. +const CLEARED_WHEN_DROPPED: [&str; 41] = [ + "display", + "position", + "pos", + "transform", + "w", + "h", + "textAlign", + "flexDir", + "flexWrap", + "justify", + "alignItems", + "alignContent", + "alignSelf", + "gap", + "rowGap", + "columnGap", + "flex", + "flexGrow", + "flexShrink", + "flexBasis", + "order", + "gridTemplateColumns", + "gridTemplateRows", + "gridColumn", + "gridRow", + "gridArea", + "top", + "right", + "bottom", + "left", + "zIndex", + "overflow", + "overflowX", + "overflowY", + "p", + "pt", + "pr", + "pb", + "pl", + "px", + "py", +]; + +/// The margin props, kept apart only because the array above is already at the +/// length rustfmt likes to fight over. +const CLEARED_WHEN_DROPPED_MARGINS: [&str; 7] = ["m", "mt", "mr", "mb", "ml", "mx", "my"]; + +/// A value that need not be written because it is what the prop already is, +/// from the plugin's `DEFAULT_PROPS_MAP`. Its padding and margin entries are +/// commented out upstream, so they are absent here too. +fn is_default(prop: &str, value: &str) -> bool { + match prop { + "alignItems" | "justifyContent" => value == "flex-start", + "flexDir" => value == "row", + "gap" => value == "0" || value == "0px", + "textDecorationSkipInk" + | "textDecorationThickness" + | "textDecorationColor" + | "textUnderlineOffset" => value == "auto", + "textDecorationStyle" => value == "solid", + _ => false, + } +} + +fn cleared_when_dropped(prop: &str) -> bool { + CLEARED_WHEN_DROPPED.contains(&prop) || CLEARED_WHEN_DROPPED_MARGINS.contains(&prop) +} + +/// What one width has to say about one prop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Drawn<'a> { + /// No width is drawn at this slot at all. + Absent, + /// A width is drawn here and leaves the prop unset. + Unset, + /// A width is drawn here and sets the prop. + Set(&'a str), +} + +/// One prop, after the widths have been compared. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Merged { + /// Every width that is drawn says the same thing, so no array is needed. + Same(Option), + /// The widths disagree, and these are the slots to write. + Array(Vec>), +} + +/// Lay one prop's values across the widths as a devup-ui responsive array. +/// +/// A slot is written only when it changes what is in effect, because `null` +/// does not mean "no value" — it means "whatever the slot before it said". The +/// same rule is why a prop cannot simply stop: dropping it would leave the +/// narrower width's value inherited, so a layout prop that a wider width no +/// longer sets is written `"initial"`. Without it `pl={["36.5px"]}` would +/// silently keep the mobile padding at every width. +/// +/// This follows `devup-figma-plugin`'s `mergePropsToResponsive` and +/// `optimizeResponsiveValue`, including two limits worth knowing. Only the +/// props in [`CLEARED_WHEN_DROPPED`] are cleared, so a `bg` that stops being +/// set still inherits. And exactly one `"initial"` is placed, at the first +/// width that exists after the last value, which is why a prop that is set, +/// dropped, then set again wider cannot be expressed. +pub fn merge_slots(prop: &str, widths: &[Drawn<'_>; SLOTS]) -> Merged { + // One width is a screen, not a screen that changes. The reference hands + // its props back untouched rather than wrapping each in a one-slot array. + let mut drawn = widths.iter().filter(|width| **width != Drawn::Absent); + if let Some(only) = drawn.next() + && drawn.next().is_none() + { + return Merged::Same(match only { + Drawn::Set(value) => Some((*value).to_owned()), + Drawn::Absent | Drawn::Unset => None, + }); + } + + let mut slots: Vec> = widths + .iter() + .map(|width| match width { + Drawn::Set(value) => Some((*value).to_owned()), + Drawn::Absent | Drawn::Unset => None, + }) + .collect(); + + if cleared_when_dropped(prop) + && let Some(last) = slots.iter().rposition(Option::is_some) + && last < SLOTS - 1 + // The clear has to land on a width that is actually drawn; putting it + // on a slot no frame occupies would say nothing. + && let Some(clear_at) = (last + 1..SLOTS).find(|slot| widths[*slot] != Drawn::Absent) + { + slots[clear_at] = Some("initial".to_owned()); + slots.truncate(clear_at + 1); + } + + // A slot that repeats what is already in effect says nothing. + let mut carried: Option = None; + for slot in &mut slots { + let Some(value) = slot.clone() else { + continue; + }; + if carried.as_deref() == Some(value.as_str()) { + *slot = None; + } else { + carried = Some(value); + } + } + if slots + .first() + .and_then(Option::as_deref) + .is_some_and(|value| is_default(prop, value)) + { + slots[0] = None; + } + while slots.last().is_some_and(Option::is_none) { + slots.pop(); + } + + match slots.len() { + 0 => Merged::Same(None), + 1 => Merged::Same(slots.swap_remove(0)), + _ => Merged::Array(slots), + } +} + +/// One width of a screen: which breakpoint it is, and the node it starts at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Breakpoint { + /// Index into [`BREAKPOINT_NAMES`]; narrowest is 0. + pub rank: usize, + pub node_id: String, +} + +/// A place where the widths stopped agreeing, and what to say about it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Divergence { + /// The node in the widest breakpoint that has no counterpart in shape. + pub node_id: String, + /// How to reach it from the root, so a reader can find the same place in + /// each width rather than only in the one being reported. + pub path: Vec, + pub reason: DivergenceReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DivergenceReason { + /// The node exists at one width and not another. + Missing, + /// Both exist and hold a different number of children. + ChildCount, + /// Both exist and are different kinds of node. + NodeType, +} + +impl DivergenceReason { + pub fn as_str(self) -> &'static str { + match self { + Self::Missing => "missing at another width", + Self::ChildCount => "a different number of children", + Self::NodeType => "a different kind of node", + } + } +} + +fn rank_of(name: &str) -> Option { + let name = name.trim().to_ascii_lowercase(); + BREAKPOINT_NAMES.iter().position(|known| *known == name) +} + +/// The breakpoint roots this snapshot carries, in the order the Section holds +/// them. +/// +/// The order is kept on purpose. Where a width does not draw a node, the +/// reference gives that width a hidden copy of the node from the *first* +/// width that does — first in the Section's own layer order, which is how the +/// plugin walks it — and every value of that copy, not only its `display`, +/// lands in the array. The about hero is drawn at tablet and desktop, and its +/// picture is `w={["770px", null, "778px", null, "770px"]}`: the mobile slot +/// says 770 because desktop comes first in that Section. Sorting the roots by +/// width put tablet's 778 there instead. +/// +/// Empty unless there are at least two: one width is a screen, not a screen +/// that changes, and there is nothing to line up. +pub fn breakpoints(snapshot: &Snapshot) -> Vec { + let mut found: Vec = Vec::new(); + for id in &snapshot.roots { + let Some(node) = snapshot.nodes.get(id) else { + continue; + }; + let Some(rank) = node.typed_view().name().and_then(rank_of) else { + continue; + }; + // Two frames with one name: the first keeps it, as in the plugin. + if found.iter().any(|breakpoint| breakpoint.rank == rank) { + continue; + } + found.push(Breakpoint { + rank, + node_id: id.clone(), + }); + } + if found.len() < 2 { + return Vec::new(); + } + found +} + +fn child_ids(snapshot: &Snapshot, node_id: &str) -> Vec { + snapshot + .nodes + .get(node_id) + .map(|node| { + node.typed_view() + .child_ids() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default() +} + +fn node_at<'a>(snapshot: &'a Snapshot, root: &str, path: &[usize]) -> Option<&'a RawNode> { + let mut current = root.to_owned(); + for step in path { + current = child_ids(snapshot, ¤t).into_iter().nth(*step)?; + } + snapshot.nodes.get(¤t) +} + +/// Every place the widths stop agreeing in shape, in the order a reader meets +/// them. An empty result means the trees line up and their differing values can +/// become arrays. +pub fn divergences(snapshot: &Snapshot, breakpoints: &[Breakpoint]) -> Vec { + let Some(widest) = breakpoints.iter().max_by_key(|breakpoint| breakpoint.rank) else { + return Vec::new(); + }; + let mut found = Vec::new(); + walk(snapshot, breakpoints, widest, &mut Vec::new(), &mut found); + found +} + +fn walk( + snapshot: &Snapshot, + breakpoints: &[Breakpoint], + widest: &Breakpoint, + path: &mut Vec, + found: &mut Vec, +) { + let Some(reference) = node_at(snapshot, &widest.node_id, path) else { + return; + }; + let reference_children = child_ids(snapshot, &reference.id).len(); + + for breakpoint in breakpoints { + if breakpoint.rank == widest.rank { + continue; + } + let reason = match node_at(snapshot, &breakpoint.node_id, path) { + None => Some(DivergenceReason::Missing), + Some(other) if other.node_type != reference.node_type => { + Some(DivergenceReason::NodeType) + } + Some(other) if child_ids(snapshot, &other.id).len() != reference_children => { + Some(DivergenceReason::ChildCount) + } + Some(_) => None, + }; + if let Some(reason) = reason { + found.push(Divergence { + node_id: reference.id.clone(), + path: path.clone(), + reason, + }); + // Below a shape that already parted company there is nothing to + // compare: every descendant would be reported for the same reason, + // burying the one place worth looking at. + return; + } + } + + // An instance is not descended into. A component drawn for several widths + // carries its own variant for each — a header is `transparent` on desktop + // and `mobileTranspa` on mobile — so its insides differ by design, and the + // reference keeps one `
` rather than merging what is behind it. + // Walking in here reported six shape differences that are the component + // doing its job. + if reference.node_type == "INSTANCE" { + return; + } + + for index in 0..reference_children { + path.push(index); + walk(snapshot, breakpoints, widest, path, found); + path.pop(); + } +} + +// --------------------------------------------------------------------------- +// Joining the widths into one tree +// --------------------------------------------------------------------------- + +/// One node's counterparts, by the slot each is drawn at. +type BySlot = [Option; SLOTS]; + +/// Where each slot's width comes in the Section's layer order; `usize::MAX` +/// for a slot no width occupies. The lowest goes first, as the plugin's +/// `firstMapValue` does: it is the width whose values fill in for a width that +/// does not draw a node, and whose element name a merged node keeps. +type Precedence = [usize; SLOTS]; + +/// The slot that comes first in the Section's order among those present. +fn first_slot(by_slot: &BySlot, precedence: &Precedence) -> Option { + (0..SLOTS) + .filter(|slot| by_slot[*slot].is_some()) + .min_by_key(|slot| precedence[*slot]) +} + +/// A node's children, grouped under the key its counterparts will be found by. +/// +/// A child whose shape is unique among its siblings is keyed by that shape, so +/// renaming it in one width does not lose it. Where several siblings share a +/// shape the shape cannot tell them apart, so their name is used instead and +/// they are paired in order. Insertion order is kept, because it is what the +/// ordering below reads. +fn children_to_map(tree: &Tree) -> Vec<(String, Vec<&Tree>)> { + let mut counts: BTreeMap = BTreeMap::new(); + for child in &tree.children { + *counts.entry(structure_signature(child)).or_default() += 1; + } + let mut grouped: Vec<(String, Vec<&Tree>)> = Vec::new(); + for child in &tree.children { + let signature = structure_signature(child); + let key = if counts.get(&signature) == Some(&1) { + format!("sig:{signature}") + } else { + child.node_name.clone() + }; + if let Some((_, bucket)) = grouped.iter_mut().find(|(existing, _)| *existing == key) { + bucket.push(child); + } else { + grouped.push((key, vec![child])); + } + } + grouped +} + +/// One order for children that every width agrees with. +/// +/// Each width gives an order over the children it has, and no width need have +/// them all. Taking any single width's order would drop the others' children; +/// concatenating would scramble them. So the orders are read as edges of a +/// graph and sorted topologically, with a child's average position across the +/// widths breaking ties. This is `mergeChildNameOrder`. +fn merge_child_order(per_width: &[Vec]) -> Vec { + let mut names: Vec = Vec::new(); + for order in per_width { + for name in order { + if !names.contains(name) { + names.push(name.clone()); + } + } + } + if names.len() < 2 { + return names; + } + + let mut edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + let mut incoming: BTreeMap<&str, usize> = names.iter().map(|name| (name.as_str(), 0)).collect(); + for order in per_width { + for pair in order.windows(2) { + if edges + .entry(pair[0].as_str()) + .or_default() + .insert(pair[1].as_str()) + { + *incoming.entry(pair[1].as_str()).or_default() += 1; + } + } + } + + let position = |name: &str| { + let mut total = 0.0; + let mut seen = 0.0; + for order in per_width { + if let Some(index) = order.iter().position(|other| other == name) { + total += if order.len() > 1 { + index as f64 / (order.len() - 1) as f64 + } else { + 0.5 + }; + seen += 1.0; + } + } + if seen > 0.0 { total / seen } else { 0.5 } + }; + + let mut ready = names + .iter() + .filter(|name| incoming.get(name.as_str()) == Some(&0)) + .map(String::as_str) + .collect::>(); + let mut sorted: Vec = Vec::new(); + while !ready.is_empty() { + ready.sort_by(|left, right| { + position(left) + .partial_cmp(&position(right)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let next = ready.remove(0); + sorted.push(next.to_owned()); + for neighbour in edges.get(next).cloned().unwrap_or_default() { + let degree = incoming.entry(neighbour).or_insert(1); + *degree = degree.saturating_sub(1); + if *degree == 0 { + ready.push(neighbour); + } + } + } + // A cycle means the widths order two children oppositely. Nothing is + // dropped on that account; the rest keep the order they were found in. + for name in &names { + if !sorted.contains(name) { + sorted.push(name.clone()); + } + } + sorted +} + +/// Render one merged prop as the attribute text a JSX element carries. +/// +/// An array is written one slot to a line, as the plugin's `JSON.stringify` +/// with an indent of two does; `render_merged` indents the continuation +/// lines to the attribute. That is also what makes the element itself +/// multi-line, whatever its prop count. +fn render_attribute(merged: &Merged) -> Option { + match merged { + Merged::Same(None) => None, + Merged::Same(Some(value)) => Some(format!("=\"{value}\"")), + Merged::Array(slots) => { + let written = slots + .iter() + .map(|slot| { + slot.as_ref() + .map_or_else(|| " null".to_owned(), |value| format!(" \"{value}\"")) + }) + .collect::>() + .join(",\n"); + Some(format!("={{[\n{written}\n]}}")) + } + } +} + +/// The `display` a devup-ui element already carries by being itself. +fn implied_display(component: &str) -> Option<&'static str> { + match component { + "Flex" | "VStack" | "Center" => Some("flex"), + "Grid" => Some("grid"), + _ => None, + } +} + +/// What `display` an element goes back to when a wider width stops hiding it. +/// +/// `merge_slots` clears a dropped layout prop to `"initial"`, which is right +/// for the other forty and wrong for this one. `initial` is the value the CSS +/// specification gives a property, not the value the element has: for `w` that +/// is `auto` and for `p` it is `0`, which are what "unset" should mean, but for +/// `display` it is `inline` whatever the element is. An element's own display +/// comes from the user-agent stylesheet instead, so a `Box` shown only from +/// tablet up would come back inline rather than block. +/// +/// `revert` is the keyword that would mean what is wanted, and it cannot be +/// used either: it rolls the cascade back past the author origin, which is +/// where devup-ui puts `VStack`'s own `display: flex`. So the value is written +/// out, and `display` is never cleared to `initial`. +fn natural_display(component: &str) -> &'static str { + match component { + "Flex" | "VStack" | "Center" => "flex", + "Grid" => "grid", + // `Image` is an `img`, which is inline. Everything else devup-ui draws + // is a `div`, and `Text` is a `p`. + "Image" => "inline", + _ => "block", + } +} + +/// Put back the `display` the element name stands for, before any merging. +/// +/// The plugin keeps `display` in a node's props and picks the element from it; +/// this projection picks the element first and drops the value. That is the +/// same thing for one width, but not across several: a width where the node is +/// hidden would leave the others with nothing to go back to, and `merge_slots` +/// would clear them to `"initial"` — which for `display` is `inline`, not +/// `flex`. The node would stop being a flex container at every width it is +/// shown. Rendering drops the value again where the element implies it, so +/// nothing is written that was not written before. +fn restore_implied_display(tree: &mut Tree) { + if let Some(display) = implied_display(&tree.component) { + tree.props + .entry("display".to_owned()) + .or_insert_with(|| display.to_owned()); + } + for child in &mut tree.children { + restore_implied_display(child); + } +} + +/// Fold one node's counterparts into a single node whose differing values have +/// become arrays. +fn merge_trees( + by_slot: &BySlot, + precedence: &Precedence, + notes: &mut Vec, +) -> Option { + let first = by_slot[first_slot(by_slot, precedence)?].clone()?; + + let mut keys = BTreeSet::new(); + for tree in by_slot.iter().flatten() { + keys.extend(tree.props.keys().cloned()); + } + let mut props = BTreeMap::new(); + for key in keys { + // A component's own props are not style props. devup-ui reads an array + // only where it applies CSS, so `property1={[...]}` on `
` is + // read as the literal array and the component sees nonsense. The widest + // width drawn wins and the rest are reported, because a screen that + // really does want a different variant per width is asking for + // something the target cannot express. + if first.is_component && !is_placement_prop(&key) { + let mut seen = Vec::new(); + for tree in by_slot.iter().flatten() { + if let Some(value) = tree.props.get(&key) { + seen.push(value.clone()); + } + } + if let Some(widest) = seen.last().cloned() { + if seen.iter().any(|value| *value != widest) { + notes.push(Unrepresented { + node_id: first.node_id.clone(), + detail: format!( + "{} takes {key} {} at different widths; a component prop cannot be responsive, so {widest} is used.", + first.component, + seen.join(" / ") + ), + }); + } + props.insert(key, format!("=\"{widest}\"")); + } + continue; + } + let widths: [Drawn<'_>; SLOTS] = std::array::from_fn(|slot| match &by_slot[slot] { + None => Drawn::Absent, + Some(tree) => tree + .props + .get(&key) + .map_or(Drawn::Unset, |value| Drawn::Set(value)), + }); + let mut merged = merge_slots(&key, &widths); + if key == "display" + && let Merged::Array(slots) = &mut merged + { + // A component reference cannot carry `display`; it lands on the + // `Box` that will be wrapped around it below. + let natural = if first.is_component { + "block" + } else { + natural_display(&first.component) + }; + for slot in &mut *slots { + if slot.as_deref() == Some("initial") { + *slot = Some(natural.to_owned()); + } + } + } + // Every width agreeing on the `display` the element already has is the + // state this started in, so it goes back to being unwritten. + if key == "display" + && matches!(&merged, Merged::Same(Some(value)) + if implied_display(&first.component) == Some(value.as_str())) + { + continue; + } + if let Some(attribute) = render_attribute(&merged) { + props.insert(key, attribute); + } + } + + let mut source_node_ids = BTreeSet::new(); + for tree in by_slot.iter().flatten() { + source_node_ids.extend(tree.source_node_ids.iter().cloned()); + } + + let content = merge_content(by_slot, &first, notes); + + // A component reference has nothing below it to line up: each width picks + // its own variant, and the component answers for its own widths. + if first.is_component { + // Where it sits belongs to a wrapper; what variant it is belongs to the + // reference. Only the first is merged — passing an array to a component + // prop does nothing, so a variant that differs by width cannot be + // expressed and the widest one drawn is kept. + let (placement, variants): (BTreeMap<_, _>, BTreeMap<_, _>) = props + .into_iter() + .partition(|(name, _)| is_placement_prop(name)); + let reference = Tree { + props: variants, + children: Vec::new(), + source_node_ids: source_node_ids.clone(), + ..first.clone() + }; + if placement.is_empty() { + return Some(reference); + } + return Some(Tree { + component: "Box".to_owned(), + props: placement, + children: vec![reference], + source_node_ids, + is_component: false, + content: None, + ..first + }); + } + + Some(Tree { + props, + children: merge_children(by_slot, precedence, notes), + source_node_ids, + content, + ..first + }) +} + +/// What a text says across the widths. +/// +/// Words are not a style prop and have no array to go into, so the widths +/// have to agree — with one allowance. A designer breaks a line at one width +/// and not another, and the same words then differ only by where `
` +/// falls; a break some widths have and others do not is written as +/// `` shown at the widths that have it. This is what the +/// plugin's `buildResponsiveTextChildren` sets out to do and does not: it +/// lines the texts up by character index, which the very break it is looking +/// for shifts, and it works on the rendered JSX of the widths, whose own +/// newlines it then rewrites as breaks. +/// +/// Words that differ by more than their breaks keep the first width's, and +/// the difference is reported rather than silently overruled. +fn merge_content( + by_slot: &BySlot, + first: &Tree, + notes: &mut Vec, +) -> Option { + let first_content = first.content.as_ref()?; + let drawn = by_slot + .iter() + .enumerate() + .filter_map(|(slot, tree)| { + tree.as_ref() + .and_then(|tree| tree.content.as_deref()) + .map(|content| (slot, content)) + }) + .collect::>(); + if drawn.iter().all(|(_, content)| *content == first_content) { + return Some(first_content.clone()); + } + + const BREAK: &str = "
"; + // The words with their breaks taken out, and where each break was: the + // offset, in characters of the words, that it sat before. + fn without_breaks(content: &str) -> (String, Vec) { + let mut words = String::with_capacity(content.len()); + let mut breaks = Vec::new(); + let mut rest = content; + let mut offset = 0; + while !rest.is_empty() { + if let Some(after) = rest.strip_prefix(BREAK) { + breaks.push(offset); + rest = after; + continue; + } + let character = rest.chars().next().expect("non-empty"); + words.push(character); + offset += 1; + rest = &rest[character.len_utf8()..]; + } + (words, breaks) + } + let stripped = drawn + .iter() + .map(|(slot, content)| (*slot, without_breaks(content))) + .collect::>(); + let (words, _) = &stripped[0].1; + if stripped.iter().any(|(_, (other, _))| other != words) { + notes.push(Unrepresented { + node_id: first.node_id.clone(), + detail: format!( + "{} reads differently at different widths; the first width's text is used.", + first.component + ), + }); + return Some(first_content.clone()); + } + + let breaks_at = |slot: usize, offset: usize| { + stripped + .iter() + .find(|(other, _)| *other == slot) + .map_or(0, |(_, (_, breaks))| { + breaks.iter().filter(|at| **at == offset).count() + }) + }; + let characters = words.chars().collect::>(); + let mut merged = String::with_capacity(first_content.len()); + for offset in 0..=characters.len() { + let most = stripped + .iter() + .map(|(slot, _)| breaks_at(*slot, offset)) + .max() + .unwrap_or_default(); + for nth in 1..=most { + let shown: BySlot = std::array::from_fn(|slot| { + by_slot[slot] + .as_ref() + .map(|_| breaks_at(slot, offset) >= nth) + }); + if shown.iter().flatten().all(|shown| *shown) { + merged.push_str(BREAK); + continue; + } + // Shown where the width breaks, `none` where it does not; a slot + // repeating the one in effect says nothing, and the first slot + // shown needs no value at all — a break is shown by default. + let mut slots: Vec> = shown + .iter() + .map(|shown| shown.map(|shown| if shown { "initial" } else { "none" }.to_owned())) + .collect(); + let mut carried: Option = None; + for slot in &mut slots { + let Some(value) = slot.clone() else { + continue; + }; + if carried.as_deref() == Some(value.as_str()) { + *slot = None; + } else { + carried = Some(value); + } + } + if slots.first().and_then(Option::as_deref) == Some("initial") { + slots[0] = None; + } + while slots.last().is_some_and(Option::is_none) { + slots.pop(); + } + let written = slots + .iter() + .map(|slot| { + slot.as_ref() + .map_or_else(|| "null".to_owned(), |value| format!("\"{value}\"")) + }) + .collect::>() + .join(", "); + merged.push_str(&format!("")); + } + if let Some(character) = characters.get(offset) { + merged.push(*character); + } + } + Some(merged) +} + +fn is_placement_prop(name: &str) -> bool { + super::variant::POSITION_PROPS.contains(&name) || name == "w" || name == "display" +} + +fn merge_children( + by_slot: &BySlot, + precedence: &Precedence, + notes: &mut Vec, +) -> Vec { + let maps: BySlot)>> = + std::array::from_fn(|slot| by_slot[slot].as_ref().map(children_to_map)); + let orders = maps + .iter() + .flatten() + .map(|map| map.iter().map(|(key, _)| key.clone()).collect::>()) + .collect::>(); + + let mut merged = Vec::new(); + for key in merge_child_order(&orders) { + let bucket = |slot: usize| { + maps[slot] + .as_ref() + .and_then(|map| map.iter().find(|(name, _)| *name == key)) + .map(|(_, children)| children) + }; + let count = (0..SLOTS) + .filter_map(|slot| bucket(slot).map(Vec::len)) + .max() + .unwrap_or_default(); + + for index in 0..count { + let mut children: BySlot = std::array::from_fn(|slot| { + bucket(slot).and_then(|list| list.get(index).cloned().cloned()) + }); + // A width that does not draw this child is given a copy of the + // first one that does — first in the Section's order — hidden. The + // copies then merge like anything else: the `display` array falls + // out of the ordinary prop merge rather than from a branch of its + // own, and so do the copy's other values, which is why the choice + // of width to copy shows in the output. + if let Some(shown) = + first_slot(&children, precedence).and_then(|slot| children[slot].clone()) + { + for slot in 0..SLOTS { + if by_slot[slot].is_some() && children[slot].is_none() { + let mut hidden = shown.clone(); + hidden.props.insert("display".to_owned(), "none".to_owned()); + children[slot] = Some(hidden); + } + } + } + merged.extend(merge_trees(&children, precedence, notes)); + } + } + merged +} + +/// Write a merged tree as JSX, in the house style: props sorted, spread over +/// lines once there are five, and childless elements closed on themselves. +fn render_merged(tree: &Tree, depth: usize) -> String { + let indent = " ".repeat(depth); + let attributes = tree + .props + .iter() + .map(|(name, attribute)| format!("{name}{attribute}")) + .collect::>(); + // Five props, or any array, and the props go one to a line — the + // plugin's `propsToString` separator rule. + let multiline = + attributes.len() >= 5 || attributes.iter().any(|attribute| attribute.contains('\n')); + let opening = if attributes.is_empty() { + String::new() + } else if multiline { + let prefix = " ".repeat(depth + 1); + let padded = attributes + .iter() + .map(|attribute| attribute.replace('\n', &format!("\n{prefix}"))) + .collect::>(); + format!("\n{prefix}{}", padded.join(&format!("\n{prefix}"))) + } else { + format!(" {}", attributes.join(" ")) + }; + + let mut children = tree + .children + .iter() + .map(|child| render_merged(child, depth + 1)) + .collect::>(); + if let Some(content) = &tree.content { + let prefix = " ".repeat(depth + 1); + children.push( + content + .lines() + .map(|line| format!("{prefix}{line}")) + .collect::>() + .join("\n"), + ); + } + + // A shape spelled out in place of the component it came from still says + // which component that was, or a reader has nothing to go on when the + // change belongs upstream. + let comment = match &tree.leading_comment { + Some(comment) => format!("{indent}{{/* {comment} */}}\n"), + None => String::new(), + }; + let component = &tree.component; + if children.is_empty() { + if multiline { + format!("{comment}{indent}<{component}{opening}\n{indent}/>") + } else { + format!("{comment}{indent}<{component}{opening} />") + } + } else { + let close_open = if multiline { + format!("\n{indent}>") + } else { + ">".to_owned() + }; + format!( + "{comment}{indent}<{component}{opening}{close_open}\n{}\n{indent}", + children.join("\n") + ) + } +} + +/// Something the widths asked for that a single tree cannot say. +/// +/// Keeping these is the point of the exercise: an export that quietly picks one +/// width and moves on looks like a success and hides the thing worth fixing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Unrepresented { + pub node_id: String, + pub detail: String, +} + +/// A screen's widths, folded into one tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergedScreen { + /// The merged JSX, indented for a component body. + pub tsx: String, + /// Every element name the JSX mentions, primitives and components alike. + pub components: BTreeSet, + /// The slot each width was placed in, narrowest first. + pub slots: Vec, + /// What could not be carried across, and why. + pub unrepresented: Vec, +} + +/// Everything `project_tree` can name. Anything else the merged tree mentions +/// is a component of the design system and is imported from `@/components`. +const DEVUP_PRIMITIVES: [&str; 7] = ["Box", "Center", "Flex", "Grid", "Image", "Text", "VStack"]; + +impl MergedScreen { + /// The elements that come from devup-ui itself. + pub fn primitives(&self) -> Vec<&str> { + self.components + .iter() + .map(String::as_str) + .filter(|name| DEVUP_PRIMITIVES.contains(name)) + .collect() + } + + /// The elements that are components of this design, one file each. + pub fn referenced_components(&self) -> Vec<&str> { + self.components + .iter() + .map(String::as_str) + .filter(|name| !DEVUP_PRIMITIVES.contains(name)) + .collect() + } + + /// The whole file: what it imports, and the screen as a default export. + pub fn module(&self, component_name: &str) -> String { + let mut lines = Vec::new(); + let primitives = self.primitives(); + if !primitives.is_empty() { + lines.push(format!( + "import {{ {} }} from '@devup-ui/react'", + primitives.join(", ") + )); + } + lines.extend( + self.referenced_components() + .iter() + .map(|name| format!("import {{ {name} }} from '@/components/{name}'")), + ); + let imports = if lines.is_empty() { + String::new() + } else { + format!("{}\n\n", lines.join("\n")) + }; + format!( + "{imports}export default function {component_name}() {{\n return (\n{}\n )\n}}\n", + self.tsx + ) + } +} + +fn collect_components(tree: &Tree, into: &mut BTreeSet) { + into.insert(tree.component.clone()); + for child in &tree.children { + collect_components(child, into); + } +} + +/// Fold a screen drawn at several widths into one tree. +/// +/// `None` when the snapshot holds fewer than two widths — that is a screen, not +/// a screen that changes, and it has an ordinary single-width conversion. +pub fn merge_breakpoints( + snapshot: &Snapshot, + options: &CodegenOptions, +) -> Result, DevupError> { + let found = breakpoints(snapshot); + if found.len() < 2 { + return Ok(None); + } + let mut by_slot: BySlot = std::array::from_fn(|_| None); + let mut precedence: Precedence = [usize::MAX; SLOTS]; + let mut slots = Vec::new(); + for (position, breakpoint) in found.iter().enumerate() { + let Some(node) = snapshot.nodes.get(&breakpoint.node_id) else { + continue; + }; + let width = node.typed_view().number("width").unwrap_or_default(); + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a frame is a small positive number of pixels wide" + )] + let slot = slot_of_width(width.max(0.0) as u32); + slots.push(slot); + // Two frames in one band would overwrite each other. The first keeps + // the slot; reporting the clash is left to the caller, which knows the + // names, rather than silently dropping a width here. + if by_slot[slot].is_none() { + let mut tree = project_tree_keeping_instances(snapshot, node, options, true)?; + restore_implied_display(&mut tree); + by_slot[slot] = Some(tree); + precedence[slot] = position; + } + } + slots.sort_unstable(); + let mut unrepresented = Vec::new(); + Ok( + merge_trees(&by_slot, &precedence, &mut unrepresented).map(|tree| { + let mut components = BTreeSet::new(); + collect_components(&tree, &mut components); + MergedScreen { + tsx: render_merged(&tree, 2), + components, + slots, + unrepresented, + } + }), + ) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index d78ee116..57ae5d0c 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -9,205 +9,421 @@ use super::{ }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum AssetKind { +pub(crate) enum AssetKind { Svg, SvgMask, Png, } -pub(super) fn asset_kind(snapshot: &Snapshot, node: &RawNode) -> Option { +pub(crate) fn asset_kind(snapshot: &Snapshot, node: &RawNode) -> Option { + asset_kind_nested(snapshot, node, false) +} + +fn asset_kind_nested(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Option { let view = node.typed_view(); - if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") { + if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") + || view + .value("inferredAutoLayout") + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str) + == Some("GRID") + { return None; } - if view - .value("inferredAutoLayout") - .and_then(Value::as_object) - .and_then(|layout| layout.get("layoutMode")) - .and_then(Value::as_str) - == Some("GRID") + + if has_smart_animate_reaction(node) + || view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .is_some_and(has_smart_animate_reaction) { return None; } - if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") - || (view.node_type() == "ELLIPSE" - && view - .value("arcData") - .and_then(|value| value.get("innerRadius")) - .and_then(Value::as_f64) - .is_some_and(|value| value != 0.0)) + + if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") { + return Some(svg_asset_kind(snapshot, node, nested)); + } + + if view.node_type() == "ELLIPSE" + && view + .value("arcData") + .and_then(|arc_data| arc_data.get("innerRadius")) + .and_then(Value::as_f64) + .is_some_and(|inner_radius| inner_radius != 0.0) { - return Some(if uniform_asset_color(snapshot, node).is_some() { - AssetKind::SvgMask - } else { - AssetKind::Svg - }); + return Some(svg_asset_kind(snapshot, node, nested)); } - let fills = view.value("fills").and_then(Value::as_array); - if view.bool("isAsset") == Some(true) { - if fills.is_some_and(|fills| { - fills.len() == 1 - && fills[0].get("type").and_then(Value::as_str) == Some("IMAGE") - && fills[0].get("scaleMode").and_then(Value::as_str) != Some("TILE") - }) { - return Some(AssetKind::Png); + + let child_ids = view.child_ids().collect::>(); + if child_ids.is_empty() { + return leaf_asset_kind(snapshot, node, nested); + } + + if child_ids.len() == 1 { + if ["paddingLeft", "paddingRight", "paddingTop", "paddingBottom"] + .into_iter() + .any(|field| view.number(field).is_some_and(|padding| padding > 0.0)) + || fills(node).is_some_and(|fills| fills.iter().any(is_visible_fill)) + { + return None; } - if fills.is_some_and(|fills| { - !fills.is_empty() - && !fills.iter().all(|paint| { - paint.get("type").and_then(Value::as_str) == Some("SOLID") - && paint.get("visible").and_then(Value::as_bool) == Some(true) - }) - }) { - return Some(if uniform_asset_color(snapshot, node).is_some() { - AssetKind::SvgMask - } else { - AssetKind::Svg - }); + + return match snapshot + .nodes + .get(child_ids[0]) + .and_then(|child| asset_kind_nested(snapshot, child, true)) + { + Some(AssetKind::Png) => Some(AssetKind::Png), + Some(AssetKind::Svg | AssetKind::SvgMask) => { + Some(svg_asset_kind(snapshot, node, nested)) + } + None => None, + }; + } + + let mut visible_children = Vec::new(); + for child_id in child_ids { + let child = snapshot.nodes.get(child_id)?; + if child.typed_view().bool("visible") != Some(false) { + visible_children.push(child); } } - if view.child_ids().next().is_some() { - let children = view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .collect::>(); - let direct_vectors = children.iter().all(|child| { + + visible_children + .into_iter() + .all(|child| { matches!( - child.typed_view().node_type(), - "VECTOR" | "STAR" | "POLYGON" + asset_kind_nested(snapshot, child, true), + Some(AssetKind::Svg | AssetKind::SvgMask) ) - }); - if view.bool("isAsset") == Some(true) && first_solid_color(view.value("fills")).is_some() { - return None; + }) + .then(|| svg_asset_kind(snapshot, node, nested)) +} + +fn leaf_asset_kind(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Option { + let node_fills = fills(node); + if node_fills.is_some_and(|fills| { + fills.iter().any(|fill| { + is_visible_fill(fill) + && (fill_type(fill) == Some("PATTERN") + || (fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) == Some("TILE"))) + }) + }) { + return None; + } + + if node.typed_view().bool("isAsset") == Some(true) { + if node_fills.is_some_and(|fills| { + fills.iter().any(|fill| { + is_visible_fill(fill) + && fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") + }) + }) { + return (node_fills.is_some_and(|fills| fills.len() == 1)).then_some(AssetKind::Png); } - if children.len() == 1 - && !direct_vectors - && matches!( - view.string("layoutMode"), - Some("HORIZONTAL" | "VERTICAL" | "GRID") - ) - { - return None; + + if node_fills.is_none_or(|fills| { + fills + .iter() + .all(|fill| is_visible_fill(fill) && fill_type(fill) == Some("SOLID")) + }) { + return nested.then(|| svg_asset_kind(snapshot, node, nested)); } - if !children.is_empty() - && children.iter().all(|child| { - matches!( - asset_kind_nested(snapshot, child), - Some(AssetKind::Svg | AssetKind::SvgMask) - ) + + return Some(svg_asset_kind(snapshot, node, nested)); + } + + (nested + && node_fills.is_some_and(|fills| { + fills.iter().all(|fill| { + !is_visible_fill(fill) + || !matches!(fill_type(fill), Some("IMAGE" | "VIDEO" | "PATTERN")) }) - { - return Some(if uniform_asset_color(snapshot, node).is_some() { - AssetKind::SvgMask - } else { - AssetKind::Svg - }); - } + })) + .then(|| svg_asset_kind(snapshot, node, nested)) +} + +fn fills(node: &RawNode) -> Option<&Vec> { + node.typed_view().value("fills").and_then(Value::as_array) +} + +fn fill_type(fill: &Value) -> Option<&str> { + fill.get("type").and_then(Value::as_str) +} + +fn is_visible_fill(fill: &Value) -> bool { + fill.get("visible").and_then(Value::as_bool) != Some(false) +} + +fn has_smart_animate_reaction(node: &RawNode) -> bool { + node.typed_view() + .value("reactions") + .and_then(Value::as_array) + .is_some_and(|reactions| { + reactions.iter().any(|reaction| { + reaction + .get("actions") + .and_then(Value::as_array) + .is_some_and(|actions| { + actions.iter().any(|action| { + action.get("type").and_then(Value::as_str) == Some("NODE") + && action + .get("transition") + .and_then(|transition| transition.get("type")) + .and_then(Value::as_str) + == Some("SMART_ANIMATE") + }) + }) + }) + }) +} + +fn svg_asset_kind(snapshot: &Snapshot, node: &RawNode, nested: bool) -> AssetKind { + if matches!( + same_color(snapshot, node, nested, None), + SameColor::Color(_) + ) { + AssetKind::SvgMask + } else { + AssetKind::Svg } - None } -fn asset_kind_nested(snapshot: &Snapshot, node: &RawNode) -> Option { - if let Some(kind) = asset_kind(snapshot, node) { - return Some(kind); +/// What an asset is painted in, if it is one thing. +/// +/// This is the `sameColor` half of the plugin's `computeAssetAnalysis`, +/// which decides whether an icon is drawn as an `` or as a Box masked +/// to its shape and filled with one colour. `Null` is a subtree that settled +/// on nothing, `False` one whose paints disagree, and only a `Color` makes a +/// mask. The two non-answers are not the same: a `Null` child leaves a +/// running colour alone, a `False` one spoils it. +#[derive(Debug, Clone, PartialEq, Eq)] +enum SameColor { + Null, + False, + Color(String), +} + +/// The plugin's `mergeSameColor`. +fn merge_same_color(current: SameColor, next: SameColor) -> SameColor { + match (current, next) { + (_, SameColor::False) => SameColor::False, + (SameColor::Null, next) => next, + (current, next) if current == next => current, + _ => SameColor::False, } +} + +/// The plugin's `analyzeOwnSameColor`: the node's own fills and strokes. +enum OwnColor { + /// No visible paint at all. + None, + /// A paint that is not a flat colour. + Null, + /// Two flat colours that differ. + False, + Color(String), +} + +fn own_same_color( + node: &RawNode, + variable_tokens: Option<&std::collections::BTreeMap>, +) -> OwnColor { let view = node.typed_view(); - if view.node_type() == "TEXT" { - return None; + let mut target: Option = None; + let mut has_paints = false; + for field in ["fills", "strokes"] { + let Some(paints) = view.value(field).and_then(Value::as_array) else { + continue; + }; + for paint in paints { + if paint.get("visible").and_then(Value::as_bool) != Some(true) { + continue; + } + has_paints = true; + if paint.get("type").and_then(Value::as_str) != Some("SOLID") { + return OwnColor::Null; + } + let Some(color) = paint_string(paint, variable_tokens) else { + return OwnColor::Null; + }; + match &target { + None => target = Some(color), + Some(current) if *current != color => return OwnColor::False, + Some(_) => {} + } + } } - if view.child_ids().next().is_some() { - return None; + if !has_paints { + return OwnColor::None; } - let fills = view.value("fills").and_then(Value::as_array)?; - if fills.iter().any(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("type").and_then(Value::as_str) != Some("SOLID") - }) { - return None; - } - if fills.iter().any(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && matches!( - paint.get("type").and_then(Value::as_str), - Some("IMAGE" | "VIDEO" | "PATTERN") - ) - }) { - None - } else { - Some(if uniform_asset_color(snapshot, node).is_some() { - AssetKind::SvgMask - } else { - AssetKind::Svg - }) + match target { + Some(color) => OwnColor::Color(color), + None => OwnColor::Null, } } -fn uniform_asset_color(snapshot: &Snapshot, node: &RawNode) -> Option { - fn visit(snapshot: &Snapshot, node: &RawNode, colors: &mut Vec) -> bool { - let view = node.typed_view(); - for field in ["fills", "strokes"] { - if let Some(paints) = view.value(field).and_then(Value::as_array) { - for paint in paints { - if paint.get("visible").and_then(Value::as_bool) == Some(false) { - continue; - } - if paint.get("type").and_then(Value::as_str) != Some("SOLID") { - return false; - } - let Some(color) = paint.get("color").and_then(color_from) else { - return false; - }; - colors.push(color); +/// A solid paint as the plugin's `solidToString` spells it: the variable it +/// is bound to as `$token`, or else the colour. Two paints are the same +/// colour to the plugin only when these agree, so a paint bound to a +/// variable and a raw paint of the same hex are *not* the same. +/// +/// Without a token map the variable id stands in for its name; that keeps +/// the comparison right when only the shape is being decided and the names +/// are not to hand. +pub(super) fn paint_string( + paint: &Value, + variable_tokens: Option<&std::collections::BTreeMap>, +) -> Option { + if let Some(id) = paint + .get("boundVariables") + .and_then(|bound| bound.get("color")) + .and_then(|color| color.get("id")) + .and_then(Value::as_str) + { + match variable_tokens { + None => return Some(format!("${id}")), + Some(tokens) => { + if let Some(token) = tokens.get(id) { + return Some(format!("${token}")); } } } - view.child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .all(|child| visit(snapshot, child, colors)) } - - let mut colors = Vec::new(); - if !visit(snapshot, node, &mut colors) || colors.is_empty() { - return None; + if paint.get("opacity").and_then(Value::as_f64) == Some(0.0) { + return Some("transparent".to_owned()); } - let first = colors.first()?.clone(); - colors.iter().all(|color| color == &first).then_some(first) + color_from_paint(paint) } -fn uniform_asset_token( +fn same_color( snapshot: &Snapshot, node: &RawNode, - variable_tokens: &std::collections::BTreeMap, -) -> Option { - fn visit( - snapshot: &Snapshot, - node: &RawNode, - variable_tokens: &std::collections::BTreeMap, - tokens: &mut Vec, - ) -> bool { - let view = node.typed_view(); - if let Some(fills) = view.value("fills").and_then(Value::as_array) { - for paint in fills.iter().filter(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("type").and_then(Value::as_str) == Some("SOLID") + nested: bool, + variable_tokens: Option<&std::collections::BTreeMap>, +) -> SameColor { + let view = node.typed_view(); + let own = || match own_same_color(node, variable_tokens) { + OwnColor::Color(color) => SameColor::Color(color), + _ => SameColor::Null, + }; + if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") + || view + .value("inferredAutoLayout") + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str) + == Some("GRID") + || has_smart_animate_reaction(node) + || view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .is_some_and(has_smart_animate_reaction) + { + return SameColor::Null; + } + if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") { + return own(); + } + if view.node_type() == "ELLIPSE" + && view + .value("arcData") + .and_then(|arc_data| arc_data.get("innerRadius")) + .and_then(Value::as_f64) + .is_some_and(|inner_radius| inner_radius != 0.0) + { + return own(); + } + + let child_ids = view.child_ids().collect::>(); + if child_ids.is_empty() { + let node_fills = fills(node); + if node_fills.is_some_and(|fills| { + fills.iter().any(|fill| { + is_visible_fill(fill) + && (fill_type(fill) == Some("PATTERN") + || (fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) == Some("TILE"))) + }) + }) { + return SameColor::Null; + } + if view.bool("isAsset") == Some(true) { + let Some(node_fills) = node_fills else { + return SameColor::Null; + }; + if node_fills.iter().any(|fill| { + is_visible_fill(fill) + && fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") }) { - let Some(token) = bound_paint_token(paint, variable_tokens) else { - return false; - }; - tokens.push(token); + return SameColor::Null; } + if node_fills.iter().all(|fill| { + fill.get("visible").and_then(Value::as_bool) == Some(true) + && fill_type(fill) == Some("SOLID") + }) { + return if nested { own() } else { SameColor::Null }; + } + return match own_same_color(node, variable_tokens) { + OwnColor::Color(color) => SameColor::Color(color), + OwnColor::False => SameColor::False, + _ => SameColor::Null, + }; + } + if nested + && node_fills.is_some_and(|fills| { + !fills.iter().any(|fill| { + is_visible_fill(fill) + && matches!(fill_type(fill), Some("IMAGE" | "VIDEO" | "PATTERN")) + }) + }) + { + return own(); } - view.child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .all(|child| visit(snapshot, child, variable_tokens, tokens)) + return SameColor::Null; } - let mut tokens = Vec::new(); - if !visit(snapshot, node, variable_tokens, &mut tokens) || tokens.is_empty() { - return None; + if child_ids.len() == 1 { + if ["paddingLeft", "paddingRight", "paddingTop", "paddingBottom"] + .into_iter() + .any(|field| view.number(field).is_some_and(|padding| padding > 0.0)) + || fills(node).is_some_and(|fills| fills.iter().any(is_visible_fill)) + { + return SameColor::Null; + } + return snapshot + .nodes + .get(child_ids[0]) + .map_or(SameColor::Null, |child| { + same_color(snapshot, child, true, variable_tokens) + }); + } + + let mut same = match own_same_color(node, variable_tokens) { + OwnColor::Null => return SameColor::Null, + OwnColor::False => return SameColor::False, + OwnColor::Color(color) => SameColor::Color(color), + OwnColor::None => SameColor::Null, + }; + for child in child_ids + .into_iter() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)) + { + same = merge_same_color(same, same_color(snapshot, child, true, variable_tokens)); } - let first = tokens.first()?.clone(); - tokens.iter().all(|token| token == &first).then_some(first) + same +} + +/// What the style pass needs beyond the node itself: which variables carry a +/// token name, and how assets are to be named. Carried together so that +/// adding to it does not lengthen every signature it passes through. +#[derive(Clone, Copy)] +pub(super) struct StyleOptions<'a> { + pub variable_tokens: &'a std::collections::BTreeMap, + pub asset_names_per_node: bool, } pub(super) fn push_style_props( @@ -217,8 +433,12 @@ pub(super) fn push_style_props( asset: Option, props: &mut Vec, used_tokens: &mut BTreeSet, - variable_tokens: &std::collections::BTreeMap, + style: StyleOptions<'_>, ) { + let StyleOptions { + variable_tokens, + asset_names_per_node: per_node, + } = style; let view = node.typed_view(); if view.bool("visible") == Some(false) { string_prop(props, "display", "none"); @@ -234,12 +454,14 @@ pub(super) fn push_style_props( } else { "png" }; - let source = format!("/{folder}/{}.{extension}", view.name().unwrap_or("Asset")); + let source = asset_source(snapshot, node, folder, extension, per_node); if asset == AssetKind::SvgMask { - if let Some(token) = uniform_asset_token(snapshot, node, variable_tokens) { - used_tokens.insert(token.clone()); - string_prop(props, "bg", format!("${token}")); - } else if let Some(color) = uniform_asset_color(snapshot, node) { + if let SameColor::Color(color) = + same_color(snapshot, node, false, Some(variable_tokens)) + { + if let Some(token) = color.strip_prefix('$') { + used_tokens.insert(token.to_owned()); + } string_prop(props, "bg", color); } let url = if source.contains(' ') { @@ -254,34 +476,74 @@ pub(super) fn push_style_props( } else { string_prop(props, "src", source); } - if asset == AssetKind::Png - && let Some(scale) = view - .value("fills") - .and_then(Value::as_array) - .and_then(|fills| fills.first()) - .and_then(|paint| paint.get("scaleMode")) - .and_then(Value::as_str) + push_object_fit(&view, props); + // An export in flow is drawn where it sits in the box the layout gives + // the node. They coincide for a plain icon; the notice logo is an + // instance of 1373x98 whose vector is 952x104 at 425px in, and + // `contain` centred a 1373-wide picture of a 952-wide logo. The + // element keeps the layout's box, and the picture is placed inside it + // at the export's own size and offset. An element the layout + // positions is placed by its export outright, in `codegen::layout`. + if view.string("layoutPositioning") != Some("ABSOLUTE") + && !super::layout::placed_by_a_free_layout( + snapshot, + node, + view.string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)), + false, + ) + && let Some(offset) = super::layout::export_offset(node) { - match scale { - "FIT" => string_prop(props, "objectFit", "contain"), - "CROP" => string_prop(props, "objectFit", "cover"), - _ => {} + let size = format!("{} {}", px(offset.w), px(offset.h)); + let position = format!("{} {}", px(offset.x), px(offset.y)); + if asset == AssetKind::SvgMask { + string_prop(props, "maskSize", size); + string_prop(props, "maskPos", position); + } else { + string_prop(props, "objectFit", "none"); + string_prop(props, "objectPos", position); } } push_radius(&view, props); push_strokes(&view, props, used_tokens, variable_tokens); - push_effects(&view, component, props); - if let Some(opacity) = view.number("opacity") - && opacity < 1.0 - { - string_prop(props, "opacity", format_number(opacity)); - } + push_effects(&view, component, props, used_tokens, variable_tokens); + // An export carries the node's own opacity: Figma writes it into the + // SVG as `` and into a PNG's alpha. Written on the element + // as well it is applied twice - a decoration at 0.2 came out at 0.04, + // which is nothing, and the landing page's hero at 0.8 came out at + // 0.64. The plugin writes it twice too. A mask is the same: the + // SVG's own opacity thins the mask, so the colour painted through it + // already shows at the node's opacity. push_blend_mode(&view, props); return; } + push_object_fit(&view, props); let color_prop = if component == "Text" { "color" } else { "bg" }; - if let Some(token) = view + // A background is every visible paint, back to front, as the plugin's + // `getBackgroundProps` composes it. Reading only the bound variable + // dropped the photo that sits on top of a `$gray200` plate: the about + // member cards are `url(...) center/cover no-repeat, $gray200`, and were + // coming out as the plate alone. + let layered = component != "Text" + && view + .value("fills") + .and_then(Value::as_array) + .is_some_and(|fills| { + fills + .iter() + .filter(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) + }) + .count() + > 1 + }); + if layered { + if let Some(background) = background_css(snapshot, node, used_tokens, style) { + string_prop(props, "bg", background); + } + } else if let Some(token) = view .value("devupTokens") .and_then(Value::as_object) .and_then(|tokens| tokens.get("fills")) @@ -293,7 +555,7 @@ pub(super) fn push_style_props( used_tokens.insert(color.clone()); string_prop(props, color_prop, format!("${color}")); } else if component == "Text" && has_non_solid_fill(&view) { - if let Some(background) = background_css(snapshot, node, variable_tokens) { + if let Some(background) = background_css(snapshot, node, used_tokens, style) { string_prop(props, "bg", background); string_prop(props, "bgClip", "text"); string_prop(props, "WebkitTextFillColor", "transparent"); @@ -314,7 +576,7 @@ pub(super) fn push_style_props( } else if let Some(color) = first_solid_color(view.value("fills")) { string_prop(props, color_prop, color); } - } else if let Some(background) = background_css(snapshot, node, variable_tokens) { + } else if let Some(background) = background_css(snapshot, node, used_tokens, style) { string_prop(props, "bg", background); } if let Some(mode) = view @@ -336,7 +598,7 @@ pub(super) fn push_style_props( if component != "Text" { push_strokes(&view, props, used_tokens, variable_tokens); } - push_effects(&view, component, props); + push_effects(&view, component, props, used_tokens, variable_tokens); if let Some(opacity) = view.number("opacity") && opacity < 1.0 { @@ -345,6 +607,36 @@ pub(super) fn push_style_props( push_blend_mode(&view, props); } +/// The plugin's `getObjectFitProps`: how the first visible image fill of a +/// node Figma calls an asset is scaled. It is written whatever element the +/// node became — the about member cards are a `Box` whose photo sits on a +/// `$gray200` plate, and the reference gives them `objectFit="cover"` all the +/// same. `FILL` and `TILE` say nothing. +fn push_object_fit(view: &TypedNode<'_>, props: &mut Vec) { + if view.bool("isAsset") != Some(true) { + return; + } + let Some(scale) = view + .value("fills") + .and_then(Value::as_array) + .and_then(|fills| { + fills.iter().find(|paint| { + paint.get("type").and_then(Value::as_str) == Some("IMAGE") + && paint.get("visible").and_then(Value::as_bool) == Some(true) + }) + }) + .and_then(|paint| paint.get("scaleMode")) + .and_then(Value::as_str) + else { + return; + }; + match scale { + "FIT" => string_prop(props, "objectFit", "contain"), + "CROP" => string_prop(props, "objectFit", "cover"), + _ => {} + } +} + fn push_blend_mode(view: &TypedNode<'_>, props: &mut Vec) { let Some(blend) = view.string("blendMode") else { return; @@ -379,40 +671,226 @@ fn has_non_solid_fill(view: &TypedNode<'_>) -> bool { fn background_css( snapshot: &Snapshot, node: &RawNode, - variable_tokens: &std::collections::BTreeMap, + used_tokens: &mut BTreeSet, + style: StyleOptions<'_>, ) -> Option { let view = node.typed_view(); let paints = view.value("fills")?.as_array()?; + // Keep each paint's own index. CSS layers run back to front, so the order + // here is reversed, but an image fill is identified in the asset manifest + // as `{nodeId}:fills:{index}` against the original order — a reference + // built from the reversed position would name the wrong asset. let visible = paints .iter() - .filter(|paint| { + .enumerate() + .filter(|(_, paint)| { paint.get("visible").and_then(Value::as_bool) != Some(false) && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) }) .rev() .collect::>(); let mut css = Vec::new(); - for (index, paint) in visible.iter().enumerate() { - let is_last = index + 1 == visible.len(); - if let Some(value) = paint_css(snapshot, node, paint, is_last, variable_tokens) { + for (layer, (fill_index, paint)) in visible.iter().enumerate() { + let is_last = layer + 1 == visible.len(); + if let Some(value) = paint_css( + snapshot, + node, + paint, + *fill_index, + is_last, + used_tokens, + style, + ) { css.push(value); } } (!css.is_empty()).then(|| css.join(", ")) } +/// The file an image fill refers to. +/// +/// Every image fill once resolved to a single hard-coded `/icons/image.png`, +/// which lost three separate things: a raster was pointed at the icon folder, +/// unrelated images from different nodes all claimed the same file and so +/// overwrote one another on disk, and two fills on one node produced the +/// identical URL twice over. The manifest identifies a fill as +/// `{nodeId}:fills:{index}`, so the reference keeps the node's name and, past +/// the first fill, its index — a lone fill keeps the plain +/// `/images/{name}.png` the `` element already emits, so the two agree +/// on the same asset. +fn image_fill_source( + snapshot: &Snapshot, + node: &RawNode, + fill_index: usize, + per_node: bool, +) -> String { + let source = if fill_index == 0 { + asset_source(snapshot, node, "images", "png", per_node) + } else { + let stem = asset_stem(snapshot, node, per_node); + format!("/images/{stem}-{fill_index}.png") + }; + if source.contains(' ') { + format!("'{source}'") + } else { + source + } +} + +/// The file an asset node is drawn from: `/{folder}/{stem}.{extension}`. +/// +/// The plugin draws an instance from its main component and names the file +/// after that node, so every instance of a variant is `Property 1=search.svg`: +/// one file for the icon wherever it is used, but the same file for every +/// component set that has a `search` variant, each overwriting the last. The +/// layer name a designer gave the node is used instead, and where two assets +/// in the snapshot that are not the same thing would share it, each says what +/// it is: see `asset_stem`. +pub(crate) fn asset_source( + snapshot: &Snapshot, + node: &RawNode, + folder: &str, + extension: &str, + per_node: bool, +) -> String { + format!( + "/{folder}/{}.{extension}", + asset_stem(snapshot, node, per_node) + ) +} + +/// The path the generated code refers to for an asset node - `/icons/x.svg` +/// for a vector, `/images/x.png` for the first image fill - so a manifest +/// can say where the code expects each asset. `None` for a node the code +/// does not draw from a file. +pub fn asset_path(snapshot: &Snapshot, node_id: &str, per_node: bool) -> Option { + let node = snapshot.nodes.get(node_id)?; + let kind = asset_kind(snapshot, node)?; + let (folder, extension) = match kind { + AssetKind::Svg | AssetKind::SvgMask => ("icons", "svg"), + _ => ("images", "png"), + }; + Some(asset_source(snapshot, node, folder, extension, per_node)) +} + +/// The `position/size` a cropped image fill is painted with, read from +/// Figma's `imageTransform`. The matrix maps the image's own 0..1 space onto +/// the box: the part on show runs from `tx` for `sx` across and from `ty` for +/// `sy` down. Scaling the picture by `1/sx` makes that part as wide as the +/// box, and `tx / (1 - sx)` is where along the overflow it has to sit - which +/// is exactly the percentage CSS positions a background by. A scale of one +/// leaves no overflow to position within, so it sits at the start. +fn image_crop(paint: &Value) -> Option { + let rows = paint.get("imageTransform")?.as_array()?; + let cell = |row: usize, column: usize| rows.get(row)?.as_array()?.get(column)?.as_f64(); + let (scale_x, offset_x) = (cell(0, 0)?, cell(0, 2)?); + let (scale_y, offset_y) = (cell(1, 1)?, cell(1, 2)?); + if scale_x == 0.0 || scale_y == 0.0 { + return None; + } + let position = |scale: f64, offset: f64| { + if (1.0 - scale).abs() < 1e-6 { + 0.0 + } else { + offset / (1.0 - scale) * 100.0 + } + }; + Some(format!( + "{}% {}%/{}% {}%", + format_number(position(scale_x, offset_x)), + format_number(position(scale_y, offset_y)), + format_number(100.0 / scale_x), + format_number(100.0 / scale_y), + )) +} + +/// Where the code draws one of a node's image fills from: `/images/x.png` +/// for the first fill and `/images/x-2.png` past it, the same name +/// `image_fill_source` writes into the code but without the quoting a CSS +/// `url()` puts around a name with a space in it. +/// +/// This answers for any node that carries the fill, where `asset_path` only +/// answers for a node the code draws entirely from a file. A section painted +/// over a photograph is a layout box holding children, not an asset - but the +/// photograph on it is still a file the code points at, and a caller has to +/// be told where. +pub fn image_fill_path( + snapshot: &Snapshot, + node_id: &str, + fill_index: usize, + per_node: bool, +) -> Option { + let node = snapshot.nodes.get(node_id)?; + let stem = asset_stem(snapshot, node, per_node); + Some(if fill_index == 0 { + format!("/images/{stem}.png") + } else { + format!("/images/{stem}-{fill_index}.png") + }) +} + +/// The file name an asset node gets, without folder or extension. +/// +/// The layer name, unless another asset in the snapshot has the same name +/// and is a different thing. Three cards each hold an `Icons` instance at a +/// different variant - chart, clock, lightning - and all three were +/// `/icons/Icons.svg`, one file overwriting the next, and every card drew the +/// chart. An instance whose name is shared then carries its variant, `Icons=chart`; +/// a node that is not an instance carries its id. Instances of one variant +/// share a name and a file, as the same icon at three widths should. +pub(crate) fn asset_stem(snapshot: &Snapshot, node: &RawNode, per_node: bool) -> String { + let view = node.typed_view(); + let name = view.name().unwrap_or("Asset"); + let identity = asset_identity(node); + let shared = per_node + || snapshot.nodes.values().any(|other| { + other.id != node.id + && other.typed_view().name() == Some(name) + && asset_identity(other) != identity + && asset_kind(snapshot, other).is_some() + }); + if !shared { + return name.to_owned(); + } + match identity { + Some(variant) => format!("{name}={variant}"), + None => format!("{name}-{}", node.id.replace([':', ';'], "-")), + } +} + +/// What makes an instance the thing it is: its variant, as `chart` or +/// `lg,primary`. `None` for a node that is not an instance of a variant. +fn asset_identity(node: &RawNode) -> Option { + let view = node.typed_view(); + let properties = view.value("variantProperties")?.as_object()?; + let values = properties + .values() + .filter_map(Value::as_str) + .collect::>(); + (!values.is_empty()).then(|| values.join(",")) +} + fn paint_css( snapshot: &Snapshot, node: &RawNode, paint: &Value, + fill_index: usize, last: bool, - variable_tokens: &std::collections::BTreeMap, + used_tokens: &mut BTreeSet, + style: StyleOptions<'_>, ) -> Option { + let StyleOptions { + variable_tokens, + asset_names_per_node: per_node, + } = style; let kind = paint.get("type")?.as_str()?; match kind { "SOLID" => { let color = bound_paint_token(paint, variable_tokens) - .map(|token| format!("${token}")) + .map(|token| { + used_tokens.insert(token.clone()); + format!("${token}") + }) .or_else(|| color_from_paint(paint))?; Some(if last { color @@ -425,13 +903,24 @@ fn paint_css( "GRADIENT_ANGULAR" => gradient_css(node, paint, "angular", variable_tokens), "GRADIENT_DIAMOND" => gradient_css(node, paint, "diamond", variable_tokens), "IMAGE" => { + let source = image_fill_source(snapshot, node, fill_index, per_node); + // A cropped fill carries its crop as a matrix over the image's own + // 0..1 space. Painted `center/cover` that is thrown away and the + // whole picture is shown instead, which is a different crop: the + // about page's photographs came out zoomed in against the render + // Figma draws of the same frame. + if paint.get("scaleMode").and_then(Value::as_str) == Some("CROP") + && let Some(crop) = image_crop(paint) + { + return Some(format!("url({source}) {crop} no-repeat")); + } let fit = match paint.get("scaleMode").and_then(Value::as_str) { Some("FIT") => "center/contain no-repeat", Some("FILL" | "CROP") => "center/cover no-repeat", Some("TILE") => "repeat", _ => "center/cover no-repeat", }; - Some(format!("url(/icons/image.png) {fit}")) + Some(format!("url({source}) {fit}")) } "PATTERN" => { let source_id = paint.get("sourceNodeId").and_then(Value::as_str)?; @@ -439,10 +928,17 @@ fn paint_css( let name = source .and_then(|node| node.typed_view().name()) .unwrap_or("pattern"); - let extension = source + // A raster belongs with the images and a vector with the icons, + // which is the split every other asset reference follows. This one + // sent a png to the icon folder. + let raster = source .and_then(|node| asset_kind(snapshot, node)) - .map(|kind| if kind == AssetKind::Png { "png" } else { "svg" }) - .unwrap_or("svg"); + .is_some_and(|kind| kind == AssetKind::Png); + let (folder, extension) = if raster { + ("images", "png") + } else { + ("icons", "svg") + }; let spacing = paint.get("spacing").and_then(Value::as_object); let x = spacing .and_then(|value| value.get("x")) @@ -474,7 +970,7 @@ fn paint_css( .collect::>() .join(" "); Some(format!( - "url(/icons/{name}.{extension}){} repeat", + "url(/{folder}/{name}.{extension}){} repeat", if position.is_empty() { String::new() } else { @@ -515,8 +1011,23 @@ fn gradient_css( color .as_object_mut()? .insert("a".to_owned(), Value::from(alpha)); + // A stop bound to a variable is the token — and where the stop or + // the paint is translucent, the token mixed with transparent by + // that much, as the plugin's `processGradientStopColor` writes it: + // a token names an opaque colour, and the alpha would be lost with + // it. The report section's backdrop is a 50% gradient between two + // tokens, `color-mix(in srgb, $primaryBg, transparent 50%)`. let color = bound_paint_token(stop, variable_tokens) - .map(|token| format!("${token}")) + .map(|token| { + if alpha < 1.0 { + format!( + "color-mix(in srgb, ${token}, transparent {}%)", + format_number((1.0 - alpha) * 100.0) + ) + } else { + format!("${token}") + } + }) .or_else(|| color_from(&color))?; Some((stop.get("position")?.as_f64()?, color)) }) @@ -882,7 +1393,13 @@ fn push_strokes( } } -fn push_effects(view: &TypedNode<'_>, component: &str, props: &mut Vec) { +fn push_effects( + view: &TypedNode<'_>, + component: &str, + props: &mut Vec, + used_tokens: &mut BTreeSet, + variable_tokens: &std::collections::BTreeMap, +) { let Some(effects) = view.value("effects").and_then(Value::as_array) else { return; }; @@ -904,7 +1421,17 @@ fn push_effects(view: &TypedNode<'_>, component: &str, props: &mut Vec) { let y = offset.get("y")?.as_f64()?; let radius = effect.get("radius")?.as_f64()?; let spread = effect.get("spread").and_then(Value::as_f64).unwrap_or(0.0); - let color = color_from(effect.get("color")?)?; + // A shadow's colour can be bound to a variable, exactly as a fill + // or a stroke can, and then the token is what the design means: + // the landing page's cards are `$shadow`, one value the theme can + // move for dark mode. Written as the resolved `#87878740` they + // were a colour nothing could reach. + let color = if let Some(token) = bound_paint_token(effect, variable_tokens) { + used_tokens.insert(token.clone()); + format!("${token}") + } else { + color_from(effect.get("color")?)? + }; let inset = if effect.get("type").and_then(Value::as_str) == Some("INNER_SHADOW") { "inset " } else { @@ -954,6 +1481,82 @@ fn push_effects(view: &TypedNode<'_>, component: &str, props: &mut Vec) { } } +/// Whether every visible effect on this node survives `push_effects` without +/// loss. Mirrors that function case for case; the two must move together. +/// +/// `DEVUP_CODEGEN_EFFECT_FALLBACK` used to fire whenever a node merely *had* an +/// effects array. A plain drop shadow is present on nearly every real design, +/// so that permanently pinned `projection` to `lossy` and made `strict: true` +/// unusable, while saying nothing about what was actually lost. +/// +/// Deliberately *not* counted as loss: `showShadowBehindNode`. CSS always +/// paints a non-inset `box-shadow` behind the element's box, so the flag only +/// changes rendering behind a translucent fill. Treating it as loss would put +/// essentially every Figma shadow back into `lossy` for a difference that is +/// usually invisible, recreating the problem this guard removes. +pub(super) fn effects_are_exact(view: &TypedNode<'_>) -> bool { + let Some(effects) = view.value("effects").and_then(Value::as_array) else { + return true; + }; + // `push_effects` picks `textShadow` for Text, which has no spread slot. + // `component.rs` resolves exactly this node type to the `Text` component. + let is_text = view.node_type() == "TEXT"; + let visible = effects + .iter() + .filter(|effect| effect.get("visible").and_then(Value::as_bool) != Some(false)) + .collect::>(); + + // `push_effects` writes `filter` once per effect that maps to it, so two + // such effects would collide on a single prop and the later one wins. + let filter_writers = visible + .iter() + .filter(|effect| { + matches!( + effect.get("type").and_then(Value::as_str), + Some("LAYER_BLUR" | "NOISE" | "TEXTURE") + ) + }) + .count(); + if filter_writers > 1 { + return false; + } + + visible + .iter() + .all(|effect| match effect.get("type").and_then(Value::as_str) { + Some("DROP_SHADOW" | "INNER_SHADOW") => { + // Same fields `push_effects` requires before it emits a shadow; + // if any is missing the effect is dropped on the floor. + let renders = effect + .get("offset") + .and_then(|offset| { + Some((offset.get("x")?.as_f64()?, offset.get("y")?.as_f64()?)) + }) + .is_some() + && effect.get("radius").and_then(Value::as_f64).is_some() + && effect.get("color").and_then(color_from).is_some(); + // CSS shadows carry no per-shadow blend mode. + let blend_survives = effect + .get("blendMode") + .and_then(Value::as_str) + .is_none_or(|mode| mode == "NORMAL"); + // `text-shadow` has no spread component. + let spread_survives = + !is_text || effect.get("spread").and_then(Value::as_f64).unwrap_or(0.0) == 0.0; + renders && blend_survives && spread_survives + } + // `push_effects` falls back to `blur(0px)` when the radius is + // missing or unparseable, which silently fabricates the blur away. + Some("LAYER_BLUR" | "BACKGROUND_BLUR") => { + effect.get("radius").and_then(Value::as_f64).is_some() + } + // `GLASS` is flattened to a plain backdrop blur, `NOISE`/`TEXTURE` + // become a no-op filter placeholder, and any other type is silently + // ignored. All of those are real losses. + _ => false, + }) +} + fn zero_or_px(value: f64) -> String { if value == 0.0 { "0".to_owned() diff --git a/crates/devup-mcp-devup-ui/src/codegen/text.rs b/crates/devup-mcp-devup-ui/src/codegen/text.rs index c4d7e515..0304cd85 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/text.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/text.rs @@ -94,6 +94,10 @@ pub(super) fn push_text_props( string_prop(props, "display", "-webkit-box"); } } + // Reads the designer's own truncation setting, which Figma always + // reports — provided it is collected. It was missing from the field + // manifest, so this saw nothing and every text claimed an ellipsis the + // design never asked for. if view.string("textTruncation") != Some("DISABLED") && view.string("layoutSizingHorizontal") != Some("HUG") { @@ -263,7 +267,8 @@ pub(super) fn render_text_children( let default = default_segment(view).expect("non-empty styledTextSegments"); let default_props = typography_props(default, text_style_tokens, variable_tokens, used_tokens); let mut rendered = Vec::new(); - for segment in segments { + let last = segments.len() - 1; + for (index, segment) in segments.iter().enumerate() { let value = segment .get("characters") .and_then(Value::as_str) @@ -292,7 +297,7 @@ pub(super) fn render_text_children( .iter() .any(|(default_name, default_value)| default_name == name && default_value == value) }); - let content = escape_jsx_text(value); + let content = escape_jsx_segment(value, index == 0, index == last); if segment_props.is_empty() { rendered.push(format!("{indent}{content}")); } else { @@ -406,53 +411,131 @@ fn bound_segment_color( variable_tokens.get(id).map(|token| format!("${token}")) } -pub(super) fn escape_jsx_text(input: &str) -> String { - let leading = input - .chars() - .take_while(|character| *character == ' ') - .count(); - let trailing = input - .chars() - .rev() - .take_while(|character| *character == ' ') +/// Whether a character is whitespace to a JavaScript regex's `\s`. +/// +/// Wider than ASCII: it takes in the no-break space, the Unicode spaces and +/// the line and paragraph separators. The last two matter — Figma writes a +/// soft return as U+2028, and a run of them at the edge of a segment is +/// whitespace to the plugin. +fn is_js_whitespace(character: char) -> bool { + matches!( + character, + '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +/// A whole text as JSX text: the plugin's `fixTextChild` and its line-break +/// substitution. +/// +/// Whitespace at either edge is written as a JSX expression holding one +/// space per character, because JSX would fold it away otherwise, and a +/// newline there counts as a space too — `"기원합니다.\n"` ends in `{" "}`, +/// which the pinned corpus holds. A run of the characters JSX cannot hold +/// bare is wrapped as one expression, `{"&&"}`. Inside the text a line break +/// is `
`, and so is a soft return (U+2028, Shift+Enter in Figma): the +/// plugin passes the character through and a browser does not break on it. +pub(crate) fn escape_jsx_text(input: &str) -> String { + escape_jsx_segment(input, true, true) +} + +/// One segment of a text, which is [`escape_jsx_text`] except at an edge the +/// segment shares with the next. +/// +/// The plugin counts a newline at the edge of *every* segment as a space, so +/// a break that falls where a coloured span ends — which is where a designer +/// most often puts one — is not drawn at all: `"성인 ADHD, \n"` followed by +/// `"우리는 다르게 봅니다."` came out on one line. At an edge inside the text +/// the break is kept, with the spaces around it still counted: +/// `성인 ADHD,{" "}
`. The text's own outer edges keep the plugin's rule. +fn escape_jsx_segment(input: &str, at_start: bool, at_end: bool) -> String { + let characters = input.chars().collect::>(); + let leading = characters + .iter() + .take_while(|character| is_js_whitespace(**character)) .count(); - let middle_end = input.len().saturating_sub(trailing); - let middle = &input[leading..middle_end]; + let trailing = if leading == characters.len() { + 0 + } else { + characters + .iter() + .rev() + .take_while(|character| is_js_whitespace(**character)) + .count() + }; + let middle = &characters[leading..characters.len() - trailing]; + let mut result = String::new(); - if leading > 0 { - result.push_str(&format!("{{\"{}\"}}", " ".repeat(leading))); - } - let mut characters = middle.chars().peekable(); - while let Some(character) = characters.next() { + push_edge_whitespace(&characters[..leading], at_start, &mut result); + let mut index = 0; + while index < middle.len() { + let character = middle[index]; match character { - '{' => result.push_str("{\"{\"}"), - '}' => result.push_str("{\"}\"}"), - '&' => result.push_str("{\"&\"}"), - '<' => result.push_str("{\"<\"}"), - '>' => result.push_str("{\">\"}"), - '\'' => result.push_str("{\"'\"}"), + '{' | '}' | '&' | '<' | '>' | '\'' => { + let run_end = middle[index..] + .iter() + .position(|other| !matches!(other, '{' | '}' | '&' | '<' | '>' | '\'')) + .map_or(middle.len(), |offset| index + offset); + let run = middle[index..run_end].iter().collect::(); + result.push_str(&format!("{{\"{run}\"}}")); + index = run_end; + continue; + } '\r' => { - if characters.peek() == Some(&'\n') { - characters.next(); - } - if characters.peek().is_none() { - result.push_str("{\" \"}"); - } else { - result.push_str("
"); + if middle.get(index + 1) == Some(&'\n') { + index += 1; } + result.push_str("
"); } - '\n' => { - if characters.peek().is_none() { - result.push_str("{\" \"}"); - } else { - result.push_str("
"); + '\n' | '\u{2028}' | '\u{2029}' => result.push_str("
"), + other => result.push(other), + } + index += 1; + } + push_edge_whitespace( + &characters[characters.len() - trailing..], + at_end, + &mut result, + ); + result +} + +/// The whitespace at one edge of a segment. At the text's outer edge every +/// character is a space, as the plugin counts them; at an edge inside the +/// text each break is a `
`, and the spaces between and around them one +/// JSX expression per run, a space per character. +fn push_edge_whitespace(run: &[char], outer_edge: bool, into: &mut String) { + if outer_edge { + if !run.is_empty() { + into.push_str(&format!("{{\"{}\"}}", " ".repeat(run.len()))); + } + return; + } + let mut spaces = 0; + let mut index = 0; + while index < run.len() { + match run[index] { + '\r' | '\n' | '\u{2028}' | '\u{2029}' => { + if spaces > 0 { + into.push_str(&format!("{{\"{}\"}}", " ".repeat(spaces))); + spaces = 0; } + if run[index] == '\r' && run.get(index + 1) == Some(&'\n') { + index += 1; + } + into.push_str("
"); } - value => result.push(value), + _ => spaces += 1, } + index += 1; } - if trailing > 0 { - result.push_str(&format!("{{\"{}\"}}", " ".repeat(trailing))); + if spaces > 0 { + into.push_str(&format!("{{\"{}\"}}", " ".repeat(spaces))); } - result } diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant.rs b/crates/devup-mcp-devup-ui/src/codegen/variant.rs index 92088ce8..ce63c56d 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant.rs @@ -12,13 +12,28 @@ use super::{ use crate::provenance::mark_node; #[derive(Clone, Debug)] -struct Tree { - node_id: String, - source_node_ids: BTreeSet, - component: String, - props: BTreeMap, - children: Vec, - content: Option, +pub(super) struct Tree { + pub node_id: String, + /// What the node is called in Figma. Only used to pair a node with its + /// counterpart at another width when shape alone cannot tell them apart. + pub node_name: String, + pub source_node_ids: BTreeSet, + pub component: String, + pub props: BTreeMap, + pub children: Vec, + pub content: Option, + /// True when `component` names a component to reference rather than a + /// devup-ui primitive, so the renderer must not descend into it. + pub is_component: bool, + /// The boolean property that decides whether this node is drawn, if one + /// does. Figma keeps it on the child rather than on the set. + pub visible_when: Option, + /// The variant options this node is drawn at, when it is not drawn at all + /// of them. Rendered as the condition guarding it. + pub drawn_when: Vec, + /// A JSX comment to write immediately above this node, naming the + /// component it was spelled out from. + pub leading_comment: Option, } #[derive(Clone, Debug)] @@ -30,15 +45,26 @@ struct Record { #[derive(Clone, Debug)] struct Definition { + /// The name as it is written in code. name: String, default: String, options: Vec, + /// A switch rather than a choice: it has no options, and a child names it + /// to say when it is drawn. + boolean: bool, + /// The name Figma knows it by, which a child's `visible` reference uses and + /// which may carry a `#id` suffix that `name` has dropped. + source: String, } #[derive(Clone, Debug)] enum Expression { Literal(String), - Responsive(Option, Option), + /// A devup-ui responsive array, written out slot by slot, narrowest first. + /// `None` is the literal `null`, which is not "no value" but "whatever the + /// slot before it said" — so where a value sits decides the width it starts + /// applying at, and the slots have to be placed, not merely listed. + Responsive(Vec>), Variant(String, Vec<(String, Expression)>), Conditional(String, String, String), } @@ -51,7 +77,7 @@ pub(super) fn generate_variant_component_set( let set = snapshot.nodes.get(set_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "variant component set을 찾지 못했습니다.", + "Variant component set was not found.", false, ) })?; @@ -86,16 +112,19 @@ pub(super) fn generate_variant_component_set( }) }) .collect::, DevupError>>()?; + // A boolean property is not part of a variant's identity — it never appears + // in `variantProperties` — so filtering records by it matches nothing. let default_filters = definitions .iter() - .map(|definition| (definition.name.clone(), definition.default.clone())) + .filter(|definition| !definition.boolean) + .map(|definition| (definition.source.clone(), definition.default.clone())) .collect::>(); let default = find_record(&records, &default_filters) .or_else(|| records.first()) .ok_or_else(|| { DevupError::new( ErrorCode::DevupCodegenFailed, - "component set에 variant component가 없습니다.", + "Component set has no variant components.", false, ) })?; @@ -118,7 +147,14 @@ pub(super) fn generate_variant_component_set( let mut root = default.tree.clone(); let mut unrepresented_variant_nodes = BTreeSet::new(); - merge_source_node_ids(&mut root, &records, &[], &mut unrepresented_variant_nodes); + merge_source_node_ids( + &mut root, + &records, + &[], + &mut unrepresented_variant_nodes, + effect.map(|definition| definition.name.as_str()), + &default.values, + ); root.props = merged_props( &records, &definitions, @@ -181,7 +217,7 @@ pub(super) fn generate_variant_component_set( .into_iter() .map(|node_id| Diagnostic { code: "DEVUP_CODEGEN_VARIANT_CHILD_FALLBACK".to_owned(), - message: "non-default variant의 중첩 차이를 default variant 구조로 대체했습니다." + message: "Nesting differences in the non-default variant were replaced with the default variant structure." .to_owned(), node_id: Some(node_id), severity: Some(DiagnosticSeverity::Warning), @@ -202,33 +238,277 @@ fn definitions(set: &RawNode) -> Vec { .and_then(Value::as_object) .into_iter() .flatten() - .filter(|(_, definition)| definition.get("type").and_then(Value::as_str) == Some("VARIANT")) - .map(|(name, definition)| Definition { - name: name.clone(), - default: definition - .get("defaultValue") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - options: definition - .get("variantOptions") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .map(str::to_owned) - .collect(), + .filter_map(|(name, definition)| { + let boolean = match definition.get("type").and_then(Value::as_str)? { + "VARIANT" => false, + // A boolean property does not name variants; it switches a + // child on and off through `componentPropertyReferences`. It + // belongs in the props all the same, or the component cannot be + // told to leave its icon out. + "BOOLEAN" => true, + _ => return None, + }; + Some(Definition { + name: instance_property_name(name), + default: definition + .get("defaultValue") + .and_then(|value| { + value + .as_str() + .map(str::to_owned) + .or_else(|| value.as_bool().map(|value| value.to_string())) + }) + .unwrap_or_default(), + options: definition + .get("variantOptions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + boolean, + source: name.clone(), + }) }) .collect() } -fn project_tree( +pub(super) fn project_tree( + snapshot: &Snapshot, + node: &RawNode, + options: &CodegenOptions, + is_render_root: bool, +) -> Result { + project_tree_inner(snapshot, node, options, is_render_root, false) +} + +/// As [`project_tree`], but instances become component references instead of +/// being expanded. +/// +/// The variant path must keep expanding them — a component set's variants are +/// merged from the inside, and the pinned corpus records that output — so this +/// is a second entry rather than a change to the first. +pub(super) fn project_tree_keeping_instances( snapshot: &Snapshot, node: &RawNode, options: &CodegenOptions, is_render_root: bool, +) -> Result { + project_tree_inner(snapshot, node, options, is_render_root, true) +} + +/// The boolean component property that decides whether a node is drawn. +/// +/// Figma records this on the child, as a reference back to a property the set +/// declares, which is why a set's definitions alone never reveal what a boolean +/// property actually does. +fn visible_when(view: &devup_mcp_figma::TypedNode<'_>) -> Option { + view.value("componentPropertyReferences") + .and_then(Value::as_object) + .and_then(|references| references.get("visible")) + .and_then(Value::as_str) + .map(instance_property_name) +} + +/// Whether a projected node is just an asset: one shape and nothing else. +/// This is the plugin's `isAssetLeafTree`. +fn is_asset_leaf(tree: &Tree) -> bool { + tree.children.is_empty() + && tree.content.is_none() + && ((tree.component == "Image" && tree.props.contains_key("src")) + || (tree.component == "Box" && tree.props.contains_key("maskImage"))) +} + +/// Variant properties a component set uses to describe itself rather than to +/// be told something, so an instance must not pass them. +/// +/// `effect` names the interaction state a variant stands for, and the whole +/// point of it is that the *definition* folds those variants into `_hover` and +/// `_active` blocks. A call site has no state to pass — writing +/// `` asks the component for a prop it does not have. +/// `viewport` is the same story for widths. +const RESERVED_VARIANT_KEYS: [&str; 2] = ["effect", "viewport"]; + +/// What a placed instance carries about where it sits rather than what it is. +/// The renderer moves these onto a wrapping `Box`, since a component reference +/// has nowhere to put them. +pub(super) const POSITION_PROPS: [&str; 7] = [ + "pos", + "top", + "right", + "bottom", + "left", + "transform", + "transformOrigin", +]; + +/// A component property's name as it is written in code. +/// +/// This is the plugin's `sanitizePropertyName`. Figma names the first variant +/// property for the editor's language, so a file authored in Korean calls it +/// `속성 1` — and a file where someone typed it in English calls it +/// `Property 1`. Both become one identifier, and which one matters: the +/// definition the plugin emits for the same component declares that exact name. +/// +/// Note what it does *not* do. The first word keeps its case, so `Property 1` +/// is `Property1` and not `property1`; folding it turned `leftIcon` into +/// `lefticon`. And only a trailing `#:` is stripped, since +/// that is Figma's own suffix rather than a character a name may not contain. +fn instance_property_name(raw: &str) -> String { + let stripped = raw + .rsplit_once('#') + .filter(|(_, suffix)| { + suffix.split_once(':').is_some_and(|(left, right)| { + !left.is_empty() + && !right.is_empty() + && left.bytes().all(|byte| byte.is_ascii_digit()) + && right.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) + .map_or(raw, |(base, _)| base); + + // The Korean word takes any space after it with it, so `속성 1` is + // `property1` rather than `property 1` waiting to be camel-cased. + let mut normalized = String::with_capacity(stripped.len()); + let mut rest = stripped.trim(); + while let Some(at) = rest.find("속성") { + normalized.push_str(&rest[..at]); + normalized.push_str("property"); + rest = rest[at + "속성".len()..].trim_start(); + } + normalized.push_str(rest); + + let mut name = String::with_capacity(normalized.len()); + let mut capitalize = false; + for character in normalized.chars() { + if character.is_whitespace() || character == '-' || character == '_' { + capitalize = true; + continue; + } + if capitalize { + name.extend(character.to_uppercase()); + capitalize = false; + } else { + name.push(character); + } + } + if name.starts_with(|character: char| character.is_ascii_digit()) { + name.insert(0, '_'); + } + name.retain(|character| { + character.is_ascii_alphanumeric() || character == '_' || character == '$' + }); + if name.is_empty() || name.bytes().all(|byte| byte.is_ascii_digit()) { + return "variant".to_owned(); + } + name +} + +/// The component an instance refers to: the longest `COMPONENT` name the +/// instance's own name starts with, which is how `render_node` resolves it. +fn referenced_component_name(snapshot: &Snapshot, view_name: Option<&str>) -> String { + snapshot + .nodes + .values() + .filter(|candidate| candidate.typed_view().node_type() == "COMPONENT") + .filter_map(|candidate| candidate.typed_view().name()) + .filter(|name| view_name.is_some_and(|instance| instance.starts_with(name))) + .max_by_key(|name| name.len()) + .map_or_else( + || legacy_component_name(view_name.unwrap_or("Component")), + legacy_component_name, + ) +} + +fn project_tree_inner( + snapshot: &Snapshot, + node: &RawNode, + options: &CodegenOptions, + is_render_root: bool, + keep_instances: bool, ) -> Result { let view = node.typed_view(); + if keep_instances && view.node_type() == "INSTANCE" { + // An instance whose component is nothing but a shape is spelled out + // rather than referenced. `` would be a component whose entire + // body is one masked Box, so the reference costs a file and an import + // and says less than the Box does. The plugin does the same, and marks + // the spot with a `{/* */}` comment. + let mut props = BTreeMap::new(); + for (raw, definition) in view + .value("componentProperties") + .and_then(Value::as_object) + .into_iter() + .flatten() + { + if definition.get("type").and_then(Value::as_str) != Some("VARIANT") { + continue; + } + let name = instance_property_name(raw); + if RESERVED_VARIANT_KEYS + .iter() + .any(|reserved| name.eq_ignore_ascii_case(reserved)) + { + continue; + } + if let Some(value) = definition.get("value").and_then(Value::as_str) { + props.insert(name, value.to_owned()); + } + } + let mut expanded = project_tree_inner(snapshot, node, options, is_render_root, false)?; + if is_asset_leaf(&expanded) { + // Spelling the shape out loses which component it came from, and + // that is the one thing a reader needs to change it in the right + // place. The reference leaves the call it declined to write. + let attributes = props + .iter() + .map(|(name, value)| format!(" {name}=\"{value}\"")) + .collect::>() + .join(""); + expanded.leading_comment = Some(format!( + "<{}{attributes} />", + referenced_component_name(snapshot, view.name()) + )); + return Ok(expanded); + } + // The variant each width picked, collected above. These are the + // instance's own choice, not something to merge — a component answers + // for its own widths. + + // Where the instance sits. `
` has nowhere to put this, so the + // renderer gives it a Box; keeping the values here lets them merge + // across widths first. + if view.string("layoutPositioning") == Some("ABSOLUTE") { + let mut placement = Vec::new(); + layout::push_layout_props( + snapshot, + node, + "Box", + &mut placement, + options.root_layout, + is_render_root, + ); + for (name, PropValue::String(value)) in placement { + if POSITION_PROPS.contains(&name.as_str()) || (name == "w" && value == "100%") { + props.insert(name, value); + } + } + } + return Ok(Tree { + node_id: node.id.clone(), + node_name: view.name().unwrap_or_default().to_owned(), + source_node_ids: BTreeSet::from([node.id.clone()]), + component: referenced_component_name(snapshot, view.name()), + props, + children: Vec::new(), + content: None, + is_component: true, + visible_when: visible_when(&view), + drawn_when: Vec::new(), + leading_comment: None, + }); + } let asset = style::asset_kind(snapshot, node); let inferred_mode = view .value("inferredAutoLayout") @@ -249,6 +529,8 @@ fn project_tree( "Image" } else if view.node_type() == "TEXT" { "Text" + } else if layout::centres_its_only_child(snapshot, node) { + "Center" } else { match inferred_mode.or_else(|| view.string("layoutMode")) { Some("GRID") => "Grid", @@ -281,7 +563,10 @@ fn project_tree( asset, &mut props, &mut used_tokens, - &options.variable_tokens, + style::StyleOptions { + variable_tokens: &options.variable_tokens, + asset_names_per_node: options.asset_names_per_node, + }, ); text::push_text_props( &view, @@ -322,7 +607,7 @@ fn project_tree( } else { view.child_ids() .filter_map(|id| snapshot.nodes.get(id)) - .map(|child| project_tree(snapshot, child, options, false)) + .map(|child| project_tree_inner(snapshot, child, options, false, keep_instances)) .collect::, _>>()? }; let content = (view.node_type() == "TEXT").then(|| { @@ -336,54 +621,85 @@ fn project_tree( }); Ok(Tree { node_id: node.id.clone(), + node_name: view.name().unwrap_or_default().to_owned(), source_node_ids: BTreeSet::from([node.id.clone()]), component, props, children, content, + is_component: false, + visible_when: visible_when(&view), + drawn_when: Vec::new(), + leading_comment: None, }) } fn merge_source_node_ids( tree: &mut Tree, records: &[Record], - path: &[usize], + path: &NodePath, unrepresented: &mut BTreeSet, + effect_name: Option<&str>, + default_values: &BTreeMap, ) { for record in records { - match tree_at(&record.tree, path) { - Some(source) if path.is_empty() => { - tree.source_node_ids.insert(source.node_id.clone()); - - if !same_rendered_structure(tree, source) { - unrepresented.insert(source.node_id.clone()); - } - } - Some(source) if same_rendered_node(tree, source) => { - tree.source_node_ids.insert(source.node_id.clone()); - } - Some(source) => { - unrepresented.insert(source.node_id.clone()); - } - None => { - unrepresented.insert(record.node_id.clone()); - } + let Some(source) = tree_at(&record.tree, path) else { + // A variant that does not hold this node at all is not a shape + // difference the merge hid: `drawn_when` states the condition it is + // drawn under, so the absence is carried rather than lost. + continue; + }; + if !same_rendered_node_kind(tree, source) { + unrepresented.insert(source.node_id.clone()); + continue; } + // Differing values are usually the point of the exercise — they become + // the maps — so a value difference is not on its own something the + // merge papered over. The exception is a difference that only an + // interaction state accounts for, below the root: selector blocks are + // written for the component's own props, so a state that changes a + // *child* has nowhere to say so and the value really is dropped. + if !path.is_empty() + && tree.props != source.props + && effect_name.is_some_and(|effect| { + record + .values + .iter() + .all(|(name, value)| name == effect || default_values.get(name) == Some(value)) + }) + && record.values.get(effect_name.unwrap_or_default()) + != default_values.get(effect_name.unwrap_or_default()) + { + unrepresented.insert(source.node_id.clone()); + continue; + } + tree.source_node_ids.insert(source.node_id.clone()); } + let keys = child_keys(tree); for (index, child) in tree.children.iter_mut().enumerate() { let mut child_path = path.to_vec(); - child_path.push(index); - merge_source_node_ids(child, records, &child_path, unrepresented); + let Some(key) = keys.get(index).cloned() else { + continue; + }; + child_path.push(key); + merge_source_node_ids( + child, + records, + &child_path, + unrepresented, + effect_name, + default_values, + ); } } -fn same_rendered_node(left: &Tree, right: &Tree) -> bool { - same_rendered_structure(left, right) && left.props == right.props -} - -fn same_rendered_structure(left: &Tree, right: &Tree) -> bool { +/// Whether two nodes are the same shape. What a text node *says* is a value +/// that gets folded like any other, so only whether it is a text node at all +/// counts here — comparing the words themselves reported every button whose +/// label differs by size, which is all of them. +fn same_rendered_node_kind(left: &Tree, right: &Tree) -> bool { left.component == right.component - && left.content == right.content + && left.content.is_some() == right.content.is_some() && left.children.len() == right.children.len() } @@ -404,7 +720,7 @@ fn merged_props( dimensions: &[Definition], viewport: Option<&Definition>, effect_value: &str, - path: Option<&[usize]>, + path: Option<&NodePath>, defaults: &BTreeMap, ) -> BTreeMap { let mut keys = defaults.keys().cloned().collect::>(); @@ -421,14 +737,223 @@ fn merged_props( dimensions, viewport, effect_value, + // The base props are the whole value, not a difference from + // anything, so nothing is filtered out as unchanged. + None, path.unwrap_or_default(), &prop, )?; - Some((prop, render_expression_attr(&expression))) + Some((prop.clone(), render_expression_attr(&prop, &expression))) }) .collect() } +/// Keys the plugin's prop getters write whether or not they have a value. +/// +/// The plugin keys a node's shape on `Object.keys(tree.props)`, and each of +/// its getters answers with every key it knows, `null` or `undefined` where +/// there is nothing to say: `getTextAlignProps` is always `{textAlign, +/// alignContent}`, `getMinMaxProps` always all four bounds, `getLayoutProps` +/// always `w`, `h`, `aspectRatio` and `flex`. The render step drops the empty +/// ones afterwards. So these keys are on every node of their kind and never +/// tell two apart — a `` right-aligned at one width and not at the next +/// is the same shape to the plugin. Our props only ever hold what renders, so +/// the same keys are left out of the signature here to get the same answer. +const KEYS_THE_PLUGIN_ALWAYS_WRITES: &[&str] = &[ + // getTextAlignProps + "textAlign", + "alignContent", + // getMinMaxProps + "maxW", + "maxH", + "minW", + "minH", + // getLayoutProps; `boxSize` replaces `w` and `h` only when both are set and + // equal, so it still counts + "w", + "h", + "aspectRatio", + "flex", + // getBlendProps + "opacity", + "mixBlendMode", + // getAutoLayoutProps; the component name already says whether a node is + // laid out and which way + "flexDir", + "gap", + "rowGap", + "columnGap", + "justifyContent", + "alignItems", + // getBackgroundProps; `bg` says whether there is paint + "bgBlendMode", + "WebkitTextFillColor", + "bgClip", + // getBorderProps; `outline` says whether there is a stroke + "outlineOffset", + // getPaddingProps writes `p` even when every side is 0 + "p", +]; + +/// `getPositionProps` writes all of these on an absolutely placed node, +/// whichever edges it is pinned to; only `pos` itself tells the shape. +const KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE: &[&str] = + &["left", "right", "top", "bottom", "transform"]; + +/// `getAutoLayoutProps` writes `display` on every laid-out node and the +/// renderer drops the `flex`; a hidden one differs by value, not by key. +const COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY: &[&str] = &["Flex", "VStack", "Center", "Grid"]; + +/// What a node looks like, ignoring the values it holds. +/// +/// This is the plugin's `getChildStructureSignature`. Prop *names* count but +/// their values do not, so a node that only changed a colour still matches +/// itself in another variant — less the names the plugin writes on every +/// node regardless, see [`KEYS_THE_PLUGIN_ALWAYS_WRITES`]. +pub(super) fn structure_signature(tree: &Tree) -> String { + let absolute = tree + .props + .get("pos") + .is_some_and(|value| value == "absolute"); + let laid_out = COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY.contains(&tree.component.as_str()); + let props = tree + .props + .keys() + .filter(|name| !KEYS_THE_PLUGIN_ALWAYS_WRITES.contains(&name.as_str())) + .filter(|name| { + !(absolute && KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE.contains(&name.as_str())) + }) + .filter(|name| !(laid_out && name.as_str() == "display")) + // `getObjectFitProps` answers for every image-filled asset, `null` + // for the scale modes that need nothing said. + .filter(|name| !(tree.component == "Image" && name.as_str() == "objectFit")) + .cloned() + .collect::>() + .join(","); + let children = tree + .children + .iter() + .map(structure_signature) + .collect::>() + .join("|"); + let kind = if tree.is_component { + "component" + } else { + "node" + }; + let text = if tree.content.is_some() { + "text" + } else { + "notext" + }; + format!("{}::{kind}::{text}::{props}::{children}", tree.component) +} + +/// How each child is found again in another variant's tree. +/// +/// Addressing a child by its position breaks the moment a variant leaves one +/// out: a `tag` button has no icon, so its text sits where the icon sat and +/// every lookup after that reads the wrong node. A child is identified by its +/// shape where that is unique among its siblings, and by its name where it is +/// not, with the occurrence to tell repeats apart. This is the plugin's +/// `treeChildrenToMap`. +fn child_keys(tree: &Tree) -> Vec<(String, usize)> { + let mut counts: BTreeMap = BTreeMap::new(); + for child in &tree.children { + *counts.entry(structure_signature(child)).or_default() += 1; + } + let mut seen: BTreeMap = BTreeMap::new(); + tree.children + .iter() + .map(|child| { + let signature = structure_signature(child); + let key = if counts.get(&signature) == Some(&1) { + format!("sig:{signature}") + } else { + child.node_name.clone() + }; + let occurrence = seen.entry(key.clone()).or_default(); + let at = *occurrence; + *occurrence += 1; + (key, at) + }) + .collect() +} + +/// Where a node sits, said in a way another variant can follow. +type NodePath = [(String, usize)]; + +/// The name under which a text node's own words are folded like any other +/// value. No CSS property can be called this, so it cannot collide with one. +const CONTENT_FIELD: &str = "\u{1}content"; + +/// What a node says for a field, whether that is a prop or its own words. +fn tree_field(tree: &Tree, field: &str) -> Option { + if field == CONTENT_FIELD { + return tree.content.clone(); + } + tree.props.get(field).cloned() +} + +/// The conditions under which a node is drawn at all. +/// +/// Not every variant holds every node: a `tag`-sized button has no icon, so the +/// merged tree — built from the default variant, which does — would draw one at +/// every size. The dimensions whose options disagree about the node's existence +/// become the condition guarding it. +/// +/// Existence is judged by what the node *is* rather than by whether something +/// sits at the same index, because a variant that dropped a child shifts every +/// child after it up one place. +fn drawn_when( + records: &[Record], + dimensions: &[Definition], + effect_value: &str, + path: &NodePath, + child: &Tree, +) -> Vec { + let mut conditions = Vec::new(); + for dimension in dimensions.iter().filter(|dimension| !dimension.boolean) { + let parent_path = &path[..path.len().saturating_sub(1)]; + let at = |option: &String, want_node: bool| { + records.iter().any(|record| { + record.values.get(&dimension.source) == Some(option) + && record.values.iter().all(|(name, value)| { + !name.eq_ignore_ascii_case("effect") || value == effect_value + }) + && (!want_node + || tree_at(&record.tree, parent_path).is_some_and(|parent| { + parent + .children + .iter() + .any(|sibling| sibling.component == child.component) + })) + }) + }; + let drawn = dimension + .options + .iter() + .filter(|option| at(option, false)) + .collect::>(); + let holding = drawn + .iter() + .filter(|option| at(option, true)) + .collect::>(); + if holding.is_empty() || holding.len() == drawn.len() { + continue; + } + conditions.push(format!( + "({})", + holding + .iter() + .map(|option| format!("{} === \"{option}\"", dimension.name)) + .collect::>() + .join(" || ") + )); + } + conditions +} + fn merge_child_props( tree: &mut Tree, records: &[Record], @@ -436,11 +961,35 @@ fn merge_child_props( dimensions: &[Definition], viewport: Option<&Definition>, effect_value: &str, - path: &[usize], + path: &NodePath, ) { + let keys = child_keys(tree); for (index, child) in tree.children.iter_mut().enumerate() { let mut child_path = path.to_vec(); - child_path.push(index); + let Some(key) = keys.get(index).cloned() else { + continue; + }; + child_path.push(key); + child.drawn_when = drawn_when(records, dimensions, effect_value, &child_path, child); + // A text node's own words are as much a variant as the colour around + // them: `lg` says "buttonLg" where `tag` says "Tag". + if child.content.is_some() + && let Some(expression) = expression_for_prop( + records, + definitions, + dimensions, + viewport, + effect_value, + None, + &child_path, + CONTENT_FIELD, + ) + { + child.content = Some(match &expression { + Expression::Literal(value) => value.clone(), + other => format!("{{{}}}", render_expression_value(other, 0)), + }); + } child.props = merged_props( records, definitions, @@ -505,6 +1054,9 @@ fn changed_effect_props( dimensions, viewport, effect_value, + // A selector block says what this state changes, so a value the + // state leaves alone is left out of it entirely. + Some(default_effect), &[], &prop, )?; @@ -513,13 +1065,15 @@ fn changed_effect_props( .collect() } +#[allow(clippy::too_many_arguments)] fn expression_for_prop( records: &[Record], definitions: &[Definition], dimensions: &[Definition], viewport: Option<&Definition>, effect_value: &str, - path: &[usize], + baseline: Option<&str>, + path: &NodePath, prop: &str, ) -> Option { let effect_name = definitions @@ -528,60 +1082,33 @@ fn expression_for_prop( .map(|definition| definition.name.as_str()); let dependencies = dimensions .iter() + .filter(|dimension| !dimension.boolean) .filter(|dimension| { dimension_depends( records, definitions, effect_name, effect_value, + baseline, path, prop, dimension, ) }) .collect::>(); - if dependencies.len() == 1 { - let dimension = dependencies[0]; - let values = dimension - .options - .iter() - .map(|option| { - ( - option.clone(), - viewport_expression( - records, - definitions, - effect_name, - effect_value, - path, - prop, - Some((&dimension.name, option)), - viewport, - ), - ) - }) - .collect::>(); - let present = values - .iter() - .filter(|(_, value)| value.is_some()) - .collect::>(); - if present.len() == 1 - && dimension.options.len() == 2 - && let Some(Expression::Literal(value)) = present[0].1.clone() - { - return Some(Expression::Conditional( - dimension.name.clone(), - present[0].0.clone(), - value, - )); - } - return Some(Expression::Variant( - dimension.name.clone(), - values - .into_iter() - .filter_map(|(key, value)| Some((key, value?))) - .collect(), - )); + if !dependencies.is_empty() { + return nested_expression( + records, + definitions, + effect_name, + effect_value, + path, + prop, + baseline, + &dependencies, + &BTreeMap::new(), + viewport, + ); } viewport_expression( records, @@ -590,67 +1117,272 @@ fn expression_for_prop( effect_value, path, prop, - None, + baseline, + &BTreeMap::new(), viewport, ) } +/// What one record says about a prop, or nothing when a baseline is given and +/// it says the same as the baseline does. +/// +/// A `_hover` block is a difference, not a restatement. Asked for the whole +/// hover value it would repeat every size and colour that hover leaves alone, +/// and the one thing hover actually changes would be buried in it. +fn record_value( + records: &[Record], + record: &Record, + effect_name: Option<&str>, + baseline: Option<&str>, + path: &NodePath, + prop: &str, +) -> Option { + let value = tree_at(&record.tree, path).and_then(|tree| tree_field(tree, prop)); + let (Some(baseline), Some(effect_name)) = (baseline, effect_name) else { + return value; + }; + let mut filters = record.values.clone(); + filters.insert(effect_name.to_owned(), baseline.to_owned()); + let unchanged = find_record(records, &filters) + .and_then(|record| tree_at(&record.tree, path)) + .and_then(|tree| tree_field(tree, prop)); + if value == unchanged { None } else { value } +} + +#[allow(clippy::too_many_arguments)] fn dimension_depends( records: &[Record], definitions: &[Definition], effect_name: Option<&str>, effect_value: &str, - path: &[usize], + baseline: Option<&str>, + path: &NodePath, prop: &str, dimension: &Definition, ) -> bool { + // A variant that does not draw the node has nothing to say about the + // prop: the node is guarded so that it is never rendered there, and + // counting its silence as a difference wrote the button icon's + // `aspectRatio={{lg: "1", md: "1", sm: "1"}[size]}` for a value every + // variant that draws it agrees on. A variant that draws the node and + // leaves the prop unset is a difference, and stays one. let sets = dimension .options .iter() - .map(|option| { - records + .filter_map(|option| { + let set = records .iter() .filter(|record| { record.values.get(&dimension.name) == Some(option) && effect_name.is_none_or(|name| { record.values.get(name).map(String::as_str) == Some(effect_value) }) + && tree_at(&record.tree, path).is_some() }) - .map(|record| { - tree_at(&record.tree, path) - .and_then(|tree| tree.props.get(prop)) - .cloned() - }) - .collect::>() + .map(|record| record_value(records, record, effect_name, baseline, path, prop)) + .collect::>(); + (!set.is_empty()).then_some(set) }) .collect::>(); let _ = definitions; sets.iter().skip(1).any(|set| set != &sets[0]) } +/// How deeply an expression nests, so that the shallower of two ways to write +/// the same thing can be preferred. A plain value costs nothing; a map costs +/// one plus the worst of what it holds. +fn nesting_cost(expression: &Expression) -> usize { + match expression { + Expression::Variant(_, values) => { + 1 + values + .iter() + .map(|(_, value)| nesting_cost(value)) + .max() + .unwrap_or_default() + } + _ => 0, + } +} + +/// One prop's value where several dimensions decide it at once. +/// +/// A value that depends on two dimensions cannot be written as one map, and +/// writing it as the default's literal loses every other combination — which is +/// what this used to do. It is written as a map inside a map instead. +/// +/// Which dimension goes on the outside is not free: `{lg: {...}[varient], tag: +/// "10px"}[size]` and its transpose say the same thing at different sizes. Each +/// candidate outer dimension is costed by how deep the result nests, with the +/// number of entries as a tiebreak, and the cheapest is kept. That is the +/// plugin's `createNestedVariantProp`. +#[allow(clippy::too_many_arguments)] +fn nested_expression( + records: &[Record], + definitions: &[Definition], + effect_name: Option<&str>, + effect_value: &str, + path: &NodePath, + prop: &str, + baseline: Option<&str>, + remaining: &[&Definition], + pinned: &BTreeMap, + viewport: Option<&Definition>, +) -> Option { + let Some((first, rest)) = remaining.split_first() else { + return viewport_expression( + records, + definitions, + effect_name, + effect_value, + path, + prop, + baseline, + pinned, + viewport, + ); + }; + + // Whether the set actually holds a variant for this combination. An option + // with no value can mean two different things, and they call for opposite + // answers: a variant that exists and leaves the prop unset is a hole, and + // filling it would give that variant a value it refused; a combination the + // designer never drew is not a hole at all, and leaving a map entry for it + // only makes the map longer. So presence is asked of the record, not of the + // prop. + let drawn = |pinned: &BTreeMap| { + let filters = definitions + .iter() + .filter(|definition| !definition.boolean) + .map(|definition| { + let value = if Some(definition.name.as_str()) == effect_name { + effect_value + } else if let Some(value) = pinned.get(&definition.name) { + value.as_str() + } else { + &definition.default + }; + (definition.name.clone(), value.to_owned()) + }) + .collect::>(); + find_record(records, &filters).is_some() + }; + + let branch = |dimension: &Definition, others: Vec<&Definition>| { + dimension + .options + .iter() + .map(|option| { + let mut pinned = pinned.clone(); + pinned.insert(dimension.name.clone(), option.clone()); + ( + option.clone(), + nested_expression( + records, + definitions, + effect_name, + effect_value, + path, + prop, + baseline, + &others, + &pinned, + viewport, + ), + ) + }) + .filter_map(|(option, value)| Some((option, value?))) + .collect::>() + }; + + if rest.is_empty() { + let values = branch(first, Vec::new()); + if values.is_empty() { + return None; + } + // Every option that was drawn saying the same thing is not a choice at + // all. Counted against the options that exist rather than all of them, + // so a prop only one variant sets stays a choice while a value shared + // by every variant there is collapses. + let drawn_options = first + .options + .iter() + .filter(|option| { + let mut pinned = pinned.clone(); + pinned.insert(first.name.clone(), (*option).clone()); + drawn(&pinned) + }) + .count(); + if values.len() == drawn_options + && values.iter().all(|(_, value)| { + matches!((value, &values[0].1), (Expression::Literal(left), Expression::Literal(right)) if left == right) + }) + { + return Some(values[0].1.clone()); + } + // One option carrying the only value reads better as the condition it + // is than as a map with a single entry and a hole where the rest were. + if values.len() == 1 + && let Expression::Literal(value) = &values[0].1 + { + return Some(Expression::Conditional( + first.name.clone(), + values[0].0.clone(), + value.clone(), + )); + } + return Some(Expression::Variant(first.name.clone(), values)); + } + + let mut best: Option<(usize, Expression)> = None; + for candidate in remaining { + let others = remaining + .iter() + .filter(|other| other.name != candidate.name) + .copied() + .collect::>(); + let values = branch(candidate, others); + if values.is_empty() { + continue; + } + // The plugin's cost: every branch that still nests counts, and the + // number of entries breaks ties at a tenth of the weight. Kept in + // tenths so the comparison is exact rather than a float one. + let cost = values + .iter() + .map(|(_, value)| nesting_cost(value) * 10) + .sum::() + + values.len(); + let expression = Expression::Variant(candidate.name.clone(), values); + if best.as_ref().is_none_or(|(best_cost, _)| cost < *best_cost) { + best = Some((cost, expression)); + } + } + best.map(|(_, expression)| expression) +} + #[allow(clippy::too_many_arguments)] fn viewport_expression( records: &[Record], definitions: &[Definition], effect_name: Option<&str>, effect_value: &str, - path: &[usize], + path: &NodePath, prop: &str, - dimension: Option<(&str, &String)>, + baseline: Option<&str>, + pinned: &BTreeMap, viewport: Option<&Definition>, ) -> Option { let lookup = |viewport_value: Option<&str>| { let filters = definitions .iter() + .filter(|definition| !definition.boolean) .map(|definition| { let value = if Some(definition.name.as_str()) == effect_name { effect_value } else if definition.name.eq_ignore_ascii_case("viewport") { viewport_value.unwrap_or(&definition.default) - } else if dimension.is_some_and(|(name, _)| name == definition.name) { - dimension - .map(|(_, value)| value.as_str()) - .unwrap_or(&definition.default) + } else if let Some(value) = pinned.get(&definition.name) { + value.as_str() } else { &definition.default }; @@ -658,9 +1390,7 @@ fn viewport_expression( }) .collect::>(); find_record(records, &filters) - .and_then(|record| tree_at(&record.tree, path)) - .and_then(|tree| tree.props.get(prop)) - .cloned() + .and_then(|record| record_value(records, record, effect_name, baseline, path, prop)) }; let Some(viewport) = viewport else { return lookup(None).map(Expression::Literal); @@ -678,13 +1408,22 @@ fn viewport_expression( if mobile == desktop { mobile.or(desktop).map(Expression::Literal) } else { - Some(Expression::Responsive(mobile, desktop)) + // A component's `viewport` variant only ever names two widths, and the + // wider one is written at the last slot rather than the second, so the + // value it carries starts applying at the widest breakpoint. + Some(Expression::Responsive(vec![ + mobile, None, None, None, desktop, + ])) } } -fn tree_at<'a>(tree: &'a Tree, path: &[usize]) -> Option<&'a Tree> { - path.iter() - .try_fold(tree, |current, index| current.children.get(*index)) +fn tree_at<'a>(tree: &'a Tree, path: &NodePath) -> Option<&'a Tree> { + path.iter().try_fold(tree, |current, (key, occurrence)| { + let index = child_keys(current) + .into_iter() + .position(|(candidate, at)| candidate == *key && at == *occurrence)?; + current.children.get(index) + }) } fn transition( @@ -740,6 +1479,11 @@ fn render_component( let fields = dimensions .iter() .map(|definition| { + if definition.boolean { + // Optional, because leaving it out is how a caller says the + // child should not be drawn. + return format!(" {}?: boolean", definition.name); + } format!( " {}: {}", definition.name, @@ -804,15 +1548,6 @@ fn render_tree( rendered_props.push(format!("{child_indent}transition=\"{value}\"")); rendered_props.push(format!("{child_indent}transitionProperty=\"{properties}\"")); } - let opening = if rendered_props.is_empty() { - format!("{indent}<{}>", tree.component) - } else { - format!( - "{indent}<{}\n{}\n{indent}>", - tree.component, - rendered_props.join("\n") - ) - }; let mut children = tree .children .iter() @@ -827,13 +1562,54 @@ fn render_tree( .join("\n"), ); } - let rendered = if children.is_empty() { - format!("{opening}\n{indent}", tree.component) - } else { - format!( - "{opening}\n{}\n{indent}", + // An element with nothing in it closes on itself, as the plugin's + // `renderNode` closes it — a folded icon was being written as an open + // and a close tag with nothing between. + let rendered = match (rendered_props.is_empty(), children.is_empty()) { + (true, true) => format!("{indent}<{} />", tree.component), + (false, true) => format!( + "{indent}<{}\n{}\n{indent}/>", + tree.component, + rendered_props.join("\n") + ), + (true, false) => format!( + "{indent}<{}>\n{}\n{indent}", + tree.component, children.join("\n"), tree.component + ), + (false, false) => format!( + "{indent}<{}\n{}\n{indent}>\n{}\n{indent}", + tree.component, + rendered_props.join("\n"), + children.join("\n"), + tree.component + ), + }; + // A node a boolean property switches on is written as that condition. The + // brace has to wrap the whole element, so it is applied after the element + // is rendered rather than woven into it. + let rendered = match &tree.leading_comment { + Some(comment) => format!("{indent}{{/* {comment} */}}\n{rendered}"), + None => rendered, + }; + let guards = tree + .visible_when + .iter() + .cloned() + .chain(tree.drawn_when.iter().cloned()) + .collect::>(); + let rendered = if guards.is_empty() { + rendered + } else { + let inner = rendered + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n"); + format!( + "{indent}{{{} && (\n{inner}\n{indent})}}", + guards.join(" && ") ) }; tree.source_node_ids @@ -842,11 +1618,26 @@ fn render_tree( .fold(rendered, |rendered, node_id| mark_node(node_id, rendered)) } -fn render_expression_attr(expression: &Expression) -> String { +/// `typography` names a token, and devup-ui types it by the literal. Handing it +/// a map indexed at runtime widens every entry to `string` and the token is no +/// longer one, so the object is frozen: `({ lg: "buttonLg", … } as const)[size]`. +/// It is the only prop that needs it, which is how the reference writes it too. +fn needs_const(prop: &str) -> bool { + prop == "typography" +} + +fn render_expression_attr(prop: &str, expression: &Expression) -> String { match expression { Expression::Literal(value) => format!("=\"{value}\""), - Expression::Conditional(prop, option, value) => { - format!("={{{prop} === '{option}' && \"{value}\"}}") + Expression::Conditional(name, option, value) => { + format!("={{{name} === '{option}' && \"{value}\"}}") + } + Expression::Variant(name, _) if needs_const(prop) => { + let rendered = render_expression_value(expression, 0); + let object = rendered + .strip_suffix(&format!("[{name}]")) + .unwrap_or(&rendered); + format!("={{({object} as const)[{name}]}}") } _ => format!("={{{}}}", render_expression_value(expression, 0)), } @@ -877,14 +1668,15 @@ fn indent_attribute_expression(expression: &str, depth: usize) -> String { fn render_expression_value(expression: &Expression, depth: usize) -> String { match expression { Expression::Literal(value) => format!("\"{value}\""), - Expression::Responsive(mobile, desktop) => format!( - "[{}, null, null, null, {}]", - mobile - .as_ref() - .map_or("null".to_owned(), |value| format!("\"{value}\"")), - desktop - .as_ref() - .map_or("null".to_owned(), |value| format!("\"{value}\"")) + Expression::Responsive(slots) => format!( + "[{}]", + slots + .iter() + .map(|slot| slot + .as_ref() + .map_or("null".to_owned(), |value| format!("\"{value}\""))) + .collect::>() + .join(", ") ), Expression::Variant(prop, values) => { let indent = " ".repeat(depth); diff --git a/crates/devup-mcp-devup-ui/src/lib.rs b/crates/devup-mcp-devup-ui/src/lib.rs index 2ee3342e..3279a427 100644 --- a/crates/devup-mcp-devup-ui/src/lib.rs +++ b/crates/devup-mcp-devup-ui/src/lib.rs @@ -1,4 +1,6 @@ pub mod codegen; pub mod provenance; +pub mod style_props; pub mod theme; +pub mod ui_validate; pub mod validation; diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index 248cc9fb..bae5e99c 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -4,7 +4,7 @@ use devup_mcp_figma::{DevupError, ErrorCode, FidelityImpact, Snapshot, discover_ use serde::{Deserialize, Serialize}; use serde_json::json; -use crate::codegen::CodegenOutput; +use crate::codegen::{CodegenOutput, asset_kind, derived_padding, placed_by_a_free_layout}; const START: &str = "\u{e000}DEVUP_PROVENANCE_START:"; const END: &str = "\u{e000}DEVUP_PROVENANCE_END:"; @@ -125,8 +125,19 @@ pub struct FidelityReport { pub assets: FidelityCoverage, pub layout: FidelityCoverage, pub impacts: FidelityImpactCounts, + /// The `nodeId#property` layout pairs the generated TSX does not account + /// for, bounded by [`MAX_REPORTED_UNCOVERED`]. Reporting only a ratio left + /// a shortfall untriageable: nothing said whether the layout was wrong or + /// merely expressed another way. Purely informational — it does not feed + /// `impacts`, `strict_compatible`, or the reported status. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub uncovered_layout: Vec, } +/// Enough to see the shape of a shortfall without turning a diagnostic into a +/// second payload. +const MAX_REPORTED_UNCOVERED: usize = 40; + impl FidelityReport { pub fn strict_compatible(&self) -> bool { self.syntax_valid @@ -285,7 +296,7 @@ pub fn validate_fidelity( { return Err(DevupError::with_details( ErrorCode::DevupCodegenFailed, - "projection trace가 source node를 정확히 한 번씩 설명하지 못했습니다.", + "Projection trace did not account for each source node exactly once.", false, json!({ "missingNodeIds": missing, @@ -388,26 +399,41 @@ pub fn validate_fidelity( .iter() .filter(|node_id| !has_asset_ancestor(node_id, &parents, &asset_nodes)) .flat_map(|node_id| { - LAYOUT_FIELDS.iter().filter_map(|field| { + let is_asset = asset_nodes.contains(*node_id); + LAYOUT_FIELDS.iter().filter_map(move |field| { snapshot .nodes .get(*node_id) - .filter(|node| layout_field_is_semantic(snapshot, node, field)) + .filter(|node| { + (!is_asset || !asset_layout_field_is_internal(field)) + && layout_field_is_semantic(snapshot, node, field) + }) .map(|_| ((*node_id).to_owned(), (*field).to_owned())) }) }) .collect::>(); - let covered_layout = layout - .iter() - .filter(|(node_id, property)| { - output.source_map.entries.iter().any(|entry| { + let (covered_layout, uncovered_layout) = { + let mut covered = 0usize; + // Which pairs were not represented, not just how many. A count alone + // cannot distinguish "the layout is wrong" from "the same layout is + // expressed differently", so a shortfall was previously impossible to + // act on or even to triage. + let mut uncovered = Vec::new(); + for (node_id, property) in &layout { + let represented = output.source_map.entries.iter().any(|entry| { entry.node_id.as_deref() == Some(node_id.as_str()) && entry.property.as_deref() == Some(property.as_str()) && entry_range(entry, &output.tsx) .is_some_and(|source| layout_source_matches(property, source)) - }) - }) - .count(); + }); + if represented { + covered += 1; + } else if uncovered.len() < MAX_REPORTED_UNCOVERED { + uncovered.push(format!("{node_id}#{property}")); + } + } + (covered, uncovered) + }; let mut impacts = FidelityImpactCounts::default(); for diagnostic in &output.diagnostics { match diagnostic.fidelity_impact() { @@ -426,6 +452,7 @@ pub fn validate_fidelity( assets: FidelityCoverage::new(assets.len(), covered_assets), layout: FidelityCoverage::new(layout.len(), covered_layout), impacts, + uncovered_layout, }) } @@ -444,6 +471,18 @@ fn has_asset_ancestor( false } +fn asset_layout_field_is_internal(field: &str) -> bool { + matches!( + field, + "layoutMode" + | "itemSpacing" + | "paddingTop" + | "paddingRight" + | "paddingBottom" + | "paddingLeft" + ) +} + fn layout_field_is_semantic( snapshot: &Snapshot, node: &devup_mcp_figma::RawNode, @@ -461,10 +500,68 @@ fn layout_field_is_semantic( .child_ids() .any(|child| child == node.id) }); - let component_canvas_dimension = matches!(field, "width" | "height") && component_set_parent; - if component_canvas_dimension { + // A frame sitting on a page or section is the canvas the design was drawn + // on, and its own dimensions are deliberately left unsaid so the result is + // not pinned to that size. Counting them would report a shortfall for + // something the output declines to claim on purpose. Kept in step with the + // same test in `codegen::layout`. + let canvas_parent = component_set_parent + || view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .map(|parent| parent.node_type.as_str()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); + if matches!(field, "width" | "height") && canvas_parent { + return false; + } + // An out-of-flow node whose children's inset became padding takes its size + // from that padding plus its content, so the size is not restated and + // counting it would report a shortfall for something said another way. + // Kept in step with the same test in `codegen::layout`. + if matches!(field, "width" | "height") + && view.string("layoutPositioning") == Some("ABSOLUTE") + && derived_padding(snapshot, node).is_some() + { return false; } + // An out-of-flow node that holds something takes its height from what it + // holds, and `codegen::layout` drops it for exactly that reason. Counting + // it here reported a shortfall against a value the converter is right not + // to state: a header pinned across the top of a screen came back as + // unaccounted-for height, and the reference implementation does not state + // it either. + let placed_out_of_flow = view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout( + snapshot, + node, + view.string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)), + canvas_parent, + ); + if field == "height" && placed_out_of_flow && view.child_ids().next().is_some() { + // An asset folds its children away and is drawn at a size, so + // `codegen::layout` restores both sides for it — unless it is wider + // than its parent. Then the width is written as 100% and the height + // is dropped, and the reference does the same: two goldens carry a + // full-width rotated background mask with no h, and the 465px puzzle + // icon on a 328px `about` mobile column comes out the same way. The + // rule below is the one in `codegen::layout` for the absolute branch, + // kept in step by hand. + let wider_than_parent = || { + let parent_width = view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .and_then(|parent| parent.typed_view().number("width")); + matches!( + (view.number("width"), parent_width), + (Some(width), Some(parent_width)) if width >= parent_width + ) + }; + if !projects_as_asset(snapshot, node) || wider_than_parent() { + return false; + } + } match field { "layoutMode" => matches!(view.string(field), Some("HORIZONTAL" | "VERTICAL" | "GRID")), "layoutPositioning" => view.string(field) == Some("ABSOLUTE"), @@ -481,7 +578,17 @@ fn layout_field_is_semantic( .is_none_or(|value| value == "FIXED") } "itemSpacing" => { - view.child_ids().count() > 1 + // Spacing describes the distance between rendered siblings, so a + // hidden child leaves nothing to space apart and the generated code + // rightly omits the gap. Counting it here would report a shortfall + // for a fact that was deliberately not expressed. Kept in step with + // the same test in `codegen::layout`. + let visible_children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)) + .count(); + visible_children > 1 && view.string("primaryAxisAlignItems") != Some("SPACE_BETWEEN") && !projects_as_asset(snapshot, node) && view.number(field).is_some_and(|value| value != 0.0) @@ -494,84 +601,7 @@ fn layout_field_is_semantic( } fn projects_as_asset(snapshot: &Snapshot, node: &devup_mcp_figma::RawNode) -> bool { - fn nested(snapshot: &Snapshot, node: &devup_mcp_figma::RawNode) -> bool { - if projects_as_asset(snapshot, node) { - return true; - } - let view = node.typed_view(); - if view.node_type() == "TEXT" || view.child_ids().next().is_some() { - return false; - } - view.value("fills") - .and_then(serde_json::Value::as_array) - .is_some_and(|fills| { - fills.iter().all(|paint| { - paint.get("visible").and_then(serde_json::Value::as_bool) == Some(false) - || paint.get("type").and_then(serde_json::Value::as_str) == Some("SOLID") - }) - }) - } - - let view = node.typed_view(); - if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") - || view - .value("inferredAutoLayout") - .and_then(serde_json::Value::as_object) - .and_then(|layout| layout.get("layoutMode")) - .and_then(serde_json::Value::as_str) - == Some("GRID") - { - return false; - } - if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") - || (view.node_type() == "ELLIPSE" - && view - .value("arcData") - .and_then(|value| value.get("innerRadius")) - .and_then(serde_json::Value::as_f64) - .is_some_and(|value| value != 0.0)) - { - return true; - } - let fills = view.value("fills").and_then(serde_json::Value::as_array); - if view.bool("isAsset") == Some(true) - && fills.is_some_and(|fills| { - (fills.len() == 1 - && fills[0].get("type").and_then(serde_json::Value::as_str) == Some("IMAGE") - && fills[0] - .get("scaleMode") - .and_then(serde_json::Value::as_str) - != Some("TILE")) - || (!fills.is_empty() - && !fills.iter().all(|paint| { - paint.get("type").and_then(serde_json::Value::as_str) == Some("SOLID") - && paint.get("visible").and_then(serde_json::Value::as_bool) - == Some(true) - })) - }) - { - return true; - } - let children = view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .collect::>(); - if children.is_empty() - || (children.len() == 1 - && !children.iter().all(|child| { - matches!( - child.typed_view().node_type(), - "VECTOR" | "STAR" | "POLYGON" - ) - }) - && matches!( - view.string("layoutMode"), - Some("HORIZONTAL" | "VERTICAL" | "GRID") - )) - { - return false; - } - children.into_iter().all(|child| nested(snapshot, child)) + asset_kind(snapshot, node).is_some() } fn semantic_nodes<'a>(snapshot: &'a Snapshot, root_id: &str) -> BTreeSet<&'a str> { @@ -686,7 +716,7 @@ fn source_covers_text(source: &str, characters: &str) -> bool { return true; } let fragments = characters - .split(['\r', '\n']) + .split(['\r', '\n', '\u{2028}', '\u{2029}']) .map(str::trim) .filter(|fragment| !fragment.is_empty()); let mut cursor = 0; @@ -700,55 +730,10 @@ fn source_covers_text(source: &str, characters: &str) -> bool { cursor > 0 && cursor == source.len() } +/// The text as the converter writes it, so a match is against the one rule +/// both sides follow rather than a copy of it kept here. fn encode_jsx_text(input: &str) -> String { - let leading = input - .chars() - .take_while(|character| *character == ' ') - .count(); - let trailing = input - .chars() - .rev() - .take_while(|character| *character == ' ') - .count(); - let middle_end = input.len().saturating_sub(trailing); - let middle = &input[leading..middle_end]; - let mut result = String::new(); - if leading > 0 { - result.push_str(&format!("{{\"{}\"}}", " ".repeat(leading))); - } - let mut characters = middle.chars().peekable(); - while let Some(character) = characters.next() { - match character { - '{' => result.push_str("{\"{\"}"), - '}' => result.push_str("{\"}\"}"), - '&' => result.push_str("{\"&\"}"), - '<' => result.push_str("{\"<\"}"), - '>' => result.push_str("{\">\"}"), - '\'' => result.push_str("{\"'\"}"), - '\r' => { - if characters.peek() == Some(&'\n') { - characters.next(); - } - if characters.peek().is_none() { - result.push_str("{\" \"}"); - } else { - result.push_str("
"); - } - } - '\n' => { - if characters.peek().is_none() { - result.push_str("{\" \"}"); - } else { - result.push_str("
"); - } - } - value => result.push(value), - } - } - if trailing > 0 { - result.push_str(&format!("{{\"{}\"}}", " ".repeat(trailing))); - } - result + crate::codegen::escape_jsx_text(input) } fn layout_source_matches(property: &str, source: &str) -> bool { @@ -1422,7 +1407,7 @@ fn find_text_span(source: &str, characters: &str, search_start: usize) -> Option return Some((start, start + rendered.len())); } let fragments = characters - .split(['\r', '\n']) + .split(['\r', '\n', '\u{2028}', '\u{2029}']) .map(str::trim) .filter(|fragment| !fragment.is_empty()) .map(encode_jsx_text) diff --git a/crates/devup-mcp-devup-ui/src/style_props.rs b/crates/devup-mcp-devup-ui/src/style_props.rs new file mode 100644 index 00000000..b95b7056 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/style_props.rs @@ -0,0 +1,988 @@ +//! Known `@devup-ui/react` primitive prop names, taken from devup-ui's own +//! source rather than invented: the keys of every `ResponsiveValue` prop in +//! `packages/react/src/types/props/*.ts` and every alias in +//! `libs/css/src/constant.rs`, at devup-ui 1.0.41 (e407524), plus the +//! entries the published Style Props API reference listed before that. The +//! docs page alone was 409 props short — `maskPos`, `bgPos`, +//! `WebkitLineClamp`, `visibility`, `zoom` among them — and the plugin writes +//! `maskPos` on every mask icon, so `devup_ui_validate` was rejecting real +//! devup-ui code as `unknown-prop`. `devup_ui_validate`'s `unknown-prop` +//! rule checks JSX attributes on the primitive elements it recognizes +//! (`Box`, `Flex`, `Text`, `Center`, `Grid`, `Image`) against this list plus +//! a small set of standard React/HTML/devup-ui-specific non-style props; +//! anything else is flagged rather than guessed at. +//! +//! `DEVUP_COLOR_LIKE_PROPS` / `DEVUP_LENGTH_LIKE_PROPS` are the subsets +//! whose CSS-property counterpart is a single color or length value; they +//! drive the `hardcoded-color` / `hardcoded-length` / `unknown-token` +//! rules. Deliberately conservative: composite props like `background` +//! (can hold a gradient) or `border` (shorthand for width+style+color) are +//! excluded from both subsets rather than guessed at, since flagging them +//! incorrectly would repeat exactly the fabrication failure this tool +//! exists to prevent. + +/// devup-ui primitive components whose JSX props this validator checks +/// against [`DEVUP_STYLE_PROPS`] for the `unknown-prop` rule. Custom +/// component names are never flagged: unlike these primitives, a custom +/// component's valid prop set cannot be known statically from devup-ui's +/// public docs, so guessing which props it accepts would risk exactly the +/// kind of invented-fact failure this tool exists to prevent. +pub const DEVUP_PRIMITIVE_ELEMENTS: &[&str] = &["Box", "Flex", "Text", "Center", "Grid", "Image"]; + +/// Non-style props every devup-ui primitive additionally accepts: standard +/// React/HTML attributes, event handlers, and devup-ui-specific structural +/// props (`as`, `selectors`). Checked case-sensitively against the exact +/// attribute name; `data-*`/`aria-*` and pseudo-state (`_hover`, `_dark`, +/// ...) / responsive-condition props are matched by prefix separately in +/// `is_known_non_style_prop`. +const DEVUP_NON_STYLE_PROPS: &[&str] = &[ + "as", + "selectors", + "children", + "className", + "style", + "id", + "key", + "ref", + "role", + "tabIndex", + "title", + "htmlFor", + "for", + "colSpan", + "rowSpan", + "type", + "name", + "value", + "defaultValue", + "placeholder", + "disabled", + "checked", + "defaultChecked", + "readOnly", + "required", + "min", + "max", + "step", + "rows", + "cols", + "src", + "srcSet", + "alt", + "sizes", + "loading", + "decoding", + "href", + "target", + "rel", + "download", + "autoFocus", + "autoComplete", + "form", + "multiple", + "accept", + "list", + "pattern", + "spellCheck", + "draggable", + "contentEditable", + "suppressHydrationWarning", +]; + +/// Returns true for props no primitive-specific check should ever flag: +/// standard React/HTML attributes, `on*` event handlers, `data-*`/`aria-*`, +/// and devup-ui pseudo-state / responsive-condition props (which are +/// user-defined selector keys, not a fixed enumerable set). +pub fn is_known_non_style_prop(name: &str) -> bool { + DEVUP_NON_STYLE_PROPS.contains(&name) + || name.starts_with("on") + || name.starts_with("data-") + || name.starts_with("aria-") + || name.starts_with('_') +} + +pub fn is_known_style_prop(name: &str) -> bool { + DEVUP_STYLE_PROPS.binary_search(&name).is_ok() +} + +pub fn is_color_like_prop(name: &str) -> bool { + DEVUP_COLOR_LIKE_PROPS.binary_search(&name).is_ok() +} + +pub fn is_length_like_prop(name: &str) -> bool { + DEVUP_LENGTH_LIKE_PROPS.binary_search(&name).is_ok() +} + +pub const DEVUP_STYLE_PROPS: &[&str] = &[ + "MozAppearance", + "MozBinding", + "MozBorderBottomColors", + "MozBorderLeftColors", + "MozBorderRightColors", + "MozBorderTopColors", + "MozBoxAlign", + "MozBoxDirection", + "MozBoxFlex", + "MozBoxFlexGroup", + "MozBoxLines", + "MozBoxOrdinalGroup", + "MozBoxOrient", + "MozBoxPack", + "MozContextProperties", + "MozFloatEdge", + "MozForceBrokenImageIcon", + "MozOrient", + "MozOutlineRadius", + "MozOutlineRadiusBottomleft", + "MozOutlineRadiusBottomright", + "MozOutlineRadiusTopleft", + "MozOutlineRadiusTopright", + "MozOverflowClipBox", + "MozStackSizing", + "MozTextBlink", + "MozUserFocus", + "MozUserInput", + "MozUserModify", + "MozWindowDragging", + "MozWindowShadow", + "WebkitAppearance", + "WebkitBorderAfter", + "WebkitBorderAfterColor", + "WebkitBorderAfterStyle", + "WebkitBorderAfterWidth", + "WebkitBorderBefore", + "WebkitBorderBeforeColor", + "WebkitBorderBeforeStyle", + "WebkitBorderBeforeWidth", + "WebkitBorderEnd", + "WebkitBorderEndColor", + "WebkitBorderEndStyle", + "WebkitBorderEndWidth", + "WebkitBorderStart", + "WebkitBorderStartColor", + "WebkitBorderStartStyle", + "WebkitBorderStartWidth", + "WebkitBoxAlign", + "WebkitBoxDirection", + "WebkitBoxFlex", + "WebkitBoxFlexGroup", + "WebkitBoxLines", + "WebkitBoxOrdinalGroup", + "WebkitBoxOrient", + "WebkitBoxPack", + "WebkitBoxReflect", + "WebkitLineClamp", + "WebkitMask", + "WebkitMaskAttachment", + "WebkitMaskClip", + "WebkitMaskComposite", + "WebkitMaskImage", + "WebkitMaskOrigin", + "WebkitMaskPosition", + "WebkitMaskPositionX", + "WebkitMaskPositionY", + "WebkitMaskRepeat", + "WebkitMaskRepeatX", + "WebkitMaskRepeatY", + "WebkitMaskSize", + "WebkitOverflowScrolling", + "WebkitTapHighlightColor", + "WebkitTextFillColor", + "WebkitTextStroke", + "WebkitTextStrokeColor", + "WebkitTextStrokeWidth", + "WebkitTouchCallout", + "WebkitUserModify", + "WebkitUserSelect", + "accentColor", + "alignContent", + "alignItems", + "alignSelf", + "alignTracks", + "alignmentBaseline", + "all", + "anchorName", + "anchorScope", + "animation", + "animationComposition", + "animationDelay", + "animationDir", + "animationDirection", + "animationDuration", + "animationFillMode", + "animationIterationCount", + "animationName", + "animationPlayState", + "animationRange", + "animationRangeEnd", + "animationRangeStart", + "animationTimeline", + "animationTimingFunction", + "animationTrigger", + "appearance", + "aspectRatio", + "backdropFilter", + "backfaceVisibility", + "background", + "backgroundAttachment", + "backgroundBlendMode", + "backgroundClip", + "backgroundColor", + "backgroundImage", + "backgroundImg", + "backgroundOrigin", + "backgroundPosition", + "backgroundPositionX", + "backgroundPositionY", + "backgroundRepeat", + "backgroundSize", + "baselineShift", + "baselineSource", + "bg", + "bgAttachment", + "bgBlendMode", + "bgClip", + "bgColor", + "bgImage", + "bgImg", + "bgOrigin", + "bgPos", + "bgPosX", + "bgPosY", + "bgPosition", + "bgPositionX", + "bgPositionY", + "bgRepeat", + "bgSize", + "blockSize", + "border", + "borderBlock", + "borderBlockColor", + "borderBlockEnd", + "borderBlockEndColor", + "borderBlockEndStyle", + "borderBlockEndWidth", + "borderBlockStart", + "borderBlockStartColor", + "borderBlockStartStyle", + "borderBlockStartWidth", + "borderBlockStyle", + "borderBlockWidth", + "borderBottom", + "borderBottomColor", + "borderBottomLeftRadius", + "borderBottomRadius", + "borderBottomRightRadius", + "borderBottomStyle", + "borderBottomWidth", + "borderCollapse", + "borderColor", + "borderEndEndRadius", + "borderEndStartRadius", + "borderImage", + "borderImageOutset", + "borderImageRepeat", + "borderImageSlice", + "borderImageSource", + "borderImageWidth", + "borderInline", + "borderInlineColor", + "borderInlineEnd", + "borderInlineEndColor", + "borderInlineEndStyle", + "borderInlineEndWidth", + "borderInlineStart", + "borderInlineStartColor", + "borderInlineStartStyle", + "borderInlineStartWidth", + "borderInlineStyle", + "borderInlineWidth", + "borderLeft", + "borderLeftColor", + "borderLeftRadius", + "borderLeftStyle", + "borderLeftWidth", + "borderRadius", + "borderRight", + "borderRightColor", + "borderRightRadius", + "borderRightStyle", + "borderRightWidth", + "borderShape", + "borderSpacing", + "borderStartEndRadius", + "borderStartStartRadius", + "borderStyle", + "borderTop", + "borderTopColor", + "borderTopLeftRadius", + "borderTopRadius", + "borderTopRightRadius", + "borderTopStyle", + "borderTopWidth", + "borderWidth", + "bottom", + "boxAlign", + "boxDecorationBreak", + "boxDirection", + "boxFlex", + "boxFlexGroup", + "boxLines", + "boxOrdinalGroup", + "boxOrient", + "boxPack", + "boxShadow", + "boxSize", + "boxSizing", + "breakAfter", + "breakBefore", + "breakInside", + "captionSide", + "caret", + "caretAnimation", + "caretColor", + "caretShape", + "clear", + "clip", + "clipPath", + "clipRule", + "color", + "colorInterpolationFilters", + "colorScheme", + "columnCount", + "columnFill", + "columnGap", + "columnHeight", + "columnRule", + "columnRuleColor", + "columnRuleStyle", + "columnRuleWidth", + "columnSpan", + "columnWidth", + "columnWrap", + "columns", + "contain", + "containIntrinsicBlockSize", + "containIntrinsicHeight", + "containIntrinsicInlineSize", + "containIntrinsicSize", + "containIntrinsicWidth", + "container", + "containerName", + "containerType", + "content", + "contentVisibility", + "cornerBlockEndShape", + "cornerBlockStartShape", + "cornerBottomLeftShape", + "cornerBottomRightShape", + "cornerBottomShape", + "cornerEndEndShape", + "cornerEndStartShape", + "cornerInlineEndShape", + "cornerInlineStartShape", + "cornerLeftShape", + "cornerRightShape", + "cornerShape", + "cornerStartEndShape", + "cornerStartStartShape", + "cornerTopLeftShape", + "cornerTopRightShape", + "cornerTopShape", + "counterIncrement", + "counterReset", + "counterSet", + "cursor", + "cx", + "cy", + "d", + "direction", + "display", + "dominantBaseline", + "dynamicRangeLimit", + "emptyCells", + "fieldSizing", + "fill", + "fillOpacity", + "fillRule", + "filter", + "flex", + "flexBasis", + "flexDir", + "flexDirection", + "flexFlow", + "flexGrow", + "flexLineCount", + "flexShrink", + "flexWrap", + "float", + "floodColor", + "floodOpacity", + "font", + "fontFamily", + "fontFeatureSettings", + "fontKerning", + "fontLanguageOverride", + "fontOpticalSizing", + "fontPalette", + "fontSize", + "fontSizeAdjust", + "fontSmooth", + "fontStretch", + "fontStyle", + "fontSynthesis", + "fontSynthesisPosition", + "fontSynthesisSmallCaps", + "fontSynthesisStyle", + "fontSynthesisWeight", + "fontVariant", + "fontVariantAlternates", + "fontVariantCaps", + "fontVariantEastAsian", + "fontVariantEmoji", + "fontVariantLigatures", + "fontVariantNumeric", + "fontVariantPosition", + "fontVariationSettings", + "fontWeight", + "fontWidth", + "forcedColorAdjust", + "frameSizing", + "gap", + "grid", + "gridArea", + "gridAutoColumns", + "gridAutoFlow", + "gridAutoRows", + "gridColumn", + "gridColumnEnd", + "gridColumnGap", + "gridColumnStart", + "gridGap", + "gridRow", + "gridRowEnd", + "gridRowGap", + "gridRowStart", + "gridTemplate", + "gridTemplateAreas", + "gridTemplateColumns", + "gridTemplateRows", + "h", + "hangingPunctuation", + "height", + "hyphenateCharacter", + "hyphenateLimitChars", + "hyphens", + "imageOrientation", + "imageRendering", + "imageResolution", + "imeMode", + "initialLetter", + "initialLetterAlign", + "inlineSize", + "inset", + "insetBlock", + "insetBlockEnd", + "insetBlockStart", + "insetInline", + "insetInlineEnd", + "insetInlineStart", + "interactivity", + "interestDelay", + "interestDelayEnd", + "interestDelayStart", + "interpolateSize", + "isolation", + "justifyContent", + "justifyItems", + "justifySelf", + "justifyTracks", + "left", + "letterSpacing", + "lightingColor", + "lineBreak", + "lineClamp", + "lineHeight", + "lineHeightStep", + "linkParameters", + "listStyle", + "listStyleImage", + "listStylePosition", + "listStyleType", + "m", + "margin", + "marginBlock", + "marginBlockEnd", + "marginBlockStart", + "marginBottom", + "marginInline", + "marginInlineEnd", + "marginInlineStart", + "marginLeft", + "marginRight", + "marginTop", + "marginTrim", + "marker", + "markerEnd", + "markerMid", + "markerStart", + "mask", + "maskBorder", + "maskBorderMode", + "maskBorderOutset", + "maskBorderRepeat", + "maskBorderSlice", + "maskBorderSource", + "maskBorderWidth", + "maskClip", + "maskComposite", + "maskImage", + "maskImg", + "maskMode", + "maskOrigin", + "maskPos", + "maskPosition", + "maskRepeat", + "maskSize", + "maskType", + "masonryAutoFlow", + "mathDepth", + "mathShift", + "mathStyle", + "maxBlockSize", + "maxH", + "maxHeight", + "maxInlineSize", + "maxLines", + "maxW", + "maxWidth", + "mb", + "minBlockSize", + "minH", + "minHeight", + "minInlineSize", + "minW", + "minWidth", + "mixBlendMode", + "ml", + "mr", + "msAccelerator", + "msBlockProgression", + "msContentZoomChaining", + "msContentZoomLimit", + "msContentZoomLimitMax", + "msContentZoomLimitMin", + "msContentZoomSnap", + "msContentZoomSnapPoints", + "msContentZoomSnapType", + "msContentZooming", + "msFilter", + "msFlowFrom", + "msFlowInto", + "msGridColumns", + "msGridRows", + "msHighContrastAdjust", + "msHyphenateLimitChars", + "msHyphenateLimitLines", + "msHyphenateLimitZone", + "msImeAlign", + "msOverflowStyle", + "msScrollChaining", + "msScrollLimit", + "msScrollLimitXMax", + "msScrollLimitXMin", + "msScrollLimitYMax", + "msScrollLimitYMin", + "msScrollRails", + "msScrollSnapPointsX", + "msScrollSnapPointsY", + "msScrollSnapType", + "msScrollSnapX", + "msScrollSnapY", + "msScrollTranslation", + "msScrollbar3dlightColor", + "msScrollbarArrowColor", + "msScrollbarBaseColor", + "msScrollbarDarkshadowColor", + "msScrollbarFaceColor", + "msScrollbarHighlightColor", + "msScrollbarShadowColor", + "msScrollbarTrackColor", + "msTextAutospace", + "msTouchSelect", + "msUserSelect", + "msWrapFlow", + "msWrapMargin", + "msWrapThrough", + "mt", + "mx", + "my", + "objectFit", + "objectPos", + "objectPosition", + "objectViewBox", + "offset", + "offsetAnchor", + "offsetDistance", + "offsetPath", + "offsetPos", + "offsetPosition", + "offsetRotate", + "opacity", + "order", + "orphans", + "outline", + "outlineColor", + "outlineOffset", + "outlineStyle", + "outlineWidth", + "overflow", + "overflowAnchor", + "overflowBlock", + "overflowClipBox", + "overflowClipMargin", + "overflowInline", + "overflowWrap", + "overflowX", + "overflowY", + "overlay", + "overscrollBehavior", + "overscrollBehaviorBlock", + "overscrollBehaviorInline", + "overscrollBehaviorX", + "overscrollBehaviorY", + "p", + "padding", + "paddingBlock", + "paddingBlockEnd", + "paddingBlockStart", + "paddingBottom", + "paddingInline", + "paddingInlineEnd", + "paddingInlineStart", + "paddingLeft", + "paddingRight", + "paddingTop", + "page", + "pageBreakAfter", + "pageBreakBefore", + "pageBreakInside", + "paintOrder", + "pathLength", + "pb", + "perspective", + "perspectiveOrigin", + "pl", + "placeContent", + "placeItems", + "placeSelf", + "pointerEvents", + "pos", + "position", + "positionAnchor", + "positionArea", + "positionTry", + "positionTryFallbacks", + "positionTryOrder", + "positionVisibility", + "positioning", + "pr", + "printColorAdjust", + "pt", + "px", + "py", + "quotes", + "r", + "readingFlow", + "readingOrder", + "resize", + "right", + "rotate", + "rowGap", + "rubyAlign", + "rubyMerge", + "rubyOverhang", + "rubyPosition", + "rx", + "ry", + "scale", + "scrollBehavior", + "scrollInitialTarget", + "scrollMargin", + "scrollMarginBlock", + "scrollMarginBlockEnd", + "scrollMarginBlockStart", + "scrollMarginBottom", + "scrollMarginInline", + "scrollMarginInlineEnd", + "scrollMarginInlineStart", + "scrollMarginLeft", + "scrollMarginRight", + "scrollMarginTop", + "scrollMarkerGroup", + "scrollPadding", + "scrollPaddingBlock", + "scrollPaddingBlockEnd", + "scrollPaddingBlockStart", + "scrollPaddingBottom", + "scrollPaddingInline", + "scrollPaddingInlineEnd", + "scrollPaddingInlineStart", + "scrollPaddingLeft", + "scrollPaddingRight", + "scrollPaddingTop", + "scrollSnapAlign", + "scrollSnapCoordinate", + "scrollSnapDestination", + "scrollSnapPointsX", + "scrollSnapPointsY", + "scrollSnapStop", + "scrollSnapType", + "scrollSnapTypeX", + "scrollSnapTypeY", + "scrollTargetGroup", + "scrollTimeline", + "scrollTimelineAxis", + "scrollTimelineName", + "scrollbarColor", + "scrollbarGutter", + "scrollbarWidth", + "shapeImageThreshold", + "shapeMargin", + "shapeOutside", + "shapeRendering", + "speakAs", + "stopColor", + "stopOpacity", + "stroke", + "strokeColor", + "strokeDasharray", + "strokeDashoffset", + "strokeLinecap", + "strokeLinejoin", + "strokeMiterlimit", + "strokeOpacity", + "strokeWidth", + "tabSize", + "tableLayout", + "textAlign", + "textAlignLast", + "textAnchor", + "textAutospace", + "textBox", + "textBoxEdge", + "textBoxTrim", + "textCombineUpright", + "textDecoration", + "textDecorationColor", + "textDecorationInset", + "textDecorationLine", + "textDecorationSkip", + "textDecorationSkipInk", + "textDecorationStyle", + "textDecorationThickness", + "textEmphasis", + "textEmphasisColor", + "textEmphasisPosition", + "textEmphasisStyle", + "textFit", + "textIndent", + "textJustify", + "textOrientation", + "textOverflow", + "textRendering", + "textShadow", + "textSizeAdjust", + "textSpacingTrim", + "textTransform", + "textUnderlineOffset", + "textUnderlinePosition", + "textWrap", + "textWrapMode", + "textWrapStyle", + "timelineScope", + "timelineTrigger", + "timelineTriggerActiveRange", + "timelineTriggerName", + "timelineTriggerSource", + "top", + "touchAction", + "transform", + "transformBox", + "transformOrigin", + "transformStyle", + "transition", + "transitionBehavior", + "transitionDelay", + "transitionDuration", + "transitionProperty", + "transitionTimingFunction", + "translate", + "triggerScope", + "typography", + "unicodeBidi", + "userSelect", + "vectorEffect", + "verticalAlign", + "viewTimeline", + "viewTimelineAxis", + "viewTimelineInset", + "viewTimelineName", + "viewTransitionClass", + "viewTransitionName", + "viewTransitionScope", + "visibility", + "w", + "whiteSpace", + "whiteSpaceCollapse", + "widows", + "width", + "willChange", + "wordBreak", + "wordSpacing", + "wordWrap", + "writingMode", + "x", + "y", + "zIndex", + "zoom", +]; + +pub const DEVUP_COLOR_LIKE_PROPS: &[&str] = &[ + "accentColor", + "backgroundColor", + "bgColor", + "borderBottomColor", + "borderColor", + "borderLeftColor", + "borderRightColor", + "borderTopColor", + "caretColor", + "color", + "fill", + "outlineColor", + "scrollbarColor", + "stroke", + "textDecorationColor", + "textEmphasisColor", +]; + +pub const DEVUP_LENGTH_LIKE_PROPS: &[&str] = &[ + "bgSize", + "borderBottomLeftRadius", + "borderBottomRightRadius", + "borderBottomWidth", + "borderLeftWidth", + "borderRadius", + "borderRightWidth", + "borderSpacing", + "borderTopLeftRadius", + "borderTopRightRadius", + "borderTopWidth", + "borderWidth", + "bottom", + "boxSize", + "columnGap", + "containIntrinsicBlockSize", + "containIntrinsicHeight", + "containIntrinsicInlineSize", + "containIntrinsicSize", + "containIntrinsicWidth", + "flexBasis", + "fontSize", + "gap", + "gridColumnGap", + "gridGap", + "gridRowGap", + "h", + "height", + "left", + "letterSpacing", + "lineHeight", + "m", + "margin", + "marginBottom", + "marginLeft", + "marginRight", + "marginTop", + "maxH", + "maxHeight", + "maxW", + "maxWidth", + "mb", + "minH", + "minHeight", + "minW", + "minWidth", + "ml", + "mr", + "mt", + "mx", + "my", + "outlineOffset", + "outlineWidth", + "overflowClipMargin", + "p", + "padding", + "paddingBottom", + "paddingLeft", + "paddingRight", + "paddingTop", + "pb", + "pl", + "pr", + "pt", + "px", + "py", + "right", + "rowGap", + "shapeMargin", + "strokeWidth", + "tabSize", + "top", + "w", + "width", + "wordSpacing", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_prop_lists_are_sorted_for_binary_search() { + let mut sorted = DEVUP_STYLE_PROPS.to_vec(); + sorted.sort_unstable(); + assert_eq!(DEVUP_STYLE_PROPS, sorted.as_slice()); + let mut colors = DEVUP_COLOR_LIKE_PROPS.to_vec(); + colors.sort_unstable(); + assert_eq!(DEVUP_COLOR_LIKE_PROPS, colors.as_slice()); + let mut lengths = DEVUP_LENGTH_LIKE_PROPS.to_vec(); + lengths.sort_unstable(); + assert_eq!(DEVUP_LENGTH_LIKE_PROPS, lengths.as_slice()); + } + + #[test] + fn color_and_length_subsets_are_subsets_of_style_props() { + for prop in DEVUP_COLOR_LIKE_PROPS { + assert!(is_known_style_prop(prop), "{prop} missing from style props"); + } + for prop in DEVUP_LENGTH_LIKE_PROPS { + assert!(is_known_style_prop(prop), "{prop} missing from style props"); + } + } + + #[test] + fn recognizes_known_and_rejects_unknown_props() { + assert!(is_known_style_prop("bg")); + assert!(is_known_style_prop("borderRadius")); + assert!(!is_known_style_prop("bgg")); + assert!(is_color_like_prop("bgColor")); + assert!(!is_color_like_prop("bg")); + assert!(is_length_like_prop("w")); + assert!(is_known_non_style_prop("onClick")); + assert!(is_known_non_style_prop("data-testid")); + assert!(is_known_non_style_prop("_hover")); + assert!(is_known_non_style_prop("as")); + } +} diff --git a/crates/devup-mcp-devup-ui/src/theme/devup_json.rs b/crates/devup-mcp-devup-ui/src/theme/devup_json.rs index e46ea937..6e1a53f9 100644 --- a/crates/devup-mcp-devup-ui/src/theme/devup_json.rs +++ b/crates/devup-mcp-devup-ui/src/theme/devup_json.rs @@ -6,7 +6,7 @@ use sha2::{Digest, Sha256}; use devup_mcp_figma::{DevupError, Diagnostic, DiagnosticSeverity, ErrorCode, UpstreamResult}; -use super::tokens::{normalize_token, variable_token}; +use super::tokens::{normalize_token, style_token, variable_token}; use crate::provenance::{ProvenanceEntry, SourceMap, json_pointer_segment}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -233,7 +233,7 @@ pub fn generate_devup_json( }); diagnostics.push(Diagnostic { code: "DEVUP_THEME_COLLECTION_MISSING".to_owned(), - message: format!("변수 '{}'의 collection을 찾지 못했습니다.", variable.name), + message: format!("Collection for variable '{}' was not found.", variable.name), node_id: None, severity: Some(DiagnosticSeverity::Warning), resource_kind: Some("variable".to_owned()), @@ -259,7 +259,7 @@ pub fn generate_devup_json( diagnostics.push(Diagnostic { code: "DEVUP_THEME_ALIAS_CYCLE".to_owned(), message: format!( - "변수 '{}'의 alias를 안전하게 해석하지 못했습니다.", + "Alias for variable '{}' could not be resolved safely.", variable.name ), node_id: None, @@ -371,7 +371,7 @@ pub fn generate_devup_json( diagnostics.push(Diagnostic { code: "DEVUP_THEME_TOKEN_CONFLICT".to_owned(), message: format!( - "동일한 theme token에 서로 다른 값이 있어 결정적 우선순위를 적용했습니다: token={token}, mode={mode}" + "The same theme token had conflicting values; applied deterministic precedence: token={token}, mode={mode}" ), node_id: None, severity: Some(DiagnosticSeverity::Warning), @@ -384,8 +384,28 @@ pub fn generate_devup_json( }); } - let mut typography = BTreeMap::new(); - let mut shadows = BTreeMap::new(); + // A text style is a `typography` entry and an effect style a `shadow` + // entry, each in the shape devup-ui reads and under the name the plugin's + // `styleNameToTypography` gives it. A style named for a breakpoint — + // `desktop/h1`, `3/bodyXlgBold` — is one slot of a responsive entry, and + // the slots of one name are gathered into an array. The raw style object + // used to be written as it came, and devup-ui's own plugin refused the + // file: `Invalid typography property value: Object {"unit": "PERCENT", + // "value": 120}`. + let mut typography_slots: BTreeMap; 6]> = BTreeMap::new(); + let mut shadow_slots: BTreeMap; 6]> = BTreeMap::new(); + let variable_names = variables + .values() + .map(|variable| { + ( + variable.id.as_str(), + variable_token( + &variable.name, + variable.code_syntax.get("WEB").map(String::as_str), + ), + ) + }) + .collect::>(); let selected_style_ids = (scope != ThemeScope::File && !snapshot.used_style_ids.is_empty()) .then(|| snapshot.used_style_ids.iter().collect::>()); let styles = snapshot @@ -398,10 +418,15 @@ pub fn generate_devup_json( }) .collect::>(); for style in &styles { - let token = normalize_token(&style.name); + let (level, token) = style_token(&style.name); + let level = level.min(5); match style.style_type.as_str() { "TEXT" => { - typography.insert(token.clone(), style.value.clone()); + let slots = typography_slots.entry(token.clone()).or_default(); + // The first style seen for a slot keeps it, as in the plugin. + if slots[level].is_none() { + slots[level] = Some(typography_value(&style.value, &variable_names)); + } source_entries.push(ProvenanceEntry { generated_range: None, json_pointer: Some(format!( @@ -417,7 +442,13 @@ pub fn generate_devup_json( }); } "EFFECT" => { - shadows.insert(token.clone(), style.value.clone()); + let Some(shadow) = shadow_value(&style.value) else { + continue; + }; + let slots = shadow_slots.entry(token.clone()).or_default(); + if slots[level].is_none() { + slots[level] = Some(shadow); + } source_entries.push(ProvenanceEntry { generated_range: None, json_pointer: Some(format!( @@ -435,18 +466,101 @@ pub fn generate_devup_json( _ => {} } } - + let typography = typography_slots + .into_iter() + .filter_map(|(token, slots)| responsive_entry(slots).map(|entry| (token, entry))) + .collect::>(); + let shadows = shadow_slots + .into_iter() + .filter_map(|(token, slots)| { + responsive_entry(slots.map(|slot| slot.map(Value::String))).map(|entry| (token, entry)) + }) + .collect::>(); + + // devup-ui takes the first theme written under `colors` as the default — + // the one in effect without a `data-theme` — so the order of the modes is + // not cosmetic. Sorted by name, `dark` came before `light`, and a file + // whose default mode is Light was handed to consumers dark. The + // collections' default modes go first, then the rest of their modes in + // the order the collections declare them, as the plugin writes them. + let mode_order = { + let mut order: Vec = Vec::new(); + let mut push = |name: String| { + if !order.contains(&name) { + order.push(name); + } + }; + for collection in collections.values() { + if let Some(default) = collection + .modes + .iter() + .find(|mode| mode.mode_id == collection.default_mode_id) + { + push(normalize_token(&default.name)); + } + } + for collection in collections.values() { + for mode in &collection.modes { + push(normalize_token(&mode.name)); + } + } + order + }; + let in_mode_order = |modes: BTreeMap>| { + let mut ordered = Map::new(); + for name in &mode_order { + if let Some(tokens) = modes.get(name) { + ordered.insert(name.clone(), json!(tokens)); + } + } + for (name, tokens) in modes { + if !ordered.contains_key(&name) { + ordered.insert(name, json!(tokens)); + } + } + Value::Object(ordered) + }; + let colors = in_mode_order(colors); + // Lengths and shadows do not vary by colour theme, but devup-ui keys them + // by one, so each colour theme gets a copy — the plugin replicates them + // the same way. A file with no colour themes keeps `default`. + let theme_names = colors + .as_object() + .map(|themes| themes.keys().cloned().collect::>()) + .filter(|names| !names.is_empty()) + .unwrap_or_else(|| vec!["default".to_owned()]); + let lengths = { + let ordered = in_mode_order(lengths); + let mut by_theme = ordered.as_object().cloned().unwrap_or_default(); + let first = by_theme.values().next().cloned(); + let mut replicated = Map::new(); + for name in &theme_names { + if let Some(tokens) = by_theme.remove(name) { + replicated.insert(name.clone(), tokens); + } else if let Some(first) = &first { + replicated.insert(name.clone(), first.clone()); + } + } + for (name, tokens) in by_theme { + replicated.insert(name, tokens); + } + Value::Object(replicated) + }; + let shadows = theme_names + .iter() + .map(|name| (name.clone(), Value::Object(shadows.clone()))) + .collect::>(); let mut theme = Map::new(); - theme.insert("colors".to_owned(), json!(colors)); - theme.insert("typography".to_owned(), json!(typography)); - theme.insert("length".to_owned(), json!(lengths)); - theme.insert("shadow".to_owned(), json!({ "default": shadows })); + theme.insert("colors".to_owned(), colors); + theme.insert("typography".to_owned(), Value::Object(typography)); + theme.insert("length".to_owned(), lengths); + theme.insert("shadow".to_owned(), Value::Object(shadows)); let mut root = Map::new(); root.insert("theme".to_owned(), Value::Object(theme)); let mut output = serde_json::to_string_pretty(&Value::Object(root)).map_err(|_| { DevupError::new( ErrorCode::DevupThemeConflict, - "devup.json을 직렬화하지 못했습니다.", + "Failed to serialize devup.json.", false, ) })?; @@ -490,6 +604,209 @@ pub fn generate_devup_json( }) } +/// A text style's value as devup-ui reads it — the plugin's +/// `textStyleToTypography`. +/// +/// `fontSize` is in pixels, `lineHeight` a ratio from a percentage (`120%` +/// is `1.2`), pixels as they are, and `normal` when Figma sets it +/// automatically; `letterSpacing` is `em` from a percentage and pixels as +/// they are; the weight is read off the font style's name; and a field the +/// style binds to a variable is that variable's token. Two departures from +/// the plugin, on purpose: `Bold Italic` is `700` and italic, where the +/// plugin reads the weight off the whole style name and gets `400`; and a +/// text case is the CSS `text-transform` value — `uppercase`, not `upper`. +fn typography_value(style: &Value, variable_names: &BTreeMap<&str, String>) -> Value { + let mut entry = Map::new(); + let font = style.get("fontName"); + let family = font + .and_then(|font| font.get("family")) + .and_then(Value::as_str); + let face = font + .and_then(|font| font.get("style")) + .and_then(Value::as_str) + .unwrap_or("Regular"); + if let Some(family) = family { + entry.insert("fontFamily".to_owned(), Value::String(family.to_owned())); + } + if face.contains("Italic") { + entry.insert("fontStyle".to_owned(), Value::String("italic".to_owned())); + } + entry.insert("fontWeight".to_owned(), Value::from(font_weight(face))); + if let Some(size) = style.get("fontSize").and_then(Value::as_f64) { + entry.insert("fontSize".to_owned(), Value::String(format_px(size))); + } + match style.get("textDecoration").and_then(Value::as_str) { + Some("UNDERLINE") => { + entry.insert( + "textDecoration".to_owned(), + Value::String("underline".to_owned()), + ); + } + Some("STRIKETHROUGH") => { + entry.insert( + "textDecoration".to_owned(), + Value::String("line-through".to_owned()), + ); + } + _ => {} + } + let transform = match style.get("textCase").and_then(Value::as_str) { + Some("UPPER") => Some("uppercase"), + Some("LOWER") => Some("lowercase"), + Some("TITLE") => Some("capitalize"), + _ => None, + }; + if let Some(transform) = transform { + entry.insert( + "textTransform".to_owned(), + Value::String(transform.to_owned()), + ); + } + if let Some(line_height) = style.get("lineHeight") { + let unit = line_height.get("unit").and_then(Value::as_str); + let value = line_height.get("value").and_then(Value::as_f64); + let written = match (unit, value) { + (Some("AUTO"), _) => Some(Value::String("normal".to_owned())), + (Some("PERCENT"), Some(percent)) => Some(Value::from((percent / 10.0).round() / 10.0)), + (Some(_), Some(pixels)) => Some(Value::String(format_px(pixels))), + _ => None, + }; + if let Some(written) = written { + entry.insert("lineHeight".to_owned(), written); + } + } + if let Some(spacing) = style.get("letterSpacing") { + let unit = spacing.get("unit").and_then(Value::as_str); + let value = spacing.get("value").and_then(Value::as_f64); + let written = match (unit, value) { + (Some("PERCENT"), Some(percent)) => Some(format!("{}em", percent.round() / 100.0)), + (Some(_), Some(pixels)) => Some(format_px(pixels)), + _ => None, + }; + if let Some(written) = written { + entry.insert("letterSpacing".to_owned(), Value::String(written)); + } + } + if let Some(bound) = style.get("boundVariables").and_then(Value::as_object) { + for field in [ + "fontFamily", + "fontSize", + "fontStyle", + "fontWeight", + "letterSpacing", + "lineHeight", + ] { + if let Some(token) = bound + .get(field) + .and_then(|alias| alias.get("id")) + .and_then(Value::as_str) + .and_then(|id| variable_names.get(id)) + { + entry.insert(field.to_owned(), Value::String(format!("${token}"))); + } + } + } + Value::Object(entry) +} + +/// The weight a font style's name means — the plugin's `getFontWeight`, +/// with `Italic` set aside first so `Bold Italic` is still bold. +fn font_weight(face: &str) -> u32 { + let name = face + .replace("Italic", "") + .replace([' ', '-', '_'], "") + .to_ascii_lowercase(); + match name.as_str() { + "thin" | "hairline" => 100, + "extralight" | "ultralight" => 200, + "light" => 300, + "" | "normal" | "regular" | "book" => 400, + "medium" => 500, + "semibold" | "demibold" => 600, + "bold" => 700, + "extrabold" | "ultrabold" => 800, + "black" | "heavy" => 900, + other => match other.parse::() { + Ok(number) if (1..=9).contains(&number) => number * 100, + Ok(number) => number, + Err(_) => 400, + }, + } +} + +/// An effect style's visible shadows as one CSS `box-shadow` — the plugin's +/// `effectStyleToCssShadow`. `None` when it casts none. +fn shadow_value(effects: &Value) -> Option { + let parts = effects + .as_array()? + .iter() + .filter(|effect| effect.get("visible").and_then(Value::as_bool) != Some(false)) + .filter_map(|effect| { + let kind = effect.get("type").and_then(Value::as_str)?; + let inset = match kind { + "DROP_SHADOW" => "", + "INNER_SHADOW" => "inset ", + _ => return None, + }; + let offset = effect.get("offset"); + let x = offset + .and_then(|offset| offset.get("x")) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let y = offset + .and_then(|offset| offset.get("y")) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let radius = effect.get("radius").and_then(Value::as_f64).unwrap_or(0.0); + let spread = effect.get("spread").and_then(Value::as_f64).unwrap_or(0.0); + let color = color_value(effect.get("color")?)?; + let length = |value: f64| { + if value == 0.0 { + "0".to_owned() + } else { + format_px(value) + } + }; + Some(format!( + "{inset}{} {} {} {} {color}", + length(x), + length(y), + length(radius), + length(spread) + )) + }) + .collect::>(); + (!parts.is_empty()).then(|| parts.join(", ")) +} + +/// One entry of `typography` or `shadow` from its six breakpoint slots — the +/// plugin's reduction: one slot filled is the value alone; the first slot +/// empty is filled with the first value there is, since a mobile-first array +/// has to start somewhere; and the trailing empty slots are dropped, which +/// the plugin does only for an array it had to fill. +fn responsive_entry(slots: [Option; 6]) -> Option { + let filled = slots.iter().filter(|slot| slot.is_some()).count(); + if filled == 0 { + return None; + } + if filled == 1 { + return slots.into_iter().flatten().next(); + } + let mut values = slots.to_vec(); + if values[0].is_none() { + values[0] = slots.iter().flatten().next().cloned(); + } + while values.last().is_some_and(Option::is_none) { + values.pop(); + } + Some(Value::Array( + values + .into_iter() + .map(|slot| slot.unwrap_or(Value::Null)) + .collect(), + )) +} + fn has_web_syntax(variable: &VariableDefinition) -> bool { variable .code_syntax @@ -540,17 +857,30 @@ pub fn variable_snapshot_from_result( find_variable_snapshot(&result.raw).ok_or_else(|| { DevupError::new( ErrorCode::DevupThemeConflict, - "Figma MCP 응답에서 변수 snapshot을 찾지 못했습니다.", + "Variable snapshot was not found in the Figma MCP response.", false, ) }) } +/// Whether a value is a variable snapshot by its own shape, rather than by +/// holding anything. A frame that binds no variable and uses no style still +/// answers with the snapshot's fields, all empty - and that is a theme with +/// nothing in it, not a failure to find one. All three fields have to be +/// there: an empty `VariableSnapshot` deserializes from almost any object, +/// and without this the search would seize on the first one it walked into. +fn names_a_variable_snapshot(value: &Value) -> bool { + ["collections", "variables", "styles"] + .into_iter() + .all(|field| value.get(field).is_some_and(Value::is_array)) +} + fn find_variable_snapshot(value: &Value) -> Option { if let Ok(snapshot) = serde_json::from_value::(value.clone()) && (!snapshot.collections.is_empty() || !snapshot.variables.is_empty() - || !snapshot.styles.is_empty()) + || !snapshot.styles.is_empty() + || names_a_variable_snapshot(value)) { return Some(snapshot); } diff --git a/crates/devup-mcp-devup-ui/src/theme/mod.rs b/crates/devup-mcp-devup-ui/src/theme/mod.rs index 17d0642a..3346bb9e 100644 --- a/crates/devup-mcp-devup-ui/src/theme/mod.rs +++ b/crates/devup-mcp-devup-ui/src/theme/mod.rs @@ -1,7 +1,8 @@ mod devup_json; +mod project_theme; mod tokens; -pub(crate) use tokens::{normalize_token, variable_token}; +pub(crate) use tokens::{style_token, variable_token}; pub use devup_json::{ Completeness, ThemeConflict, ThemeConflictCandidate, ThemeCounts, ThemeOutput, ThemeScope, @@ -9,3 +10,7 @@ pub use devup_json::{ VariableMode, VariableSnapshot, VariableStyle, generate_devup_json, variable_snapshot_from_result, }; +pub use project_theme::{ + ProjectTheme, TokenCategory, TokenEntry, closest_tokens, edit_distance, normalize_identifier, + parse_project_theme, +}; diff --git a/crates/devup-mcp-devup-ui/src/theme/project_theme.rs b/crates/devup-mcp-devup-ui/src/theme/project_theme.rs new file mode 100644 index 00000000..a2506543 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/theme/project_theme.rs @@ -0,0 +1,415 @@ +//! Reads an on-disk project `devup.json` — the file an application actually +//! ships, authored by hand or generated once by `devup_figma_to_json` — and +//! exposes the token names and resolved values it actually defines. +//! +//! This is deliberately a *different* type from [`super::VariableSnapshot`]: +//! `VariableSnapshot` is the raw Figma variable/style export this crate +//! projects *into* a `devup.json` string. [`ProjectTheme`] instead *reads +//! back* an already-materialized `devup.json` file so a caller (the +//! `devup_project_context` and `devup_ui_validate` MCP tools) can check +//! whether a `$token` an agent wants to use actually exists in the project, +//! instead of guessing. See `README.md`'s brief for the incident this +//! guards against: three agents independently invented `$gray100`, a +//! 16px bubble radius, and a 36px avatar size that did not exist in the +//! project's real `devup.json`. +//! +//! `devup.json`'s `theme.colors` / `theme.length` / `theme.shadow` are +//! conventionally mode-keyed (`{"default": {"primary": "#000"}, "dark": {...}}`, +//! matching [`super::generate_devup_json`]'s own output), but hand-authored +//! files sometimes flatten a single-mode theme directly to +//! `{"primary": "#000"}`. [`parse_project_theme`] accepts both shapes: +//! second-level values that are themselves JSON objects are treated as a +//! mode name containing tokens; scalar/array second-level values are +//! treated as tokens of an implicit `"default"` mode. + +use std::collections::BTreeMap; + +use devup_mcp_figma::{DevupError, ErrorCode}; +use serde_json::Value; + +use super::tokens::normalize_token; + +/// Which theme axis a token belongs to. Mirrors `devup.json`'s +/// `theme.{colors,typography,length,shadow}` keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TokenCategory { + Colors, + Typography, + Length, + Shadow, +} + +impl TokenCategory { + pub fn as_str(self) -> &'static str { + match self { + TokenCategory::Colors => "colors", + TokenCategory::Typography => "typography", + TokenCategory::Length => "length", + TokenCategory::Shadow => "shadow", + } + } +} + +/// A theme token's resolved value(s) across whichever modes define it. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenEntry { + pub category: TokenCategory, + /// mode name -> resolved value. Typography tokens (which `devup.json` + /// never mode-keys) use the single implicit mode `"default"`. + pub values_by_mode: BTreeMap, +} + +/// A project's `devup.json`, as actually read from disk: only the tokens it +/// defines, nothing inferred or assumed. +#[derive(Debug, Clone, Default)] +pub struct ProjectTheme { + /// mode -> token -> value + pub colors: BTreeMap>, + /// token -> value (devup.json never mode-keys typography) + pub typography: BTreeMap, + /// mode -> token -> value + pub length: BTreeMap>, + /// mode -> token -> value + pub shadow: BTreeMap>, +} + +impl ProjectTheme { + /// All mode names any category actually defines, sorted and deduplicated. + pub fn modes(&self) -> Vec { + let mut modes = self + .colors + .keys() + .chain(self.length.keys()) + .chain(self.shadow.keys()) + .cloned() + .collect::>(); + modes.sort(); + modes.dedup(); + modes + } + + /// A flat catalog of every token this theme defines, keyed by token + /// name, merged across categories. `devup_ui_validate` uses this to + /// check whether a referenced `$token` exists anywhere in the theme; + /// `devup_project_context` uses the per-category maps directly so it + /// can report which axis (`colors`/`typography`/`length`/`shadow`) a + /// token belongs to. + pub fn token_catalog(&self) -> BTreeMap { + let mut catalog = BTreeMap::new(); + for (mode, tokens) in &self.colors { + for (token, value) in tokens { + catalog + .entry(token.clone()) + .or_insert_with(|| TokenEntry { + category: TokenCategory::Colors, + values_by_mode: BTreeMap::new(), + }) + .values_by_mode + .insert(mode.clone(), value.clone()); + } + } + for (token, value) in &self.typography { + catalog + .entry(token.clone()) + .or_insert_with(|| TokenEntry { + category: TokenCategory::Typography, + values_by_mode: BTreeMap::new(), + }) + .values_by_mode + .insert("default".to_owned(), value.clone()); + } + for (mode, tokens) in &self.length { + for (token, value) in tokens { + catalog + .entry(token.clone()) + .or_insert_with(|| TokenEntry { + category: TokenCategory::Length, + values_by_mode: BTreeMap::new(), + }) + .values_by_mode + .insert(mode.clone(), value.clone()); + } + } + for (mode, tokens) in &self.shadow { + for (token, value) in tokens { + catalog + .entry(token.clone()) + .or_insert_with(|| TokenEntry { + category: TokenCategory::Shadow, + values_by_mode: BTreeMap::new(), + }) + .values_by_mode + .insert(mode.clone(), value.clone()); + } + } + catalog + } + + pub fn contains_token(&self, token: &str) -> bool { + self.colors + .values() + .any(|tokens| tokens.contains_key(token)) + || self.typography.contains_key(token) + || self + .length + .values() + .any(|tokens| tokens.contains_key(token)) + || self + .shadow + .values() + .any(|tokens| tokens.contains_key(token)) + } + + pub fn token_count(&self) -> usize { + self.token_catalog().len() + } + + /// Color tokens (any mode) whose resolved value normalizes to the same + /// hex string as `hex`. Used to suggest an existing token instead of a + /// hardcoded color. + pub fn color_tokens_matching_hex(&self, hex: &str) -> Vec { + let normalized = normalize_hex(hex); + let mut matches = self + .colors + .values() + .flat_map(|tokens| tokens.iter()) + .filter(|(_, value)| { + value + .as_str() + .is_some_and(|candidate| normalize_hex(candidate) == normalized) + }) + .map(|(token, _)| token.clone()) + .collect::>(); + matches.sort(); + matches.dedup(); + matches + } + + /// Length tokens (any mode) whose resolved value equals `px` (e.g. + /// `"16px"`) exactly as written. + pub fn length_tokens_matching_px(&self, px: &str) -> Vec { + let mut matches = self + .length + .values() + .flat_map(|tokens| tokens.iter()) + .filter(|(_, value)| value.as_str() == Some(px)) + .map(|(token, _)| token.clone()) + .collect::>(); + matches.sort(); + matches.dedup(); + matches + } +} + +fn normalize_hex(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +/// Parses a project's `devup.json` file content (the whole file, i.e. the +/// object with the top-level `theme` key) into a [`ProjectTheme`]. +/// +/// Never invents or assumes structure: a missing `theme` key, or a missing +/// category under it, simply yields an empty map for that category rather +/// than an error. Malformed JSON is the only parse failure. +pub fn parse_project_theme(source: &str) -> Result { + let root: Value = serde_json::from_str(source).map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "Failed to parse devup.json as JSON.", + false, + serde_json::json!({ "parseError": error.to_string() }), + ) + })?; + let theme = root.get("theme").cloned().unwrap_or(Value::Null); + Ok(ProjectTheme { + colors: parse_mode_keyed(theme.get("colors")), + typography: parse_flat(theme.get("typography")), + length: parse_mode_keyed(theme.get("length")), + shadow: parse_mode_keyed(theme.get("shadow")), + }) +} + +/// Parses a `theme.` value that is conventionally mode-keyed +/// (`{"default": {"token": value}}`) but tolerates a flattened single-mode +/// shape (`{"token": value}`) by treating it as the `"default"` mode. +/// Distinguishes the two shapes per top-level entry: an entry whose value is +/// itself a JSON object is treated as `mode -> tokens`; an entry whose value +/// is a scalar/array is treated as a token of the implicit `"default"` mode. +fn parse_mode_keyed(value: Option<&Value>) -> BTreeMap> { + let mut result = BTreeMap::>::new(); + let Some(Value::Object(entries)) = value else { + return result; + }; + for (key, entry) in entries { + match entry { + Value::Object(tokens) => { + let mode_tokens = result.entry(key.clone()).or_default(); + for (token, token_value) in tokens { + mode_tokens.insert(token.clone(), token_value.clone()); + } + } + other => { + result + .entry("default".to_owned()) + .or_default() + .insert(key.clone(), other.clone()); + } + } + } + result +} + +fn parse_flat(value: Option<&Value>) -> BTreeMap { + let Some(Value::Object(entries)) = value else { + return BTreeMap::new(); + }; + entries + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +/// Simple Levenshtein edit distance, used only to suggest the closest +/// existing token names for a `$token` that does not exist. Deliberately +/// unweighted (all edits cost 1): this is a "did you mean" hint, not a +/// scored ranking algorithm. +pub fn edit_distance(left: &str, right: &str) -> usize { + let left = left.chars().collect::>(); + let right = right.chars().collect::>(); + let mut previous_row = (0..=right.len()).collect::>(); + let mut current_row = vec![0usize; right.len() + 1]; + for (i, &left_char) in left.iter().enumerate() { + current_row[0] = i + 1; + for (j, &right_char) in right.iter().enumerate() { + let cost = usize::from(left_char != right_char); + current_row[j + 1] = (current_row[j] + 1) + .min(previous_row[j + 1] + 1) + .min(previous_row[j] + cost); + } + std::mem::swap(&mut previous_row, &mut current_row); + } + previous_row[right.len()] +} + +/// Returns up to `limit` token names from `catalog` closest to `query` by +/// edit distance, sorted by distance then name. Empty if `catalog` is empty. +pub fn closest_tokens<'a>( + query: &str, + catalog: impl Iterator, + limit: usize, +) -> Vec { + let mut scored = catalog + .map(|token| (edit_distance(query, token), token.clone())) + .collect::>(); + scored.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + scored + .into_iter() + .take(limit) + .map(|(_, token)| token) + .collect() +} + +/// Confirms [`normalize_token`] stays reachable for callers that need +/// devup.json-style token normalization alongside project-theme reading +/// (`devup_project_context`'s `api`/`db` scopes derive suggested +/// identifiers the same way theme tokens are named). +pub fn normalize_identifier(input: &str) -> String { + normalize_token(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_mode_keyed_colors_and_flat_typography() { + let source = r##"{ + "theme": { + "colors": { + "default": { "primary": "#111111", "background": "#ffffff" }, + "dark": { "primary": "#eeeeee", "background": "#000000" } + }, + "typography": { + "body1": { "fontSize": "14px", "lineHeight": "20px" } + }, + "length": { + "default": { "sm": "8px", "md": "16px" } + }, + "shadow": { + "default": { "card": "0 1px 2px rgba(0,0,0,0.1)" } + } + } + }"##; + let theme = parse_project_theme(source).expect("valid devup.json"); + assert_eq!( + theme.colors["default"]["primary"], + Value::String("#111111".to_owned()) + ); + assert_eq!( + theme.colors["dark"]["primary"], + Value::String("#eeeeee".to_owned()) + ); + assert!(theme.typography.contains_key("body1")); + assert_eq!( + theme.length["default"]["md"], + Value::String("16px".to_owned()) + ); + assert!(theme.contains_token("primary")); + assert!(theme.contains_token("md")); + assert!(!theme.contains_token("gray100")); + } + + #[test] + fn tolerates_flattened_single_mode_colors() { + let source = r##"{ "theme": { "colors": { "primary": "#111111" } } }"##; + let theme = parse_project_theme(source).expect("valid devup.json"); + assert_eq!( + theme.colors["default"]["primary"], + Value::String("#111111".to_owned()) + ); + } + + #[test] + fn missing_theme_key_yields_empty_categories_not_an_error() { + let theme = parse_project_theme("{}").expect("empty object is valid JSON"); + assert!(theme.colors.is_empty()); + assert!(theme.typography.is_empty()); + assert_eq!(theme.token_count(), 0); + } + + #[test] + fn rejects_malformed_json() { + let error = parse_project_theme("{ not json").unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + } + + #[test] + fn suggests_closest_tokens_by_edit_distance() { + let source = r##"{ "theme": { "colors": { "default": { + "captionLight": "#999999", "backgroundLight": "#fafafa", "primary": "#111111" + } } } }"##; + let theme = parse_project_theme(source).unwrap(); + let catalog = theme.token_catalog(); + let names = catalog.keys().collect::>(); + let suggestions = closest_tokens("gray100", names.into_iter(), 2); + assert_eq!(suggestions.len(), 2); + } + + #[test] + fn finds_color_tokens_matching_hardcoded_hex() { + let source = r##"{ "theme": { "colors": { "default": { "primary": "#FF0000" } } } }"##; + let theme = parse_project_theme(source).unwrap(); + assert_eq!(theme.color_tokens_matching_hex("#ff0000"), vec!["primary"]); + assert!(theme.color_tokens_matching_hex("#00ff00").is_empty()); + } + + #[test] + fn finds_length_tokens_matching_hardcoded_px() { + let source = r##"{ "theme": { "length": { "default": { "md": "16px" } } } }"##; + let theme = parse_project_theme(source).unwrap(); + assert_eq!(theme.length_tokens_matching_px("16px"), vec!["md"]); + assert!(theme.length_tokens_matching_px("17px").is_empty()); + } +} diff --git a/crates/devup-mcp-devup-ui/src/theme/tokens.rs b/crates/devup-mcp-devup-ui/src/theme/tokens.rs index 8dd0119e..634d85de 100644 --- a/crates/devup-mcp-devup-ui/src/theme/tokens.rs +++ b/crates/devup-mcp-devup-ui/src/theme/tokens.rs @@ -34,3 +34,48 @@ pub(crate) fn variable_token(name: &str, web_syntax: Option<&str>) -> String { } normalize_token(name.rsplit('/').next().unwrap_or(name)) } + +/// A style's token and the breakpoint it is for, from its name — the +/// plugin's `styleNameToTypography`. +/// +/// `desktop/h1`, `tablet/h1` and `mobile/h1` are `h1` at slots 4, 2 and 0, +/// and `3/bodyXlgBold` is `bodyXlgBold` at slot 3: a leading group that is a +/// breakpoint or a number says where the style applies, not what it is +/// called. Any other name is slot 0 as it is, with a group kept — the corpus +/// has `typography/heading` as `typographyHeading`. The same rule names the +/// `typography="…"` a text is given and the key `devup.json` defines it +/// under, so the two agree. +pub(crate) fn style_token(name: &str) -> (usize, String) { + let lower = name.to_ascii_lowercase(); + for (prefix, level) in [("desktop/", 4), ("tablet/", 2), ("mobile/", 0)] { + if lower.starts_with(prefix) { + return (level, normalize_token(&name[prefix.len()..])); + } + } + if let Some((group, rest)) = name.split_once('/') + && !rest.is_empty() + && let Ok(level) = group.trim().parse::() + { + return (level, normalize_token(rest)); + } + (0, normalize_token(name)) +} + +#[cfg(test)] +mod style_token_tests { + use super::style_token; + + #[test] + fn a_breakpoint_or_number_in_front_says_where_not_what() { + assert_eq!(style_token("desktop/h1"), (4, "h1".to_owned())); + assert_eq!(style_token("Tablet/H1"), (2, "h1".to_owned())); + assert_eq!(style_token("mobile/body lg"), (0, "bodyLg".to_owned())); + assert_eq!(style_token("3/bodyXlgBold"), (3, "bodyXlgBold".to_owned())); + assert_eq!(style_token("0/buttonSm"), (0, "buttonSm".to_owned())); + assert_eq!( + style_token("typography/heading"), + (0, "typographyHeading".to_owned()) + ); + assert_eq!(style_token("Heading/H1"), (0, "headingH1".to_owned())); + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate.rs b/crates/devup-mcp-devup-ui/src/ui_validate.rs new file mode 100644 index 00000000..9ab3f2a1 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -0,0 +1,587 @@ +//! `devup_ui_validate` — the highest-leverage of the three ground-truth +//! tools. Parses TSX with the same `oxc_parser`/`oxc_allocator`/`oxc_span` +//! stack already used to validate every generated TSX (`validation.rs`), +//! then walks the AST with `oxc_ast_visit::Visit` to catch the exact +//! failure class documented in this repository's brief: three agents +//! independently inventing `$gray100` (a color token that does not exist +//! in the project's real `devup.json`), a 16px bubble radius, and a 36px +//! avatar size, none traceable to any source of truth. +//! +//! Two facts verified against `@devup-ui/react`'s own docs and ESLint rule +//! (`css-utils-literal-only`) shape the rules here and deliberately +//! *narrow* what the brief's "런타임 값" wording might suggest: +//! +//! - JSX style props on `Box`/`Flex`/`Text`/... (`bg={dynamicValue}`) ARE +//! valid devup-ui: the compiler lowers them to a CSS custom property at +//! build time (`className="a" style={{"--a": dynamicValue}}`). Flagging +//! these as errors would itself be a fabricated rule. +//! - `css()`, `globalCss()`, and `keyframes()` utility calls are the actual +//! "must be statically analyzable" boundary — devup-ui's own +//! `css-utils-literal-only` ESLint rule rejects variables/expressions +//! there, because these calls are extracted at build time with no +//! runtime fallback. `runtime-value` therefore targets these three call +//! sites, not general JSX props. +//! +//! `unknown-token` / `hardcoded-color` / `hardcoded-length` operate on the +//! `Box`/`Flex`/`Text`/`Center`/`Grid`/`Image` primitives' known color- and +//! length-like props (`style_props.rs`, itself sourced from devup-ui's +//! published Style Props API reference, not invented). + +use std::collections::BTreeSet; + +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, JSXAttribute, JSXAttributeName, JSXAttributeValue, + JSXElementName, JSXOpeningElement, ObjectExpression, ObjectPropertyKind, PropertyKey, + UnaryOperator, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType, Span}; +use serde::Serialize; + +use crate::style_props::{ + DEVUP_PRIMITIVE_ELEMENTS, is_color_like_prop, is_known_non_style_prop, is_known_style_prop, + is_length_like_prop, +}; +use crate::theme::{ProjectTheme, closest_tokens}; + +/// Devup-ui `css`/`globalCss`/`keyframes` utility call names whose object +/// argument must be statically analyzable (devup-ui's own +/// `css-utils-literal-only` ESLint rule constraint). +const LITERAL_ONLY_CALLS: &[&str] = &["css", "globalCss", "keyframes"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Warning, + Error, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Violation { + pub rule: &'static str, + pub severity: Severity, + pub byte_range: [usize; 2], + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggestion: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UiValidation { + pub ok: bool, + pub violations: Vec, + pub checked_tokens: usize, + pub available_token_count: usize, +} + +/// Validates `tsx` against `theme` (a project's real `devup.json`, or +/// `None` if unavailable — in which case `unknown-token` is skipped rather +/// than guessed at; callers should surface `theme` unavailability to the +/// user separately, since silently skipping token checks is different from +/// confirming a token exists). `strict` additionally fails `ok` on +/// `warning`-severity violations. +pub fn validate_devup_ui_tsx( + tsx: &str, + theme: Option<&ProjectTheme>, + strict: bool, +) -> UiValidation { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, tsx, SourceType::tsx()).parse(); + + let mut violations = Vec::new(); + for diagnostic in &parsed.diagnostics { + let (start, end) = diagnostic + .labels + .first() + .map(|label| { + let start = (label.offset() as usize).min(tsx.len()); + let end = start.saturating_add(label.len() as usize).min(tsx.len()); + (start, end) + }) + .unwrap_or((0, 0)); + violations.push(Violation { + rule: "invalid-syntax", + severity: Severity::Error, + byte_range: [start, end], + message: format!("TSX failed TypeScript+JSX syntax validation: {diagnostic}"), + suggestion: None, + }); + } + + let available_token_count = theme.map(ProjectTheme::token_count).unwrap_or(0); + let mut visitor = TsxVisitor { + theme, + checked_tokens: 0, + violations: Vec::new(), + element_stack: Vec::new(), + }; + visitor.visit_program(&parsed.program); + violations.extend(visitor.violations); + let checked_tokens = visitor.checked_tokens; + + let ok = violations + .iter() + .all(|violation| violation.severity != Severity::Error) + && (!strict || violations.is_empty()); + + UiValidation { + ok, + violations, + checked_tokens, + available_token_count, + } +} + +struct TsxVisitor<'t> { + theme: Option<&'t ProjectTheme>, + checked_tokens: usize, + violations: Vec, + element_stack: Vec>, +} + +impl<'t> TsxVisitor<'t> { + fn current_is_primitive(&self) -> bool { + self.element_stack + .last() + .and_then(|name| name.as_deref()) + .is_some_and(|name| DEVUP_PRIMITIVE_ELEMENTS.contains(&name)) + } + + fn check_attribute_value(&mut self, prop_name: &str, text: &str, span: Span) { + if let Some(token) = text.strip_prefix('$') { + self.checked_tokens += 1; + if let Some(theme) = self.theme + && !theme.contains_token(token) + { + let catalog = theme.token_catalog(); + let names = catalog.keys().collect::>(); + let suggestions = closest_tokens(token, names.into_iter(), 3); + self.violations.push(Violation { + rule: "unknown-token", + severity: Severity::Error, + byte_range: [span.start as usize, span.end as usize], + message: format!("${token} is not defined in devup.json."), + suggestion: if suggestions.is_empty() { + None + } else { + Some(format!( + "closest existing tokens: {}", + suggestions + .iter() + .map(|name| format!("${name}")) + .collect::>() + .join(", ") + )) + }, + }); + } + return; + } + if is_color_like_prop(prop_name) && is_hex_color(text) { + let suggestion = self + .theme + .map(|theme| theme.color_tokens_matching_hex(text)); + self.violations.push(Violation { + rule: "hardcoded-color", + severity: Severity::Warning, + byte_range: [span.start as usize, span.end as usize], + message: format!( + "{prop_name} uses hardcoded color {text}. Consider using a devup.json token." + ), + suggestion: match suggestion { + Some(tokens) if !tokens.is_empty() => Some(format!( + "matching tokens: {}", + tokens + .iter() + .map(|name| format!("${name}")) + .collect::>() + .join(", ") + )), + _ => None, + }, + }); + return; + } + if is_length_like_prop(prop_name) && is_px_length(text) { + let suggestion = self + .theme + .map(|theme| theme.length_tokens_matching_px(text)); + self.violations.push(Violation { + rule: "hardcoded-length", + severity: Severity::Warning, + byte_range: [span.start as usize, span.end as usize], + message: format!( + "{prop_name} uses hardcoded length {text}. Consider using a devup.json token." + ), + suggestion: match suggestion { + Some(tokens) if !tokens.is_empty() => Some(format!( + "matching tokens: {}", + tokens + .iter() + .map(|name| format!("${name}")) + .collect::>() + .join(", ") + )), + _ => None, + }, + }); + } + } + + fn check_unknown_prop(&mut self, prop_name: &str, span: Span) { + if !self.current_is_primitive() { + return; + } + if is_known_style_prop(prop_name) || is_known_non_style_prop(prop_name) { + return; + } + self.violations.push(Violation { + rule: "unknown-prop", + severity: Severity::Error, + byte_range: [span.start as usize, span.end as usize], + message: format!( + "{prop_name} is not a prop recognized by {}.", + self.element_stack + .last() + .and_then(|name| name.as_deref()) + .unwrap_or("devup-ui primitive") + ), + suggestion: None, + }); + } + + fn check_literal_only_call(&mut self, call: &CallExpression) { + let Some(callee) = call.callee.get_identifier_reference() else { + return; + }; + if !LITERAL_ONLY_CALLS.contains(&callee.name.as_str()) { + return; + } + let Some(Argument::ObjectExpression(object)) = call.arguments.first() else { + return; + }; + self.check_static_object(object, callee.name.as_str()); + } + + fn check_static_object(&mut self, object: &ObjectExpression, call_name: &str) { + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + let key = property_key_name(&property.key).unwrap_or_else(|| "?".to_owned()); + if !is_static_expression(&property.value) { + self.violations.push(Violation { + rule: "runtime-value", + severity: Severity::Error, + byte_range: [ + property.value.span().start as usize, + property.value.span().end as usize, + ], + message: format!( + "{call_name}({{ {key}: ... }}) accepts only statically analyzable literal values. Variables or expressions break zero-runtime extraction." + ), + suggestion: None, + }); + } + } + } +} + +impl<'a, 't> Visit<'a> for TsxVisitor<'t> { + fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { + let tag_name = jsx_element_name(&element.name); + self.element_stack.push(tag_name); + walk::walk_jsx_opening_element(self, element); + self.element_stack.pop(); + } + + fn visit_jsx_attribute(&mut self, attribute: &JSXAttribute<'a>) { + if let JSXAttributeName::Identifier(name) = &attribute.name { + let prop_name = name.name.as_str(); + self.check_unknown_prop(prop_name, name.span); + if let Some(JSXAttributeValue::StringLiteral(literal)) = &attribute.value { + self.check_attribute_value(prop_name, literal.value.as_str(), literal.span); + } + } + walk::walk_jsx_attribute(self, attribute); + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + self.check_literal_only_call(call); + walk::walk_call_expression(self, call); + } +} + +fn jsx_element_name(name: &JSXElementName) -> Option { + match name { + JSXElementName::Identifier(identifier) => Some(identifier.name.as_str().to_owned()), + JSXElementName::IdentifierReference(reference) => Some(reference.name.as_str().to_owned()), + _ => None, + } +} + +fn property_key_name(key: &PropertyKey) -> Option { + match key { + PropertyKey::StaticIdentifier(identifier) => Some(identifier.name.as_str().to_owned()), + PropertyKey::StringLiteral(literal) => Some(literal.value.as_str().to_owned()), + _ => None, + } +} + +/// Static-analysis literal check mirroring devup-ui's `css-utils-literal-only` +/// ESLint rule: string/number/boolean/null literals, unary-negated numeric +/// literals, and arrays/objects composed entirely of such, are allowed. +/// Identifiers, member/call expressions, template literals with +/// substitutions, and any other runtime-dependent expression are not. +fn is_static_expression(expression: &Expression) -> bool { + match expression { + Expression::StringLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::NullLiteral(_) => true, + Expression::TemplateLiteral(template) => template.expressions.is_empty(), + Expression::UnaryExpression(unary) => { + matches!( + unary.operator, + UnaryOperator::UnaryNegation | UnaryOperator::UnaryPlus + ) && is_static_expression(&unary.argument) + } + Expression::ArrayExpression(array) => array.elements.iter().all(|element| { + element.as_expression().is_some_and(is_static_expression) || element.is_elision() + }), + Expression::ObjectExpression(object) => { + object.properties.iter().all(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => { + is_static_expression(&property.value) + } + ObjectPropertyKind::SpreadProperty(_) => false, + }) + } + _ => false, + } +} + +fn is_hex_color(text: &str) -> bool { + let Some(hex) = text.strip_prefix('#') else { + return false; + }; + matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|character| character.is_ascii_hexdigit()) +} + +fn is_px_length(text: &str) -> bool { + let Some(number) = text.strip_suffix("px") else { + return false; + }; + let number = number.strip_prefix('-').unwrap_or(number); + !number.is_empty() + && number + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + && number.matches('.').count() <= 1 +} + +/// All prop-name-independent identifiers this validator can flag, exposed +/// for tests that want to assert coverage without duplicating the rule +/// list. +pub fn rule_names() -> BTreeSet<&'static str> { + [ + "invalid-syntax", + "unknown-token", + "hardcoded-color", + "hardcoded-length", + "unknown-prop", + "runtime-value", + ] + .into_iter() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::parse_project_theme; + + fn fixture_theme() -> ProjectTheme { + parse_project_theme( + r##"{ "theme": { + "colors": { "default": { "captionLight": "#999999", "backgroundLight": "#fafafa" } }, + "typography": {}, + "length": { "default": { "sm": "8px", "md": "16px" } }, + "shadow": {} + } }"##, + ) + .unwrap() + } + + #[test] + fn catches_the_gray100_regression_case() { + let tsx = r##"export const Bubble = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(!report.ok); + assert!( + report + .violations + .iter() + .any(|violation| violation.rule == "unknown-token" + && violation.message.contains("gray100")) + ); + } + + #[test] + fn allows_existing_tokens() { + let tsx = r##"export const Bubble = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(report.ok, "{:?}", report.violations); + assert_eq!(report.checked_tokens, 1); + } + + #[test] + fn flags_hardcoded_hex_color_with_suggestion() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + let violation = report + .violations + .iter() + .find(|violation| violation.rule == "hardcoded-color") + .expect("hardcoded-color violation"); + assert_eq!(violation.severity, Severity::Warning); + assert!( + violation + .suggestion + .as_deref() + .unwrap() + .contains("captionLight") + ); + } + + #[test] + fn flags_hardcoded_px_length_with_suggestion() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + let violation = report + .violations + .iter() + .find(|violation| violation.rule == "hardcoded-length") + .expect("hardcoded-length violation"); + assert!(violation.suggestion.as_deref().unwrap().contains("md")); + } + + #[test] + fn dynamic_jsx_props_are_not_flagged_as_runtime_value() { + let tsx = r##"export const X = ({color}) => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "runtime-value"), + "{:?}", + report.violations + ); + } + + #[test] + fn catches_runtime_value_inside_css_call() { + let tsx = r##" + import { css } from '@devup-ui/react' + const v = getValue() + const cls = css({ width: v }) + "##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(!report.ok); + assert!( + report + .violations + .iter() + .any(|violation| violation.rule == "runtime-value") + ); + } + + #[test] + fn allows_literal_only_css_call() { + let tsx = r##" + import { css } from '@devup-ui/react' + const cls = css({ width: 1, height: '100%', items: [1, '2'] }) + "##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(report.ok, "{:?}", report.violations); + } + + #[test] + fn flags_unknown_prop_on_primitive_element() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, None, false); + assert!( + report + .violations + .iter() + .any(|violation| violation.rule == "unknown-prop") + ); + } + + #[test] + fn does_not_flag_unknown_prop_on_custom_component() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, None, false); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "unknown-prop"), + "{:?}", + report.violations + ); + } + + #[test] + fn does_not_flag_pseudo_and_event_props() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, None, false); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "unknown-prop"), + "{:?}", + report.violations + ); + } + + #[test] + fn reports_invalid_syntax_as_violation_not_panic() { + let report = validate_devup_ui_tsx("export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, None, false); + assert_eq!(report.checked_tokens, 1); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "unknown-token") + ); + } + + #[test] + fn strict_mode_fails_on_warnings() { + let tsx = r##"export const X = () => ;"##; + let lenient = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + let strict = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), true); + assert!(lenient.ok); + assert!(!strict.ok); + } +} diff --git a/crates/devup-mcp-devup-ui/src/validation.rs b/crates/devup-mcp-devup-ui/src/validation.rs index 87f862ef..a1606c0c 100644 --- a/crates/devup-mcp-devup-ui/src/validation.rs +++ b/crates/devup-mcp-devup-ui/src/validation.rs @@ -49,7 +49,7 @@ pub fn validate_tsx(source: &str) -> Result { .collect::>(); Err(DevupError::with_details( ErrorCode::DevupCodegenFailed, - "생성된 DevupUI TSX가 TypeScript JSX 문법 검증을 통과하지 못했습니다.", + "Generated DevupUI TSX failed TypeScript JSX syntax validation.", false, json!({ "errorCount": errors.len(), diff --git a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs new file mode 100644 index 00000000..727fd62f --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs @@ -0,0 +1,344 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +#[test] +fn a_folded_asset_does_not_anchor_children_it_no_longer_has() { + // The vectors inside are baked into the exported icon, so nothing is left + // to position against and a containing block would serve no one. + let tsx = generate( + "1:button", + json!([ + { + "id": "1:button", "type": "FRAME", + "fields": { + "name": "clear button", "childrenIds": ["1:ring"], + "width": 24.0, "height": 24.0 + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:ring", "type": "ELLIPSE", + "fields": { + "name": "Ellipse", "parentId": "1:button", "childrenIds": [], + "layoutPositioning": "ABSOLUTE", + "width": 24.0, "height": 24.0, "x": 0.0, "y": 0.0, + "fills": [{"type": "SOLID", "visible": true, "color": {"r": 0.0, "g": 0.0, "b": 0.0}}] + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + assert!( + tsx.contains("/icons/clear button.svg"), + "expected a folded asset: {tsx}" + ); + assert!( + !tsx.contains("pos=\"relative\""), + "a folded asset has no children to anchor: {tsx}" + ); +} + +#[test] +fn a_raster_pattern_is_referenced_from_the_image_folder() { + // A png is an image and an svg is an icon, which is the split every other + // asset reference follows. Pattern fills sent both to the icon folder. + let tsx = generate( + "1:wall", + json!([ + { + "id": "1:wall", "type": "FRAME", + "fields": { + "name": "Wall", "childrenIds": [], + "width": 200.0, "height": 100.0, + "fills": [{ + "type": "PATTERN", "visible": true, + "sourceNodeId": "1:tile", + "spacing": {"x": 0.0, "y": 0.0} + }] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:tile", "type": "FRAME", + "fields": { + "name": "Tile", "childrenIds": [], "isAsset": true, + "width": 20.0, "height": 20.0, + "fills": [{"type": "IMAGE", "visible": true, "scaleMode": "FILL", "imageHash": "h"}] + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + assert!( + tsx.contains("/images/Tile.png"), + "a raster pattern is an image: {tsx}" + ); + assert!(!tsx.contains("/icons/Tile"), "{tsx}"); +} + +#[test] +fn separate_image_fills_do_not_claim_the_same_file() { + // Two fills on one node are two different images. A single hard-coded + // reference gave both the same URL, so the layered background repeated one + // picture and whichever was exported last overwrote the other on disk. + let tsx = generate( + "1:card", + json!([{ + "id": "1:card", "type": "FRAME", + "fields": { + "name": "Card", "childrenIds": [], + "width": 125.0, "height": 100.0, + "fills": [ + {"type": "IMAGE", "visible": true, "scaleMode": "FILL", "imageHash": "aaa"}, + {"type": "IMAGE", "visible": true, "scaleMode": "FILL", "imageHash": "bbb"} + ] + }, + "extra": {}, "fieldErrors": {} + }]), + ); + + assert!(tsx.contains("/images/Card.png"), "{tsx}"); + assert!(tsx.contains("/images/Card-1.png"), "{tsx}"); +} + +#[test] +fn image_filled_asset_container_preserves_text_children() { + let tsx = generate( + "1:cover", + json!([ + { + "id": "1:cover", "type": "FRAME", + "fields": { + "name": "Book cover", "childrenIds": ["1:title"], "isAsset": true, + "fills": [{"type": "IMAGE", "visible": true, "scaleMode": "FILL"}] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:title", "type": "TEXT", + "fields": { + "name": "Title", "parentId": "1:cover", "childrenIds": [], + "characters": "Preserved title" + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + // The fill names the node it came from, so two different images cannot + // claim the same file. The name has a space, hence the quoting. + assert!(tsx.contains("bg=\"url('/images/Book cover.png') center/cover no-repeat\"")); + assert!(tsx.contains("Preserved title")); + assert!(!tsx.contains(" PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/local-screens") +} + +/// Every `/icons/x.svg` and `/images/x.png` the code draws from. +/// +/// A name with a space in it is written quoted, and only then, so a quoted +/// path runs to its closing quote — `'/images/Frame 269.png'` keeps its +/// space — while a bare one ends at the first quote, bracket, comma or space +/// that follows it. +fn pointed_at(code: &str) -> BTreeSet { + let mut found = BTreeSet::new(); + // A design can show code as text - these fixtures display JSX samples + // that name files of their own - and the generator escapes the brackets + // of such a sample as `{"<"}`. A line carrying that escape is something + // the screen prints, not something it draws from. + let code = code + .lines() + .filter(|line| !line.contains("{\"<\"}") && !line.contains("{\">\"}")) + .collect::>() + .join("\n"); + let code = code.as_str(); + for prefix in ["/icons/", "/images/"] { + let mut rest = code; + while let Some(at) = rest.find(prefix) { + let after = at + prefix.len(); + let quote = rest[..at] + .chars() + .next_back() + .filter(|mark| *mark == '\'' || *mark == '"'); + let end = match quote { + // A quoted name keeps its spaces, and never holds a bracket: + // where one turns up first the quote belonged to something + // around the `url(...)`, not to the name. + Some(mark) => rest[after..] + .find([mark, ')']) + .map_or(rest.len(), |offset| after + offset), + None => rest[after..] + .find(['"', '\'', ')', ',', ' ', '\n']) + .map_or(rest.len(), |offset| after + offset), + }; + found.insert(format!("{prefix}{}", &rest[after..end])); + rest = &rest[end..]; + } + } + found +} + +/// Where the manifest says each asset's bytes belong. +fn listed(snapshot: &Snapshot, per_node: bool) -> BTreeSet { + discover_asset_manifest(snapshot) + .assets + .iter() + .filter_map(|asset| { + match asset + .field + .strip_prefix("fills/") + .and_then(|index| index.parse::().ok()) + { + Some(fill_index) => image_fill_path(snapshot, &asset.node_id, fill_index, per_node), + None => asset_path(snapshot, &asset.node_id, per_node), + } + }) + .collect() +} + +fn snapshot_of(path: &std::path::Path) -> Option<(Snapshot, CodegenOptions)> { + let raw = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let snapshot: Snapshot = serde_json::from_value(value.get("snapshot")?.clone()).ok()?; + let resource = |name: &str| { + value + .get("payload") + .and_then(|payload| payload.get(name)) + .cloned() + .map(|raw| UpstreamResult { raw }) + }; + let options = CodegenOptions { + inline_instances: true, + ..CodegenOptions::default() + } + .with_resource_results(resource("variables").as_ref(), resource("styles").as_ref()); + Some((snapshot, options)) +} + +#[test] +fn every_asset_the_code_points_at_is_one_the_manifest_lists() { + let Ok(entries) = fs::read_dir(captures()) else { + eprintln!("no captures; skipping"); + return; + }; + let mut checked = 0; + let mut roots = 0; + let mut failures = BTreeSet::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some((snapshot, options)) = snapshot_of(&path) else { + continue; + }; + let capture = path.file_stem().unwrap_or_default().to_string_lossy(); + checked += 1; + // The naming is an option, and the invariant holds either way: the + // manifest and the code have to agree on the name whichever one is in + // force. + for per_node in [false, true] { + let known = listed(&snapshot, per_node); + for root in &snapshot.roots { + let options = CodegenOptions { + asset_names_per_node: per_node, + ..options.clone() + }; + let Ok(output) = generate_component(&snapshot, root, &options) else { + continue; + }; + roots += 1; + for wanted in pointed_at(&output.tsx) { + if !known.contains(&wanted) { + failures.insert(format!("{capture}: {wanted}")); + } + } + } + } + } + assert!( + failures.is_empty(), + "{} picture(s) across {checked} captures the manifest does not list:\n {}", + failures.len(), + failures.iter().cloned().collect::>().join("\n ") + ); + eprintln!( + "checked {checked} captures, {roots} generated modules, {} unlisted", + failures.len() + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/button_answer.rs b/crates/devup-mcp-devup-ui/tests/button_answer.rs new file mode 100644 index 00000000..edfcdaaa --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/button_answer.rs @@ -0,0 +1,297 @@ +//! The `Button` component set against the plugin's definition of it. +//! +//! `fixtures/plugin-answers/button/components.tsx` is what the plugin wrote +//! for `devup-Test`'s `582:2137`, and `fixtures/local-components/button-set.json` +//! is the capture of the set with the variables it binds. The capture is not +//! committed, so this skips when it is absent rather than pretending to have +//! checked. +//! +//! `variant_nesting.rs` pins the shape of the maps; this compares the whole +//! definition line for line, with each difference tied to what the capture +//! says about the file. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component_set_target}; +use devup_mcp_figma::{Snapshot, UpstreamResult}; + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures") +} + +fn ours() -> Option { + let raw = fs::read_to_string(fixtures().join("local-components/button-set.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let snapshot: Snapshot = serde_json::from_value(value.get("snapshot")?.clone()).ok()?; + let resource = |name: &str| { + value + .get("payload") + .and_then(|payload| payload.get(name)) + .cloned() + .map(|raw| UpstreamResult { raw }) + }; + let variables = resource("variables")?; + let styles = resource("styles"); + let options = CodegenOptions { + component_name: Some("Button".to_owned()), + ..CodegenOptions::default() + } + .with_resource_results(Some(&variables), styles.as_ref()); + Some( + generate_component_set_target(&snapshot, "582:2137", "Button", &options) + .ok()? + .tsx, + ) +} + +/// Brace depth of a piece of a line, outside its strings. +fn balance(piece: &str) -> i32 { + let mut depth = 0; + let mut quoted = false; + for character in piece.chars() { + match character { + '"' => quoted = !quoted, + '{' | '[' if !quoted => depth += 1, + '}' | ']' if !quoted => depth -= 1, + _ => {} + } + } + depth +} + +/// A file's lines as they are compared: indentation and blank lines dropped, +/// an expression that spans several lines — a variant map, a pseudo-selector +/// object, a text map — folded onto one with its spacing normalised, and the +/// module around the definition left out. A JSX element or a function body +/// is not an expression: its opener is a `>` after props or a `{` after `)`, +/// and it is left alone. +fn comparable_lines(source: &str) -> Vec { + let opens = |piece: &str| { + ["={{", "={[", ": {", ": [", "{{", "={"] + .iter() + .any(|opener| piece.ends_with(opener)) + && balance(piece) > 0 + }; + let normalise = |folded: &str| { + let squeezed = folded.split_whitespace().collect::>().join(" "); + squeezed + .replace(" ,", ",") + .replace("{ ", "{") + .replace(" }", "}") + .replace("[ ", "[") + .replace(" ]", "]") + }; + let mut out = Vec::new(); + let mut current = String::new(); + let mut depth = 0; + for raw in source.lines() { + let piece = raw.trim(); + if piece.is_empty() { + continue; + } + if current.is_empty() { + if opens(piece) { + depth = balance(piece); + current.push_str(piece); + } else { + out.push(normalise(piece)); + } + continue; + } + current.push(' '); + current.push_str(piece); + depth += balance(piece); + if depth <= 0 { + out.push(normalise(¤t)); + current.clear(); + } + } + if !current.is_empty() { + out.push(normalise(¤t)); + } + out +} + +/// The lines on which the definition is written differently on purpose, +/// each with what the capture says. +/// +/// - **The icons' colour.** The answer gives both icons `bg="$gray400"` at +/// every variant. The capture gives the icon instances a white fill on +/// `primary` and `error`, `text` on `white` and `ghost`, and `gray400` on +/// `disabled` only — the same map as the label's `color`, which the answer +/// itself writes per variant. `$gray400` is the icon component's own +/// colour, not the instances', and the map is the file. +/// - **The right icon's aspect ratio.** The `Arrow` instances carry +/// `targetAspectRatio` 20×20 at `md`, `sm` and `lg`/`ghost`, and none at +/// the other `lg`s; the answer writes nothing for it. The left icon is +/// 512×512 everywhere and both write `aspectRatio="1"`. +/// - **The order of a map's keys.** Keys follow the order the set declares +/// its options in, `primary, white, ghost, disabled, error`, as the +/// `varient` type in the interface does; the plugin's follow the layer +/// order of the set's children, `primary, disabled, white, ghost, error`, +/// which is the order a `Map` happened to be filled in. +/// - **A hover value equal to the resting one.** `gap` on hover is `10px` +/// on every `md` variant, and only `ghost` rests at `8px`; the hover block +/// says it where hovering changes it, the answer on every `md`. +/// - **`disabled` on hover and active.** The capture holds 49 components, +/// `disabled` at four sizes and only at `effect=default`; there is no +/// disabled hover or active in the file, and the answer's entries for them +/// come from another state of it. +/// - **The icons' files** are named after the instances' own layers, +/// `MypageIcon` and `Arrow`; the plugin names them after the variant +/// components, which every set with a `user` variant would share. +const DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ + // the icons' colour + ( + "bg=\"$gray400\"", + "the icon instances are coloured per variant", + ), + ( + "bg={{primary: \"#FFF\", white: \"$text\", ghost: \"$text\", disabled: \"$gray400\", error: \"#FFF\"}[varient]}", + "the icon instances are coloured per variant", + ), + // the right icon's aspect ratio + ( + "aspectRatio={{lg: varient === 'ghost' && \"1\", md: \"1\", sm: \"1\"}[size]}", + "the Arrow instances carry a targetAspectRatio where the file has one", + ), + // key order, and the hover and active blocks + ( + "bg={{primary: \"$primary\", disabled: \"$gray200\", white: \"$innerBg\", error: \"$error\"}[varient]}", + "keys in the set's option order", + ), + ( + "bg={{primary: \"$primary\", white: \"$innerBg\", disabled: \"$gray200\", error: \"$error\"}[varient]}", + "keys in the set's option order", + ), + ( + "gap={{lg: \"10px\", md: {primary: \"10px\", disabled: \"10px\", white: \"10px\", ghost: \"8px\", error: \"10px\"}[varient], sm: \"8px\"}[size]}", + "keys in the set's option order", + ), + ( + "gap={{lg: \"10px\", md: {primary: \"10px\", white: \"10px\", ghost: \"8px\", disabled: \"10px\", error: \"10px\"}[varient], sm: \"8px\"}[size]}", + "keys in the set's option order", + ), + ( + "px={{lg: {primary: \"24px\", disabled: \"24px\", white: \"24px\", ghost: \"10px\", error: \"24px\"}[varient], md: {primary: \"16px\", disabled: \"16px\", white: \"16px\", ghost: \"12px\", error: \"16px\"}[varient], sm: {primary: \"12px\", disabled: \"12px\", white: \"12px\", ghost: \"10px\", error: \"12px\"}[varient], tag: \"10px\"}[size]}", + "keys in the set's option order", + ), + // `white` is the one variant with a 1px inside stroke. Figma paints it + // over the padding and CSS adds it around, so `white` is padded a pixel + // less to put its label where Figma has it - which also makes `tag` a + // map. Rendered against Figma's own PNGs, the landing page's stroked + // cards only sit right once this is done. + ( + "px={{lg: {primary: \"24px\", white: \"23px\", ghost: \"10px\", disabled: \"24px\", error: \"24px\"}[varient], md: {primary: \"16px\", white: \"15px\", ghost: \"12px\", disabled: \"16px\", error: \"16px\"}[varient], sm: {primary: \"12px\", white: \"11px\", ghost: \"10px\", disabled: \"12px\", error: \"12px\"}[varient], tag: {primary: \"10px\", white: \"9px\", disabled: \"10px\", error: \"10px\"}[varient]}[size]}", + "keys in the set's option order; the stroked variant is padded a pixel less", + ), + ( + "color={{primary: \"#FFF\", disabled: \"$gray400\", white: \"$text\", ghost: \"$text\", error: \"#FFF\"}[varient]}", + "keys in the set's option order", + ), + ( + "color={{primary: \"#FFF\", white: \"$text\", ghost: \"$text\", disabled: \"$gray400\", error: \"#FFF\"}[varient]}", + "keys in the set's option order", + ), + ( + "boxSize={{lg: {primary: \"20px\", disabled: \"20px\", white: \"20px\", ghost: \"16px\", error: \"20px\"}[varient], md: \"16px\", sm: \"12px\"}[size]}", + "keys in the set's option order", + ), + ( + "boxSize={{lg: {primary: \"20px\", white: \"20px\", ghost: \"16px\", disabled: \"20px\", error: \"20px\"}[varient], md: \"16px\", sm: \"12px\"}[size]}", + "keys in the set's option order", + ), + ( + "boxSize={{lg: {primary: \"20px\", disabled: \"20px\", white: \"20px\", ghost: \"18px\", error: \"20px\"}[varient], md: \"16px\", sm: \"14px\"}[size]}", + "keys in the set's option order", + ), + ( + "boxSize={{lg: {primary: \"20px\", white: \"20px\", ghost: \"18px\", disabled: \"20px\", error: \"20px\"}[varient], md: \"16px\", sm: \"14px\"}[size]}", + "keys in the set's option order", + ), + ( + "_active={{\"bg\": {primary: \"$primaryDarkest\", disabled: \"$errorDarkest\", white: \"$gray200\", ghost: \"$gray200\", error: \"$errorDarkest\"}[varient], \"borderRadius\": varient === 'disabled' && \"6px\", \"px\": varient === 'disabled' && \"10px\"}}", + "no disabled active in the file; keys in the set's option order", + ), + ( + "_active={{\"bg\": {primary: \"$primaryDarkest\", white: \"$gray200\", ghost: \"$gray200\", error: \"$errorDarkest\"}[varient]}}", + "no disabled active in the file; keys in the set's option order", + ), + ( + "_hover={{\"bg\": {primary: \"$primaryDark\", disabled: \"$errorDark\", white: \"$gray100\", ghost: \"$gray100\", error: \"$errorDark\"}[varient], \"borderRadius\": varient === 'disabled' && \"6px\", \"px\": varient === 'disabled' && \"10px\", \"gap\": size === 'md' && \"10px\"}}", + "no disabled hover in the file; a hover value is said where it changes; keys in the set's option order", + ), + ( + "_hover={{\"bg\": {primary: \"$primaryDark\", white: \"$gray100\", ghost: \"$gray100\", error: \"$errorDark\"}[varient], \"gap\": {md: varient === 'ghost' && \"10px\"}[size]}}", + "no disabled hover in the file; a hover value is said where it changes; keys in the set's option order", + ), + // the icons' files + ( + "maskImage=\"url('/icons/속성 1=user.svg')\"", + "an asset is named after its instance", + ), + ( + "maskImage=\"url(/icons/MypageIcon.svg)\"", + "an asset is named after its instance", + ), + ( + "maskImage=\"url('/icons/속성 1=right.svg')\"", + "an asset is named after its instance", + ), + ( + "maskImage=\"url(/icons/Arrow.svg)\"", + "an asset is named after its instance", + ), +]; + +#[test] +fn the_button_definition_is_the_plugin_s() { + let Some(tsx) = ours() else { + eprintln!("no button capture; skipping"); + return; + }; + let Ok(answer) = fs::read_to_string(fixtures().join("plugin-answers/button/components.tsx")) + else { + eprintln!("no button answer; skipping"); + return; + }; + let ours = comparable_lines(&tsx); + let theirs = comparable_lines(&answer); + let mut only_ours = ours.clone(); + for line in &theirs { + if let Some(index) = only_ours.iter().position(|other| other == line) { + only_ours.remove(index); + } + } + let mut only_theirs = theirs.clone(); + for line in &ours { + if let Some(index) = only_theirs.iter().position(|other| other == line) { + only_theirs.remove(index); + } + } + let unexplained = |lines: &[String]| { + lines + .iter() + .filter(|line| !line.starts_with("import ")) + .filter(|line| !DIFFERS_ON_PURPOSE.iter().any(|(known, _)| known == line)) + .cloned() + .collect::>() + }; + let ours_unexplained = unexplained(&only_ours); + let theirs_unexplained = unexplained(&only_theirs); + assert!( + ours_unexplained.is_empty() && theirs_unexplained.is_empty(), + "button: lines not accounted for.\n written here and not in the answer:\n {}\n in the answer and not here:\n {}", + ours_unexplained.join("\n "), + theirs_unexplained.join("\n ") + ); + // Every explanation is for a line that is actually there; a stale entry + // would hide a line that came to differ later. + for (known, why) in DIFFERS_ON_PURPOSE { + assert!( + only_ours.iter().any(|line| line == known) + || only_theirs.iter().any(|line| line == known), + "explained but not different: {known} ({why})" + ); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/compat_fixtures.rs b/crates/devup-mcp-devup-ui/tests/compat_fixtures.rs index 0567e086..38d0e766 100644 --- a/crates/devup-mcp-devup-ui/tests/compat_fixtures.rs +++ b/crates/devup-mcp-devup-ui/tests/compat_fixtures.rs @@ -56,7 +56,7 @@ fn upstream_json_goldens() { .join("../snapshots") .join(category) .join(format!("{}.snap", case.id)); - let committed = std::fs::read_to_string(snapshot) + let committed = std::fs::read_to_string(&snapshot) .expect("committed snapshot") .replace("\r\n", "\n"); let expected = committed @@ -66,6 +66,19 @@ fn upstream_json_goldens() { .trim(); if actual == expected { passed += 1; + } else if std::env::var_os("DEVUP_FIXTURE_UPDATE").is_some() { + // The pinned corpus is the plugin's own output and is not edited + // lightly: it moves only for a change this repo has decided to + // make on purpose, measured against Figma's own PNGs, and each + // such move is a reviewed diff. This is the one way to make it. + let header = committed + .splitn(3, "---\n") + .take(2) + .collect::>() + .join("---\n"); + std::fs::write(&snapshot, format!("{header}---\n\n{actual}\n")) + .expect("rewrite the committed snapshot"); + passed += 1; } else { let mismatch = expected .bytes() diff --git a/crates/devup-mcp-devup-ui/tests/compat_manifest.rs b/crates/devup-mcp-devup-ui/tests/compat_manifest.rs index ef6c569e..79013522 100644 --- a/crates/devup-mcp-devup-ui/tests/compat_manifest.rs +++ b/crates/devup-mcp-devup-ui/tests/compat_manifest.rs @@ -5,8 +5,9 @@ use std::path::PathBuf; #[test] fn pinned_plugin_corpus_is_complete_and_self_consistent() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/devup-figma-plugin"); - let summary = support::validate_corpus(&root) - .unwrap_or_else(|violations| panic!("compat corpus 위반:\n{}", violations.join("\n"))); + let summary = support::validate_corpus(&root).unwrap_or_else(|violations| { + panic!("compat corpus violations:\n{}", violations.join("\n")) + }); assert_eq!(summary.source_files, 54); assert_eq!(summary.ledger_entries, 978); assert_eq!(summary.cases, 268); @@ -24,8 +25,9 @@ fn manifest_hashes_are_stable_across_checkout_line_endings() { #[test] fn coverage_registry_maps_every_inventory_entry_to_real_evidence() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/devup-figma-plugin"); - let summary = support::validate_coverage_registry(&root) - .unwrap_or_else(|violations| panic!("coverage registry 위반:\n{}", violations.join("\n"))); + let summary = support::validate_coverage_registry(&root).unwrap_or_else(|violations| { + panic!("coverage registry violations:\n{}", violations.join("\n")) + }); assert_eq!(summary.inventory_entries, 978); assert_eq!(summary.snapshot_parity_entries, 252); @@ -34,6 +36,6 @@ fn coverage_registry_maps_every_inventory_entry_to_real_evidence() { assert_eq!(summary.non_parity_entries, 60); assert_eq!( summary.not_ported_entries, 0, - "모든 upstream inventory 항목은 실행 evidence 또는 명시적인 범위 분류를 가져야 합니다." + "every upstream inventory entry must have executable evidence or an explicit scope classification." ); } diff --git a/crates/devup-mcp-devup-ui/tests/component_projection.rs b/crates/devup-mcp-devup-ui/tests/component_projection.rs new file mode 100644 index 00000000..5b10853d --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/component_projection.rs @@ -0,0 +1,58 @@ +//! The same screen said twice, so a caller can place it in a project. +//! +//! `tsx` expands every instance into primitives: complete, but it cannot tell +//! you that a stretch of it is a Header the project may already own. +//! `componentTsx` keeps instances as `
` with the import that resolves +//! them. Neither alone is enough — one cannot be split, the other cannot be +//! rendered — and the difference between them is each component's body, which +//! is what a caller writes into a new file when the component is missing. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; + +fn capture(name: &str) -> Option { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens") + .join(name); + let raw = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + serde_json::from_value(value.get("snapshot").cloned().unwrap_or(value)).ok() +} + +fn render(snapshot: &Snapshot, root: &str, inline: bool) -> String { + let options = CodegenOptions { + inline_instances: inline, + ..CodegenOptions::default() + }; + generate_component(snapshot, root, &options) + .expect("the capture converts") + .tsx +} + +#[test] +fn an_instance_is_a_reference_in_one_projection_and_its_parts_in_the_other() { + let Some(snapshot) = capture("first-form.json") else { + eprintln!("no capture; skipping"); + return; + }; + let root = snapshot.roots.first().expect("a captured root"); + + let expanded = render(&snapshot, root, true); + let referenced = render(&snapshot, root, false); + + assert!( + referenced.contains("
"), + "componentTsx should name the instance: {referenced}" + ); + assert!( + !expanded.contains("
"), + "tsx should have expanded it instead: {expanded}" + ); + assert!( + referenced.contains("from '@/components/Header'") + || referenced.contains("from \"@/components/Header\""), + "a named component needs the import that resolves it: {referenced}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/default_omission_golden.rs b/crates/devup-mcp-devup-ui/tests/default_omission_golden.rs new file mode 100644 index 00000000..8e9b810d --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/default_omission_golden.rs @@ -0,0 +1,294 @@ +//! Pins the exact set of node fields `fast_snapshot.js` may omit from the +//! envelope, by replaying the omission over the ten real WQUW-151 screens +//! (1,500+ nodes covering every node type the file uses) and requiring the +//! generated TSX to stay byte-identical. +//! +//! The rules here and the `SCALAR_DEFAULTS` / `NULL_SENSITIVE_FIELDS` tables in +//! `crates/devup-mcp-figma/src/scripts/fast_snapshot.js` must stay in sync; +//! this test is what makes that safe to change. +//! +//! Fields deliberately NOT omitted, each for a reason visible in the converter: +//! - `maxWidth` / `maxHeight`: `codegen/layout.rs` compares +//! `view.value("maxWidth") != Some(&Value::Null)`, so a present-null and an +//! absent field take opposite branches. +//! - `opacity`: `codegen/component.rs` locates a hover variant with +//! `number("opacity").is_some()` - presence itself is the signal. +//! - `visible`: the component registration snapshot emits a `"visible"` line +//! whenever the field is present. +//! - `layoutPositioning`: compared against `Some("AUTO")`, so absence is not +//! equivalent to the default. +//! - per-corner radii and per-side stroke weights: they feed shorthand +//! builders that read the corners/sides as a group, so dropping the ones +//! that happen to be zero would change the shorthand. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{RawNode, Snapshot}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FrameFixture { + source: FrameSource, + snapshot: Snapshot, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FrameSource { + node_id: String, +} + +/// Mirrors `STYLE_ID_FIELDS` in `fast_snapshot.js`. +const STYLE_ID_FIELDS: &[&str] = &[ + "backgroundStyleId", + "effectStyleId", + "fillStyleId", + "gridStyleId", + "strokeStyleId", + "textStyleId", +]; + +/// Mirrors `NULL_SENSITIVE_FIELDS` in `fast_snapshot.js`: fields whose +/// present-null is load-bearing and must survive the omission. +const NULL_SENSITIVE_FIELDS: &[&str] = &["maxWidth", "maxHeight"]; + +/// Mirrors `SCALAR_DEFAULTS` in `fast_snapshot.js`. +fn scalar_defaults() -> Vec<(&'static str, Value)> { + use serde_json::json; + vec![ + ("rotation", json!(0)), + ("cornerRadius", json!(0)), + ("isAsset", json!(false)), + ("isMask", json!(false)), + ("clipsContent", json!(false)), + ("blendMode", json!("PASS_THROUGH")), + ("strokeAlign", json!("INSIDE")), + ("textCase", json!("ORIGINAL")), + ("textDecoration", json!("NONE")), + ("textAlignHorizontal", json!("LEFT")), + ("textAlignVertical", json!("TOP")), + ("counterAxisAlignItems", json!("MIN")), + ("primaryAxisAlignItems", json!("MIN")), + ("gridColumnCount", json!(0)), + ("gridRowCount", json!(0)), + ("gridColumnGap", json!(0)), + ("gridRowGap", json!(0)), + ("gridColumnAnchorIndex", json!(-1)), + ("gridRowAnchorIndex", json!(-1)), + ] +} + +fn numbers_equal(left: &Value, right: &Value) -> bool { + match (left.as_f64(), right.as_f64()) { + (Some(left), Some(right)) => (left - right).abs() < f64::EPSILON, + _ => left == right, + } +} + +fn is_omittable(field: &str, value: &Value) -> bool { + if value.is_null() { + return !NULL_SENSITIVE_FIELDS.contains(&field); + } + if value.as_array().is_some_and(Vec::is_empty) { + return true; + } + if value.as_object().is_some_and(serde_json::Map::is_empty) { + return true; + } + if value.as_str() == Some("") && STYLE_ID_FIELDS.contains(&field) { + return true; + } + scalar_defaults() + .iter() + .any(|(name, default)| *name == field && numbers_equal(value, default)) +} + +fn omit_defaults(node: &mut RawNode) -> usize { + let before = node.fields.len(); + node.fields + .retain(|field, value| !is_omittable(field, value)); + node.extra.clear(); + before - node.fields.len() +} + +fn fixtures() -> Vec { + [ + include_str!("fixtures/wquw-151-frames/3879-35503.json"), + include_str!("fixtures/wquw-151-frames/3879-35518.json"), + include_str!("fixtures/wquw-151-frames/3879-35569.json"), + include_str!("fixtures/wquw-151-frames/3879-35652.json"), + include_str!("fixtures/wquw-151-frames/3879-35729.json"), + include_str!("fixtures/wquw-151-frames/3879-35887.json"), + include_str!("fixtures/wquw-151-frames/3879-35973.json"), + include_str!("fixtures/wquw-151-frames/3879-36059.json"), + include_str!("fixtures/wquw-151-frames/3879-36108.json"), + include_str!("fixtures/wquw-151-frames/3879-36144.json"), + ] + .into_iter() + .map(|raw| serde_json::from_str(raw).expect("WQUW-151 frame fixture")) + .collect() +} + +fn tsx(snapshot: &Snapshot, root_id: &str) -> String { + generate_component( + snapshot, + root_id, + &CodegenOptions { + component_name: Some("OmissionProbe".to_owned()), + include_diagnostics: true, + inline_instances: true, + ..CodegenOptions::default() + }, + ) + .unwrap_or_else(|error| panic!("{root_id} codegen failed: {error}")) + .tsx +} + +#[test] +fn omitting_default_valued_fields_keeps_every_real_screen_byte_identical() { + let mut checked_nodes = 0_usize; + let mut dropped_fields = 0_usize; + + for fixture in fixtures() { + let root_id = fixture.source.node_id.clone(); + let before = tsx(&fixture.snapshot, &root_id); + + let mut trimmed = fixture.snapshot.clone(); + for node in trimmed.nodes.values_mut() { + dropped_fields += omit_defaults(node); + } + checked_nodes += trimmed.nodes.len(); + + assert_eq!( + before, + tsx(&trimmed, &root_id), + "omitting default-valued fields changed the TSX for screen {root_id}" + ); + } + + // Guards against a fixture set that silently shrank to nothing. + assert!( + checked_nodes > 1_000, + "expected the ten real screens to cover >1000 nodes, saw {checked_nodes}" + ); + assert!( + dropped_fields > 10_000, + "expected the omission to drop >10000 fields, saw {dropped_fields}" + ); +} + +/// Mirrors `SEGMENT_ONLY_KEYS` in `fast_snapshot.js`. +const SEGMENT_ONLY_KEYS: &[&str] = &[ + "start", + "end", + "characters", + "fontWeight", + "textStyleId", + "fillStyleId", + "listOptions", + "indentation", + "hyperlink", +]; + +#[test] +fn deduping_single_segment_text_keeps_every_real_screen_byte_identical() { + // A lone styled text segment restates typography the TEXT node already + // carries, and `codegen/text.rs` reads the node field first, falling back + // to the segment only when the node lacks it. + let mut single_segment_nodes = 0_usize; + + for fixture in fixtures() { + let root_id = fixture.source.node_id.clone(); + let before = tsx(&fixture.snapshot, &root_id); + + let mut trimmed = fixture.snapshot.clone(); + for node in trimmed.nodes.values_mut() { + let Some(segments) = node + .fields + .get_mut("styledTextSegments") + .and_then(Value::as_array_mut) + else { + continue; + }; + if segments.len() != 1 { + continue; + } + single_segment_nodes += 1; + if let Some(only) = segments[0].as_object_mut() { + only.retain(|key, _| SEGMENT_ONLY_KEYS.contains(&key.as_str())); + } + } + + assert_eq!( + before, + tsx(&trimmed, &root_id), + "deduping the lone text segment changed the TSX for screen {root_id}" + ); + } + + assert!( + single_segment_nodes > 200, + "expected the fixtures to cover >200 single-segment text nodes, saw {single_segment_nodes}" + ); +} + +#[test] +fn presence_sensitive_fields_are_never_omitted() { + // Each of these takes a different branch when absent than when present at + // its default, so the script must keep them verbatim. + for field in NULL_SENSITIVE_FIELDS { + assert!(!is_omittable(field, &Value::Null), "{field} must survive"); + } + for (field, value) in [ + ("opacity", serde_json::json!(1)), + ("visible", serde_json::json!(true)), + ("layoutPositioning", serde_json::json!("AUTO")), + ("topLeftRadius", serde_json::json!(0)), + ("topRightRadius", serde_json::json!(0)), + ("bottomLeftRadius", serde_json::json!(0)), + ("bottomRightRadius", serde_json::json!(0)), + ("strokeWeight", serde_json::json!(1)), + ("strokeTopWeight", serde_json::json!(1)), + ("strokeRightWeight", serde_json::json!(1)), + ("strokeBottomWeight", serde_json::json!(1)), + ("strokeLeftWeight", serde_json::json!(1)), + ] { + assert!(!is_omittable(field, &value), "{field} must survive"); + } +} + +#[test] +fn every_null_field_the_fixtures_contain_is_classified_deliberately() { + // A future manifest addition that shows up as null must be judged, not + // silently swept into the blanket null rule. + let mut null_fields = std::collections::BTreeSet::new(); + for fixture in fixtures() { + for node in fixture.snapshot.nodes.values() { + for (field, value) in &node.fields { + if value.is_null() { + null_fields.insert(field.clone()); + } + } + } + } + let known = [ + "componentPropertyReferences", + "inferredAutoLayout", + "maxHeight", + "maxWidth", + "minHeight", + "minWidth", + "targetAspectRatio", + "variantProperties", + ]; + let unexpected = null_fields + .iter() + .filter(|field| !known.contains(&field.as_str())) + .cloned() + .collect::>(); + assert!( + unexpected.is_empty(), + "unclassified null-valued fields appeared: {unexpected:?}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/effect_fidelity_golden.rs b/crates/devup-mcp-devup-ui/tests/effect_fidelity_golden.rs new file mode 100644 index 00000000..68242b09 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/effect_fidelity_golden.rs @@ -0,0 +1,264 @@ +//! `DEVUP_CODEGEN_EFFECT_FALLBACK` must describe what actually happened. +//! +//! The diagnostic used to fire whenever a node merely *had* an `effects` +//! array, without asking whether those effects converted. Because a drop +//! shadow is ubiquitous, that made `projection: lossy` -- and therefore +//! `status: partial` -- unavoidable for essentially every real design, which +//! in turn made `strict: true` unusable. +//! +//! The first test is the real `3997:47759` node from `A : STORY-SUBSEL` +//! (`85CgSws3o5XsLv7aAwWJyS`): a `BACKGROUND_BLUR` plus a `DROP_SHADOW`, both +//! of which `push_effects` converts exactly, to +//! `backdropFilter="blur(8px)"` and `boxShadow="0 4px 12px 0 #0000001A"`. +//! +//! The remaining tests pin the effects that genuinely cannot be expressed, so +//! tightening the guard cannot silently under-report real infidelity. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_node}; +use devup_mcp_figma::{RawNode, SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn frame_with_effects(effects: Value) -> Value { + json!({ + "id": "node:1", + "type": "FRAME", + "fields": { + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "effects": effects, + "height": 146, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 1321315031", + "visible": true, + "width": 240, + "x": 0, + "y": 0 + } + }) +} + +fn text_with_effects(effects: Value) -> Value { + json!({ + "id": "node:1", + "type": "TEXT", + "fields": { + "characters": "shadowed", + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "effects": effects, + "fontName": { "family": "Pretendard", "style": "Regular" }, + "fontSize": 15, + "height": 24, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "shadowed", + "textAutoResize": "WIDTH_AND_HEIGHT", + "visible": true, + "width": 80, + "x": 0, + "y": 0 + } + }) +} + +fn drop_shadow(extra: Value) -> Value { + let mut shadow = json!({ + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "a": 0.100_000_001_490_116_12, "b": 0, "g": 0, "r": 0 }, + "offset": { "x": 0, "y": 4 }, + "radius": 12, + "showShadowBehindNode": false, + "spread": 0, + "type": "DROP_SHADOW", + "visible": true + }); + let object = shadow.as_object_mut().expect("shadow is an object"); + for (key, value) in extra.as_object().expect("extra is an object") { + object.insert(key.clone(), value.clone()); + } + shadow +} + +const BACKGROUND_BLUR: fn() -> Value = || { + json!({ + "blurType": "NORMAL", + "boundVariables": {}, + "radius": 8, + "type": "BACKGROUND_BLUR", + "visible": true + }) +}; + +struct Rendered { + tsx: String, + reported_lossy: bool, +} + +fn render(node: Value) -> Rendered { + let node = serde_json::from_value::(node).expect("node deserializes"); + let snapshot = merge_chunks(vec![SnapshotChunk { + file_key: "85CgSws3o5XsLv7aAwWJyS".to_owned(), + version: None, + root_ids: vec!["node:1".to_owned()], + nodes: vec![node], + diagnostics: Vec::new(), + }]) + .expect("snapshot merges"); + let output = + generate_node(&snapshot, "node:1", &CodegenOptions::default()).expect("codegen succeeds"); + let reported_lossy = output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "DEVUP_CODEGEN_EFFECT_FALLBACK"); + Rendered { + tsx: output.tsx, + reported_lossy, + } +} + +#[test] +fn effects_that_convert_exactly_are_not_reported_lossy() { + let rendered = render(frame_with_effects(json!([ + BACKGROUND_BLUR(), + drop_shadow(json!({})) + ]))); + + // Both effects really did land in the output, so the claim below is about + // a converted node rather than an empty one. + assert!( + rendered.tsx.contains(r#"backdropFilter="blur(8px)""#), + "BACKGROUND_BLUR should convert; got:\n{}", + rendered.tsx + ); + assert!( + rendered + .tsx + .contains(r#"boxShadow="0 4px 12px 0 #0000001A""#), + "DROP_SHADOW should convert; got:\n{}", + rendered.tsx + ); + assert!( + !rendered.reported_lossy, + "both effects converted exactly, so EFFECT_FALLBACK must not fire -- \ + otherwise any design with a shadow can never reach status=complete" + ); +} + +#[test] +fn a_lone_layer_blur_is_not_reported_lossy() { + let rendered = render(frame_with_effects(json!([{ + "radius": 4, "type": "LAYER_BLUR", "visible": true + }]))); + assert!(rendered.tsx.contains(r#"filter="blur(4px)""#)); + assert!(!rendered.reported_lossy); +} + +#[test] +fn a_blur_without_a_radius_is_reported_lossy() { + // `push_effects` reads the radius with `unwrap_or(0.0)`, so a missing one + // is silently fabricated into `blur(0px)` -- the blur is gone, not converted. + assert!( + render(frame_with_effects(json!([{ + "type": "BACKGROUND_BLUR", "visible": true + }]))) + .reported_lossy + ); + assert!( + render(frame_with_effects(json!([{ + "type": "LAYER_BLUR", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn noise_is_still_reported_lossy() { + // Converted to a no-op `contrast(100%) brightness(100%)` placeholder. + assert!( + render(frame_with_effects(json!([{ + "type": "NOISE", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn texture_is_still_reported_lossy() { + assert!( + render(frame_with_effects(json!([{ + "type": "TEXTURE", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn glass_is_still_reported_lossy() { + // Flattened to a plain backdrop blur, which is an approximation. + assert!( + render(frame_with_effects(json!([{ + "radius": 8, "type": "GLASS", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn an_unknown_effect_type_is_still_reported_lossy() { + // Silently dropped by `push_effects`; that must stay visible. + assert!( + render(frame_with_effects(json!([{ + "radius": 8, "type": "SOME_FUTURE_EFFECT", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn an_invisible_unsupported_effect_is_not_reported_lossy() { + // `push_effects` skips invisible effects, so nothing was lost. + assert!( + !render(frame_with_effects(json!([{ + "type": "NOISE", "visible": false + }]))) + .reported_lossy + ); +} + +#[test] +fn a_shadow_with_a_non_normal_blend_mode_is_reported_lossy() { + // CSS box-shadow has no per-shadow blend mode. + assert!( + render(frame_with_effects(json!([drop_shadow( + json!({ "blendMode": "MULTIPLY" }) + )]))) + .reported_lossy + ); +} + +#[test] +fn a_text_shadow_that_needs_spread_is_reported_lossy() { + // `text-shadow` has no spread component, so a non-zero spread is dropped. + let rendered = render(text_with_effects(json!([drop_shadow( + json!({ "spread": 4 }) + )]))); + assert!(rendered.tsx.contains("textShadow=")); + assert!( + rendered.reported_lossy, + "spread cannot survive in text-shadow and must be reported" + ); +} + +#[test] +fn a_text_shadow_without_spread_is_not_reported_lossy() { + let rendered = render(text_with_effects(json!([drop_shadow(json!({}))]))); + assert!(rendered.tsx.contains("textShadow=")); + assert!(!rendered.reported_lossy); +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json b/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json new file mode 100644 index 00000000..598ea8d0 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json @@ -0,0 +1,544 @@ +{ + "note": "Both node sets were collected read-only from the same live Figma node (file 85CgSws3o5XsLv7aAwWJyS, node 3997:47467) in the same session on 2026-09-03. `legacy` uses the pre-trim collection semantics (133-field manifest + prototype-chain walk into `extra`, no default omission); `trimmed` uses the shipped 77-field manifest with default omission. Only design node/text values are stored - no tokens, headers or account data.", + "fileKey": "85CgSws3o5XsLv7aAwWJyS", + "rootId": "3997:47467", + "legacyUtf8Bytes": 23311, + "trimmedUtf8Bytes": 3862, + "legacy": [ + { + "id": "3997:47467", + "type": "FRAME", + "fields": { + "parentId": "4279:7804", + "childrenIds": ["3997:47468"], + "absoluteBoundingBox": { "height": 97, "width": 9605, "x": 14422, "y": 18313 }, + "absoluteRenderBounds": { "height": 97, "width": 9605, "x": 14422, "y": 18313 }, + "annotations": [], + "attachedConnectors": [], + "backgroundStyleId": "", + "backgrounds": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 0.4038458466529846, "g": 0.3634612560272217, "r": 0 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "blendMode": "PASS_THROUGH", + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": {}, + "clipsContent": false, + "componentPropertyReferences": null, + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "cornerRadius": 0, + "cornerSmoothing": 0, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "dashPattern": [], + "detachedInfo": null, + "effectStyleId": "", + "effects": [], + "expanded": false, + "explicitVariableModes": {}, + "exportSettings": [], + "fillStyleId": "", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 0.4038458466529846, "g": 0.3634612560272217, "r": 0 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "gridColumnAnchorIndex": -1, + "gridColumnCount": 0, + "gridColumnGap": 0, + "gridColumnSpan": 1, + "gridRowAnchorIndex": -1, + "gridRowCount": 0, + "gridRowGap": 0, + "gridRowSpan": 1, + "gridStyleId": "", + "guides": [], + "height": 97, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": false, + "isMask": false, + "itemReverseZIndex": false, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrids": [], + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "layoutWrap": "NO_WRAP", + "locked": false, + "maskType": "ALPHA", + "maxHeight": null, + "maxWidth": null, + "minHeight": null, + "minWidth": null, + "name": "[FR-03~06] 체험하기", + "numberOfFixedChildren": 0, + "opacity": 1, + "overflowDirection": "NONE", + "overlayBackground": { "type": "NONE" }, + "overlayBackgroundInteraction": "NONE", + "overlayPositionType": "CENTER", + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "reactions": [], + "relativeTransform": [[1, 0, 93], [0, 1, 178]], + "removed": false, + "resolvedVariableModes": {}, + "rotation": 0, + "strokeAlign": "INSIDE", + "strokeBottomWeight": 1, + "strokeCap": "NONE", + "strokeJoin": "MITER", + "strokeLeftWeight": 1, + "strokeMiterLimit": 4, + "strokeRightWeight": 1, + "strokeStyleId": "", + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokes": [], + "stuckNodes": [], + "targetAspectRatio": null, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 9605, + "x": 93, + "y": 178 + }, + "extra": { + "absoluteTransform": [[1, 0, 14422], [0, 1, 18313]], + "animationStyles": [], + "animations": {}, + "availableInferredVariables": {}, + "complexStrokeProperties": { "type": "BASIC" }, + "constrainProportions": false, + "counterAxisSpacing": 0, + "fillGeometry": [ + { "data": "M0 0 L9605 0 L9605 97 L0 97 L0 0 Z", "windingRule": "NONZERO" } + ], + "gridAutoTracks": "NONE", + "gridChildHorizontalAlign": "AUTO", + "gridChildVerticalAlign": "AUTO", + "gridColumnSizes": [], + "gridColumnSizingCSS": "", + "gridItemsPositioning": "MANUAL", + "gridRowSizes": [], + "gridRowSizingCSS": "", + "horizontalPadding": 20, + "inferredVariables": {}, + "manualKeyframeTracks": {}, + "node": { "$nodeId": "3997:47467", "$nodeType": "FRAME" }, + "placeholder": false, + "playbackSettings": { "autoplay": true, "loop": true, "muted": false }, + "primaryAxisSizingMode": "FIXED", + "strokeGeometry": [], + "strokesIncludedInLayout": false, + "timelines": [{ "duration": 2, "id": "3997:47467" }], + "variableConsumptionMap": {}, + "variableWidthStrokeProperties": { + "variableWidthPoints": [], + "widthProfile": "UNIFORM" + }, + "verticalPadding": 20 + }, + "fieldErrors": { + "devStatus": "in get_devStatus: \"devStatus\" is not a supported API", + "isClip": "in get_isClip: \"isClip\" is not a supported API", + "isClipBackedComponentInstance": "in get_isClipBackedComponentInstance: \"isClipBackedComponentInstance\" is not a supported API", + "rotationOrigin": "in get_rotationOrigin: \"rotationOrigin\" is not a supported API", + "widgetHoverStyle": "in get_widgetHoverStyle: \"widgetHoverStyle\" is not a supported API" + } + }, + { + "id": "3997:47468", + "type": "TEXT", + "fields": { + "parentId": "3997:47467", + "childrenIds": [], + "absoluteBoundingBox": { "height": 57, "width": 450, "x": 14442, "y": 18333 }, + "absoluteRenderBounds": { + "height": 48.28125, + "width": 440.34375, + "x": 14447.0625, + "y": 18340.28125 + }, + "annotations": [], + "attachedConnectors": [], + "blendMode": "PASS_THROUGH", + "boundVariables": {}, + "characters": "[FR-03~06] 체험하기", + "componentPropertyReferences": null, + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "dashPattern": [], + "detachedInfo": null, + "effectStyleId": "", + "effects": [], + "explicitVariableModes": {}, + "exportSettings": [], + "fillStyleId": "", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 1, "g": 1, "r": 1 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { "family": "Pretendard", "style": "Bold" }, + "fontSize": 48, + "gridColumnAnchorIndex": -1, + "gridColumnSpan": 1, + "gridRowAnchorIndex": -1, + "gridRowSpan": 1, + "height": 57, + "hyperlink": null, + "isAsset": false, + "isMask": false, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { "unit": "PERCENT", "value": 0 }, + "lineHeight": { "unit": "AUTO" }, + "locked": false, + "maskType": "ALPHA", + "maxHeight": null, + "maxWidth": null, + "minHeight": null, + "minWidth": null, + "name": "[FR-03~06] 체험하기", + "opacity": 1, + "paragraphIndent": 0, + "paragraphSpacing": 0, + "reactions": [], + "relativeTransform": [[1, 0, 20], [0, 1, 20]], + "removed": false, + "resolvedVariableModes": {}, + "rotation": 0, + "strokeAlign": "OUTSIDE", + "strokeCap": "NONE", + "strokeJoin": "MITER", + "strokeMiterLimit": 4, + "strokeStyleId": "", + "strokeWeight": 1, + "strokes": [], + "stuckNodes": [], + "targetAspectRatio": null, + "textAlignHorizontal": "CENTER", + "textAlignVertical": "TOP", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textCase": "ORIGINAL", + "textDecoration": "NONE", + "textStyleId": "", + "visible": true, + "width": 450, + "x": 20, + "y": 20, + "styledTextSegments": [ + { + "characters": "[FR-03~06] 체험하기", + "end": 15, + "fillStyleId": "", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 1, "g": 1, "r": 1 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { "family": "Pretendard", "style": "Bold" }, + "fontSize": 48, + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "letterSpacing": { "unit": "PERCENT", "value": 0 }, + "lineHeight": { "unit": "AUTO" }, + "listOptions": { "type": "NONE" }, + "start": 0, + "textCase": "ORIGINAL", + "textDecoration": "NONE", + "textStyleId": "" + } + ] + }, + "extra": { + "absoluteTransform": [[1, 0, 14442], [0, 1, 18333]], + "animationStyles": [], + "animations": {}, + "autoRename": true, + "availableInferredVariables": { + "fills": [ + [ + { "id": "VariableID:1:1000", "type": "VARIABLE_ALIAS" }, + { "id": "VariableID:1:1006", "type": "VARIABLE_ALIAS" }, + { "id": "VariableID:68:6122", "type": "VARIABLE_ALIAS" } + ] + ] + }, + "canUpgradeToNativeBidiSupport": false, + "complexStrokeProperties": { "type": "BASIC" }, + "constrainProportions": false, + "fontWeight": 700, + "gridChildHorizontalAlign": "AUTO", + "gridChildVerticalAlign": "AUTO", + "hangingList": false, + "hangingPunctuation": false, + "hasMissingFont": true, + "inferredVariables": { + "fills": [ + [ + { + "id": "VariableID:b1ac3f2d99f4f584780a1b02b0bdf70873612d7d/2324:176", + "type": "VARIABLE_ALIAS" + }, + { "id": "VariableID:1:1000", "type": "VARIABLE_ALIAS" }, + { "id": "VariableID:1:1006", "type": "VARIABLE_ALIAS" }, + { "id": "VariableID:68:6122", "type": "VARIABLE_ALIAS" }, + { + "id": "VariableID:fc5c8b3838fdbe6abf30bcbc881a5a6c2da71856/155:1", + "type": "VARIABLE_ALIAS" + }, + { + "id": "VariableID:767f04d30caf20c0c878e8546732b78b74fb70e1/156:471", + "type": "VARIABLE_ALIAS" + }, + { + "id": "VariableID:a01f35bf66e644ecb6fb9343dbef6146ccf77918/155:11", + "type": "VARIABLE_ALIAS" + } + ] + ] + }, + "leadingTrim": "NONE", + "listSpacing": 0, + "manualKeyframeTracks": {}, + "maxLines": null, + "node": { "$nodeId": "3997:47468", "$nodeType": "TEXT" }, + "openTypeFeatures": {}, + "placeholder": false, + "playbackSettings": { "autoplay": true, "loop": true, "muted": false }, + "strokeGeometry": [], + "textDecorationColor": null, + "textDecorationOffset": null, + "textDecorationSkipInk": null, + "textDecorationStyle": null, + "textDecorationThickness": null, + "textTruncation": "DISABLED", + "textWrapStyle": "AUTO", + "timelines": [{ "duration": 2, "id": "3997:47467" }], + "variableConsumptionMap": {}, + "variableWidthStrokeProperties": { + "variableWidthPoints": [], + "widthProfile": "UNIFORM" + } + }, + "fieldErrors": { + "isClip": "in get_isClip: \"isClip\" is not a supported API", + "isClipBackedComponentInstance": "in get_isClipBackedComponentInstance: \"isClipBackedComponentInstance\" is not a supported API", + "rotationOrigin": "in get_rotationOrigin: \"rotationOrigin\" is not a supported API", + "widgetHoverStyle": "in get_widgetHoverStyle: \"widgetHoverStyle\" is not a supported API" + } + } + ], + "trimmed": [ + { + "id": "3997:47467", + "type": "FRAME", + "fields": { + "parentId": "4279:7804", + "childrenIds": ["3997:47468"], + "absoluteBoundingBox": { "height": 97, "width": 9605, "x": 14422, "y": 18313 }, + "blendMode": "PASS_THROUGH", + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": {}, + "clipsContent": false, + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "cornerRadius": 0, + "counterAxisAlignItems": "CENTER", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 0.4038458466529846, "g": 0.3634612560272217, "r": 0 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "gridColumnAnchorIndex": -1, + "gridColumnCount": 0, + "gridColumnGap": 0, + "gridRowAnchorIndex": -1, + "gridRowCount": 0, + "gridRowGap": 0, + "height": 97, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": false, + "isMask": false, + "itemSpacing": 10, + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "name": "[FR-03~06] 체험하기", + "opacity": 1, + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "rotation": 0, + "strokeAlign": "INSIDE", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 9605, + "x": 93, + "y": 178 + }, + "extra": {}, + "fieldErrors": {} + }, + { + "id": "3997:47468", + "type": "TEXT", + "fields": { + "parentId": "3997:47467", + "childrenIds": [], + "absoluteBoundingBox": { "height": 57, "width": 450, "x": 14442, "y": 18333 }, + "blendMode": "PASS_THROUGH", + "boundVariables": {}, + "characters": "[FR-03~06] 체험하기", + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 1, "g": 1, "r": 1 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { "family": "Pretendard", "style": "Bold" }, + "fontSize": 48, + "gridColumnAnchorIndex": -1, + "gridRowAnchorIndex": -1, + "height": 57, + "isAsset": false, + "isMask": false, + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { "unit": "PERCENT", "value": 0 }, + "lineHeight": { "unit": "AUTO" }, + "name": "[FR-03~06] 체험하기", + "opacity": 1, + "rotation": 0, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAlignVertical": "TOP", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textCase": "ORIGINAL", + "textDecoration": "NONE", + "visible": true, + "width": 450, + "x": 20, + "y": 20, + "styledTextSegments": [ + { + "characters": "[FR-03~06] 체험하기", + "end": 15, + "fillStyleId": "", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 1, "g": 1, "r": 1 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { "family": "Pretendard", "style": "Bold" }, + "fontSize": 48, + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "letterSpacing": { "unit": "PERCENT", "value": 0 }, + "lineHeight": { "unit": "AUTO" }, + "listOptions": { "type": "NONE" }, + "start": 0, + "textCase": "ORIGINAL", + "textDecoration": "NONE", + "textStyleId": "" + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + ], + "expectedTsx": "import { Flex, Text } from \"@devup-ui/react\";\n\nexport function Fr0306체험하기() {\n return (\n \n \n [FR-03~06] 체험하기\n \n \n );\n}\n" +} diff --git a/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs b/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs new file mode 100644 index 00000000..ed922112 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs @@ -0,0 +1,61 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::json; + +#[test] +fn fixed_non_square_frame_folded_into_mask_keeps_its_size() { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": ["1:root"], + "nodes": [ + { + "id": "1:root", "type": "FRAME", + "fields": { + "name": "Screen", "childrenIds": ["1:logo"], + "width": 100, "height": 100, + "fills": [{ + "type": "SOLID", "visible": true, + "color": {"r": 1, "g": 1, "b": 1} + }] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:logo", "type": "FRAME", + "fields": { + "name": "BI Logo", "parentId": "1:root", "childrenIds": ["1:vector"], + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "layoutPositioning": "ABSOLUTE", "width": 24, "height": 9, + "x": 64, "y": 79, + "targetAspectRatio": {"x": 79.9, "y": 29.9} + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:vector", "type": "VECTOR", + "fields": { + "name": "BI Logo Vector", "parentId": "1:logo", "childrenIds": [], + "fills": [{ + "type": "SOLID", "visible": true, + "color": {"r": 0, "g": 0, "b": 0} + }] + }, + "extra": {}, "fieldErrors": {} + } + ], + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + let tsx = generate_component(&snapshot, "1:root", &CodegenOptions::default()) + .expect("codegen") + .tsx; + + assert!(tsx.contains("maskImage=\"url('/icons/BI Logo.svg')\"")); + assert!( + tsx.contains("h=\"9px\"") && tsx.contains("w=\"24px\""), + "folded mask lost its fixed dimensions:\n{tsx}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/free_placement_anchor.rs b/crates/devup-mcp-devup-ui/tests/free_placement_anchor.rs new file mode 100644 index 00000000..809438b6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/free_placement_anchor.rs @@ -0,0 +1,418 @@ +//! A frame without auto-layout places its children itself. +//! +//! Where the gap around them can be measured it becomes padding, which puts +//! them where they belong. Where nothing can be measured — the child fills the +//! frame, or carries no position of its own — the containing block is still +//! what keeps the child resolvable. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +#[test] +fn a_measurable_inset_becomes_padding_and_needs_no_anchor() { + let tsx = generate( + "1:panel", + json!([ + { + "id": "1:panel", "type": "FRAME", + "fields": { + "name": "Panel", "childrenIds": ["1:book"], + "layoutMode": "NONE", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 360.0, "height": 240.0, + "paddingTop": 10.0, "paddingRight": 10.0, + "paddingBottom": 10.0, "paddingLeft": 10.0, + "parentId": "0:page", "parentType": "SECTION" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:book", "type": "FRAME", + "fields": { + "name": "Book", "parentId": "1:panel", "childrenIds": [], + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 129.0, "height": 200.0, "x": 116.0, "y": 20.0 + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + // The stale padding fields say 10 on every side; the child's real position + // says otherwise, and 116 + 129 + 115 returns the frame's own 360. + assert!(tsx.contains("pl=\"116px\""), "{tsx}"); + assert!(tsx.contains("pr=\"115px\""), "{tsx}"); + assert!(tsx.contains("py=\"20px\""), "{tsx}"); + assert!( + !tsx.contains("p=\"10px\""), + "stale padding must not survive: {tsx}" + ); + assert!( + !tsx.contains("pos=\"relative\""), + "padding already places the child: {tsx}" + ); +} + +#[test] +fn a_child_that_fills_its_frame_keeps_the_anchor() { + let tsx = generate( + "1:icon", + json!([ + { + "id": "1:icon", "type": "FRAME", + "fields": { + "name": "Social", "childrenIds": ["1:layer"], + "layoutMode": "NONE", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 32.0, "height": 32.0, + "fills": [{"type": "SOLID", "visible": true, "color": {"r": 1.0, "g": 1.0, "b": 1.0}}], + "parentId": "0:row", "parentType": "FRAME" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:layer", "type": "GROUP", + "fields": { + "name": "Layer 2", "parentId": "1:icon", "childrenIds": [], + "layoutPositioning": "AUTO", + "width": 32.0, "height": 32.0 + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + // No position to measure, so nothing became padding and the anchor stays. + assert!( + tsx.contains("pos=\"relative\""), + "an unmeasurable placement still needs its containing block: {tsx}" + ); +} + +/// A group pinned into a card, holding shapes at their own coordinates. Three +/// things went wrong at once on the devup-ui landing page's join-us panel, and +/// the arcs and badges it draws were simply not there: +/// +/// - the group is `ABSOLUTE`, so its children were read as being in flow and +/// stacked from its corner, clipped away; +/// - once placed, the group was told `pos="relative"` for holding positioned +/// children, over the `absolute` it already had, and took 1,102px of page; +/// - a group's children carry `x` and `y` in the group's parent's space, and +/// placed as read every circle sat 277px left and 187px high of Figma. +/// +/// A shape in a group also keeps its own size: `h="100%"` and no width, which +/// is the plugin's rule for a positioned shape, is no circle at all. +#[test] +fn shapes_in_a_pinned_group_are_placed_in_the_group_at_their_own_size() { + let tsx = generate( + "1:card", + json!([ + { + "id": "1:card", "type": "FRAME", + "fields": { + "name": "card", "childrenIds": ["1:group", "1:title"], + "layoutMode": "HORIZONTAL", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 1440.0, "height": 356.0, "clipsContent": true, + "absoluteBoundingBox": {"x": 1000.0, "y": 2000.0, "width": 1440.0, "height": 356.0}, + "parentId": "0:page", "parentType": "SECTION" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:group", "type": "GROUP", + "fields": { + "name": "Group 2", "parentId": "1:card", + "childrenIds": ["1:outer", "1:badge"], + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 1102.0, "height": 1102.0, "x": -277.0, "y": -187.0, + "absoluteBoundingBox": {"x": 723.0, "y": 1813.0, "width": 1102.0, "height": 1102.0}, + "constraints": {"horizontal": "MIN", "vertical": "MIN"} + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:outer", "type": "ELLIPSE", + "fields": { + "name": "Ellipse 6", "parentId": "1:group", "childrenIds": [], + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 1102.0, "height": 1102.0, "x": -277.0, "y": -187.0, + "absoluteBoundingBox": {"x": 723.0, "y": 1813.0, "width": 1102.0, "height": 1102.0}, + "constraints": {"horizontal": "MIN", "vertical": "MIN"}, + "arcData": {"startingAngle": 0, "endingAngle": 6.0, "innerRadius": 0}, + "strokes": [{"type": "SOLID", "visible": true, "color": {"r": 1, "g": 1, "b": 1}, "opacity": 0.4}], + "strokeWeight": 4.0, "strokeAlign": "INSIDE" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:badge", "type": "ELLIPSE", + "fields": { + "name": "Ellipse 9", "parentId": "1:group", "childrenIds": [], + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 130.0, "height": 130.0, "x": 376.0, "y": 12.0, + "absoluteBoundingBox": {"x": 1376.0, "y": 2012.0, "width": 130.0, "height": 130.0}, + "constraints": {"horizontal": "MIN", "vertical": "MIN"}, + "arcData": {"startingAngle": 0, "endingAngle": 6.0, "innerRadius": 0}, + "fills": [{"type": "SOLID", "visible": true, "color": {"r": 0.15, "g": 0.42, "b": 0.8}}] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:title", "type": "TEXT", + "fields": { + "name": "title", "parentId": "1:card", "childrenIds": [], + "characters": "Join our community", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", "layoutSizingVertical": "HUG", + "width": 300.0, "height": 40.0, + "absoluteBoundingBox": {"x": 1600.0, "y": 2100.0, "width": 300.0, "height": 40.0} + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + // The group stays pinned - not put back in flow for holding positioned + // children - and the card is what holds it. + assert!( + tsx.contains("left=\"-277px\"") && tsx.contains("top=\"-187px\""), + "the group keeps its place in the card: {tsx}" + ); + let group_line = tsx + .lines() + .find(|line| line.contains("left=\"-277px\"")) + .expect("the group's own line"); + assert!( + group_line.contains("pos=\"absolute\"") && !group_line.contains("relative"), + "the group is not told `relative` over the `absolute` it already has: {group_line}" + ); + // The badge is at 653,199 inside the group - its absolute box against the + // group's - not at the 376,12 it reads in the card's space, and it is + // 130px, not `h="100%"` with no width. + assert!( + tsx.contains("left=\"653px\""), + "the badge is placed in the group's space: {tsx}" + ); + assert!(tsx.contains("top=\"199px\""), "{tsx}"); + assert!( + tsx.contains("boxSize=\"130px\""), + "a shape in a group is its own size: {tsx}" + ); + // The outermost circle coincides with the group, so it sits at 0,0 and + // fills it. + assert!(tsx.contains("left=\"0px\""), "{tsx}"); + assert!( + !tsx.contains("left=\"376px\""), + "the card-space coordinate must not leak through: {tsx}" + ); +} + +/// Figma paints children in order; CSS paints a positioned element after +/// every in-flow sibling whatever the order. A pinned picture drawn first is +/// under everything in Figma and over everything in CSS - the landing page's +/// hero sat on its headline and the join-us badges on their buttons - so it +/// is sent behind with `zIndex="-1"`, inside a stacking context the parent +/// opens with `zIndex="0"` so it still clears the parent's own background. +/// +/// Only a child at the very bottom. `-1` goes behind *every* in-flow +/// sibling, so a pinned header drawn second, after its banner, is left +/// alone: sent behind, the notice page's header vanished under the banner it +/// sits on. +#[test] +fn a_pinned_child_drawn_first_goes_behind_the_content_but_one_drawn_second_does_not() { + let hero_first = generate( + "1:screen", + json!([ + { + "id": "1:screen", "type": "FRAME", + "fields": { + "name": "screen", "childrenIds": ["1:section"], + "layoutMode": "VERTICAL", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "HUG", + "width": 1440.0, "height": 540.0, + "parentId": "0:page", "parentType": "SECTION" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:section", "type": "FRAME", + "fields": { + "name": "section", "parentId": "1:screen", + "childrenIds": ["1:picture", "1:headline"], + "layoutMode": "VERTICAL", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", "layoutSizingVertical": "HUG", + "width": 1440.0, "height": 540.0 + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:picture", "type": "RECTANGLE", + "fields": { + "name": "picture", "parentId": "1:section", "childrenIds": [], + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 600.0, "height": 600.0, "x": 700.0, "y": -100.0, + "constraints": {"horizontal": "MIN", "vertical": "MIN"}, + "fills": [{"type": "SOLID", "visible": true, "color": {"r": 0.5, "g": 0.5, "b": 1}}] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:headline", "type": "TEXT", + "fields": { + "name": "headline", "parentId": "1:section", "childrenIds": [], + "characters": "Zero Config", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", "layoutSizingVertical": "HUG", + "width": 500.0, "height": 120.0 + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + assert!( + hero_first.contains("zIndex=\"-1\""), + "the picture goes behind: {hero_first}" + ); + assert!( + hero_first.contains("zIndex=\"0\""), + "the section holds it: {hero_first}" + ); + + let header_second = generate( + "1:page", + json!([ + { + "id": "1:page", "type": "FRAME", + "fields": { + "name": "page", "childrenIds": ["1:banner", "1:header", "1:body"], + "layoutMode": "VERTICAL", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "HUG", + "width": 360.0, "height": 1215.0, + "parentId": "0:page", "parentType": "SECTION" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:banner", "type": "FRAME", + "fields": { + "name": "banner", "parentId": "1:page", "childrenIds": [], + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", "layoutSizingVertical": "FIXED", + "width": 360.0, "height": 320.0, + "fills": [{"type": "SOLID", "visible": true, "color": {"r": 0, "g": 0.2, "b": 0.7}}] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:header", "type": "FRAME", + "fields": { + "name": "header", "parentId": "1:page", "childrenIds": [], + "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 360.0, "height": 60.0, "x": 0.0, "y": 0.0, + "constraints": {"horizontal": "MIN", "vertical": "MIN"} + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:body", "type": "TEXT", + "fields": { + "name": "body", "parentId": "1:page", "childrenIds": [], + "characters": "notice", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", "layoutSizingVertical": "HUG", + "width": 200.0, "height": 40.0 + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + assert!( + !header_second.contains("zIndex="), + "a header pinned over its banner stays where CSS puts it: {header_second}" + ); +} + +/// An export carries the node's own opacity - Figma writes it into an SVG +/// as `` and into a PNG's alpha; the landing page's hero at 0.8 +/// exports with its opaque pixels at alpha 204 - so it is not written on the +/// element as well. Written twice, a decoration at 0.2 came out at 0.04. A +/// node that is not an asset still carries its own. +#[test] +fn an_asset_is_not_given_the_opacity_its_export_already_carries() { + let tsx = generate( + "1:card", + json!([ + { + "id": "1:card", "type": "FRAME", + "fields": { + "name": "card", "childrenIds": ["1:picture", "1:veil"], + "layoutMode": "VERTICAL", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "HUG", + "width": 400.0, "height": 300.0, + "parentId": "0:page", "parentType": "SECTION" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:picture", "type": "RECTANGLE", + "fields": { + "name": "picture", "parentId": "1:card", "childrenIds": [], + "isAsset": true, "opacity": 0.8, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 200.0, "height": 200.0, + "fills": [{"type": "IMAGE", "scaleMode": "FILL", "imageHash": "hash-1", "visible": true}] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:veil", "type": "FRAME", + "fields": { + "name": "veil", "parentId": "1:card", "childrenIds": [], + "opacity": 0.5, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FILL", "layoutSizingVertical": "FIXED", + "width": 400.0, "height": 40.0, + "fills": [{"type": "SOLID", "visible": true, "color": {"r": 0, "g": 0, "b": 0}}] + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + let picture = tsx + .lines() + .find(|line| line.contains("/images/picture")) + .expect("the picture is an image"); + assert!( + !picture.contains("opacity="), + "the export already carries 0.8: {picture}" + ); + assert!( + tsx.contains("opacity=\"0.5\""), + "a frame that is not an asset keeps its own opacity: {tsx}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/keyframes_screen.rs b/crates/devup-mcp-devup-ui/tests/keyframes_screen.rs new file mode 100644 index 00000000..ae23b444 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/keyframes_screen.rs @@ -0,0 +1,177 @@ +//! A timed Smart Animate, against what the plugin wrote for it. +//! +//! `fixtures/plugin-answers/keyframes/pure.tsx` is the plugin's answer for +//! `devup-Test`'s frame `458:2021`, a loading spinner drawn as eight frames +//! that Smart-Animate to one another on a timer and back to the first. The +//! capture beside it, `fixtures/local-screens/keyframes.json`, holds all +//! eight because the snapshot script follows the chain; it is not committed, +//! so these skip when it is absent rather than pretending to have checked. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::{ + codegen::{CodegenOptions, generate_component}, + ui_validate::{Severity, validate_devup_ui_tsx}, +}; +use devup_mcp_figma::{Snapshot, UpstreamResult}; + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures") +} + +fn ours() -> Option { + let raw = fs::read_to_string(fixtures().join("local-screens/keyframes.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let snapshot: Snapshot = serde_json::from_value(value.get("snapshot")?.clone()).ok()?; + let resource = |name: &str| { + value + .get("payload") + .and_then(|payload| payload.get(name)) + .cloned() + .map(|raw| UpstreamResult { raw }) + }; + let variables = resource("variables"); + let styles = resource("styles"); + let options = + CodegenOptions::default().with_resource_results(variables.as_ref(), styles.as_ref()); + let root = snapshot.roots.first()?.clone(); + let output = generate_component(&snapshot, &root, &options).ok()?; + assert!( + output + .diagnostics + .iter() + .all(|diagnostic| diagnostic.code != "DEVUP_CODEGEN_ANIMATION_UNREACHABLE"), + "every frame of the chain was collected: {:?}", + output.diagnostics + ); + Some(output.tsx) +} + +/// A file's lines as they are compared: indentation and blank lines dropped, +/// and an expression that spans lines — `animationName={keyframes({...})}` — +/// folded onto one. +fn comparable_lines(source: &str) -> Vec { + let mut lines = Vec::new(); + let mut open: Option<(String, i32)> = None; + for raw in source.lines() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let depth = line.chars().fold(0i32, |depth, character| match character { + '{' | '[' | '(' => depth + 1, + '}' | ']' | ')' => depth - 1, + _ => depth, + }); + match &mut open { + Some((folded, balance)) => { + folded.push(' '); + folded.push_str(line); + *balance += depth; + if *balance <= 0 { + lines.push(folded.clone()); + open = None; + } + } + None => { + if line.ends_with("({") && depth > 0 { + open = Some((line.to_owned(), depth)); + } else { + lines.push(line.to_owned()); + } + } + } + } + if let Some((folded, _)) = open { + lines.push(folded); + } + lines +} + +/// The lines on which this is written differently from the answer on +/// purpose. A 12px frame holding a 2px dot at its centre keeps its size; the +/// plugin writes none for a positioned frame with children, and the frame +/// collapses to the dot, which then lands 5px off. The 64px spinner frame, +/// a page root that places every dot absolutely, keeps its height for the +/// same reason: with nothing in flow the plugin's box has none. The answer +/// is the JSX alone, with no module around it. +const DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ + ("boxSize=\"12px\"", "a pinned size is a layout fact"), + ("h=\"64px\"", "a pinned size is a layout fact"), + ( + "import { Box, Flex, keyframes } from \"@devup-ui/react\";", + "the answer is the JSX alone", + ), + ("export function _1() {", "the answer is the JSX alone"), + ("return (", "the answer is the JSX alone"), + (");", "the answer is the JSX alone"), + ("}", "the answer is the JSX alone"), +]; + +/// The keyframes are the plugin's, to the percent and the pixel: what moves +/// from one frame of the chain to the next, at the moment it lands, the loop +/// closing at 100%, the duration counting the return, and no delay for a +/// timeout under 10ms. +#[test] +fn the_spinner_s_keyframes_are_the_plugin_s() { + let Some(tsx) = ours() else { + eprintln!("no keyframes capture; skipping"); + return; + }; + let Ok(answer) = fs::read_to_string(fixtures().join("plugin-answers/keyframes/pure.tsx")) + else { + eprintln!("no keyframes answer; skipping"); + return; + }; + let ours = comparable_lines(&tsx); + let theirs = comparable_lines(&answer); + let mut only_ours = ours.clone(); + for line in &theirs { + if let Some(index) = only_ours.iter().position(|other| other == line) { + only_ours.remove(index); + } + } + let mut only_theirs = theirs.clone(); + for line in &ours { + if let Some(index) = only_theirs.iter().position(|other| other == line) { + only_theirs.remove(index); + } + } + let unexplained = |lines: &[String]| { + lines + .iter() + .filter(|line| !DIFFERS_ON_PURPOSE.iter().any(|(known, _)| known == line)) + .cloned() + .collect::>() + }; + let ours_unexplained = unexplained(&only_ours); + let theirs_unexplained = unexplained(&only_theirs); + assert!( + ours_unexplained.is_empty() && theirs_unexplained.is_empty(), + "lines not accounted for.\n written here and not in the answer:\n {}\n in the answer and not here:\n {}", + ours_unexplained.join("\n "), + theirs_unexplained.join("\n ") + ); + assert!( + tsx.contains("animationName={keyframes({"), + "the keyframes are a call, not a string:\n{tsx}" + ); +} + +/// `keyframes({...})` holds only literals, and the animation props are ones +/// devup-ui takes; the validator the server offers must accept it. +#[test] +fn the_animated_module_passes_the_validator() { + let Some(tsx) = ours() else { + eprintln!("no keyframes capture; skipping"); + return; + }; + let report = validate_devup_ui_tsx(&tsx, None, false); + let errors = report + .violations + .iter() + .filter(|violation| violation.severity == Severity::Error) + .map(|violation| format!("{}: {}", violation.rule, violation.message)) + .collect::>(); + assert!(report.ok && errors.is_empty(), "{}", errors.join("\n")); +} diff --git a/crates/devup-mcp-devup-ui/tests/local_screens.rs b/crates/devup-mcp-devup-ui/tests/local_screens.rs new file mode 100644 index 00000000..010ec73b --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/local_screens.rs @@ -0,0 +1,92 @@ +//! Runs codegen over snapshots captured from a live Figma file. +//! +//! Figma meters tool calls, and one export spends about fifteen of them, so +//! checking a codegen change against a real screen used to cost allowance +//! every time — and ran out. These snapshots are captured once and replayed +//! for free, which is what makes it practical to see a change against real +//! designs rather than only synthetic nodes. +//! +//! They are scratch, not ground truth: the pinned corpus under +//! `fixtures/devup-figma-plugin` decides correctness, and this directory is +//! ignored by git. With nothing captured the test simply reports that and +//! passes, so a fresh checkout is never blocked on it. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::{ + codegen::{CodegenOptions, generate_component}, + provenance::validate_fidelity, +}; +use devup_mcp_figma::Snapshot; + +fn captured() -> Vec<(String, String, Snapshot)> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/local-screens"); + let Ok(entries) = fs::read_dir(&root) else { + return Vec::new(); + }; + let mut screens = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let raw = fs::read_to_string(&path).expect("captured screen"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("captured screen is json"); + let snapshot: Snapshot = + serde_json::from_value(value["snapshot"].clone()).expect("captured snapshot"); + let label = value["label"].as_str().unwrap_or("screen").to_owned(); + let root_id = snapshot.roots.first().cloned().expect("a captured root"); + screens.push((label, root_id, snapshot)); + } + screens +} + +#[test] +fn every_captured_screen_converts_and_accounts_for_itself() { + let screens = captured(); + if screens.is_empty() { + eprintln!( + "no captured screens in fixtures/local-screens; skipping. \ + Capture them from a live file to exercise this." + ); + return; + } + + let mut report = Vec::new(); + for (label, root_id, snapshot) in &screens { + // Matches how the server converts a screen. Without inlining, an + // instance stays a component reference and everything inside it goes + // unemitted, which reads as a huge shortfall that the real path does + // not have. + let options = CodegenOptions { + inline_instances: true, + ..CodegenOptions::default() + }; + let output = generate_component(snapshot, root_id, &options) + .unwrap_or_else(|error| panic!("{label} failed to convert: {error:?}")); + let fidelity = validate_fidelity(snapshot, root_id, &output) + .unwrap_or_else(|error| panic!("{label} failed fidelity: {error:?}")); + + assert!(fidelity.syntax_valid, "{label} produced unparseable TSX"); + assert!( + fidelity.uncovered_layout.is_empty(), + "{label} leaves layout facts unaccounted for: {:?}", + fidelity.uncovered_layout + ); + assert_eq!( + fidelity.text.covered, fidelity.text.total, + "{label} dropped text" + ); + + report.push(format!( + " {label}: {} chars, layout {}/{}, text {}/{}", + output.tsx.len(), + fidelity.layout.covered, + fidelity.layout.total, + fidelity.text.covered, + fidelity.text.total + )); + } + + eprintln!("captured screens:\n{}", report.join("\n")); +} diff --git a/crates/devup-mcp-devup-ui/tests/manifest_trim_golden.rs b/crates/devup-mcp-devup-ui/tests/manifest_trim_golden.rs new file mode 100644 index 00000000..5a525a0d --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/manifest_trim_golden.rs @@ -0,0 +1,130 @@ +//! Golden test for the 6th-round collection trim. +//! +//! Both node sets in the fixture were collected read-only from the *same* +//! live Figma node in the same session: `legacy` with the pre-trim semantics +//! (133-field manifest, prototype-chain walk into `extra`, no default +//! omission) and `trimmed` with the shipped 77-field manifest plus default +//! omission. Shrinking the manifest is only safe if the DevupUI converter +//! still produces byte-identical TSX from the smaller snapshot, which is +//! exactly what this pins. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_node}; +use devup_mcp_figma::{RawNode, SnapshotChunk, merge_chunks}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Golden { + file_key: String, + root_id: String, + legacy_utf8_bytes: usize, + trimmed_utf8_bytes: usize, + legacy: Vec, + trimmed: Vec, + expected_tsx: String, +} + +fn golden() -> Golden { + serde_json::from_str(include_str!("fixtures/manifest-trim-golden.json")) + .expect("manifest trim golden fixture") +} + +fn tsx(golden: &Golden, nodes: Vec) -> String { + let snapshot = merge_chunks(vec![SnapshotChunk { + file_key: golden.file_key.clone(), + version: None, + root_ids: vec![golden.root_id.clone()], + nodes, + diagnostics: Vec::new(), + }]) + .expect("snapshot merges"); + generate_node(&snapshot, &golden.root_id, &CodegenOptions::default()) + .expect("codegen succeeds") + .tsx +} + +/// `generate_node` emits the bare JSX body; the server wraps it in the +/// component shell recorded as `expectedTsx`. Compare them whitespace-insensitively. +fn without_whitespace(value: &str) -> String { + value.chars().filter(|c| !c.is_whitespace()).collect() +} + +#[test] +fn the_trimmed_manifest_produces_the_same_tsx_as_the_full_legacy_collection() { + let golden = golden(); + + let legacy_tsx = tsx(&golden, golden.legacy.clone()); + let trimmed_tsx = tsx(&golden, golden.trimmed.clone()); + + assert_eq!( + legacy_tsx, trimmed_tsx, + "trimming the collection manifest changed the converter's output" + ); + assert!( + without_whitespace(&golden.expected_tsx).contains(&without_whitespace(&trimmed_tsx)), + "generated JSX no longer matches the end-to-end TSX recorded from the live run:\n{trimmed_tsx}" + ); +} + +#[test] +fn the_recorded_end_to_end_tsx_carries_every_measured_design_value() { + // Values checked against the live Figma node: fill rgb(0, 0.36346, 0.40385) + // -> #005D67, 20px uniform padding, 9605px width, CENTER cross-axis + // alignment, white 48px Pretendard Bold text. + let expected = golden().expected_tsx; + for fragment in [ + "import { Flex, Text } from \"@devup-ui/react\";", + "alignItems=\"center\"", + "bg=\"#005D67\"", + "p=\"20px\"", + "w=\"9605px\"", + "color=\"#FFF\"", + "fontFamily=\"Pretendard\"", + "fontSize=\"48px\"", + "fontWeight=\"700\"", + "[FR-03~06] 체험하기", + ] { + assert!(expected.contains(fragment), "missing {fragment}"); + } +} + +#[test] +fn the_trimmed_collection_is_materially_smaller_for_the_same_node() { + let golden = golden(); + let node_count = golden.trimmed.len(); + assert_eq!(golden.legacy.len(), node_count); + + // Measured on the real node: 23,311 -> 3,862 bytes for two nodes, i.e. + // 11,655.5 -> 1,931 bytes per node. + let legacy_per_node = golden.legacy_utf8_bytes / node_count; + let trimmed_per_node = golden.trimmed_utf8_bytes / node_count; + assert!( + trimmed_per_node * 4 < legacy_per_node, + "expected at least a 4x reduction, got {legacy_per_node} -> {trimmed_per_node}" + ); +} + +#[test] +fn no_field_the_converter_reads_was_dropped_from_the_trimmed_nodes() { + let golden = golden(); + + // Every field the trimmed collection kept must still carry the same value + // it had under the full legacy collection - the trim may only ever remove + // fields, never change one. + for (legacy, trimmed) in golden.legacy.iter().zip(&golden.trimmed) { + assert_eq!(legacy.id, trimmed.id); + assert_eq!(legacy.node_type, trimmed.node_type); + for (field, value) in &trimmed.fields { + assert_eq!( + legacy.fields.get(field), + Some(value), + "field {field} on node {} changed under the trim", + trimmed.id + ); + } + assert!( + trimmed.extra.is_empty(), + "the trimmed collection must never populate `extra`" + ); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/paint_opacity_golden.rs b/crates/devup-mcp-devup-ui/tests/paint_opacity_golden.rs new file mode 100644 index 00000000..74d4c376 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/paint_opacity_golden.rs @@ -0,0 +1,185 @@ +//! Regression: a Figma SOLID paint's `opacity` must survive into the emitted +//! colour on **every** path, including the masked-asset path. +//! +//! Figma expresses a translucent solid two different ways: alpha inside +//! `color.a`, and a separate `opacity` on the paint. The effective alpha is the +//! product. `color_from_paint` does that multiplication; formatting +//! `paint["color"]` directly does not, and silently drops `opacity`. +//! +//! The nodes below are the real `3997:47765` / `3997:47766` pair captured +//! read-only from `85CgSws3o5XsLv7aAwWJyS` (the speech-bubble tail on +//! `A : STORY-SUBSEL`). Its VECTOR fill is rgb(0.2388, 0.0647, 0.0647) at +//! `opacity: 0.85`, i.e. `#3D1010` at 85% => `#3D1010D9`. The same paint on the +//! bubble body (`3997:47760`, not an asset) already rendered as `#3D1010D9`, +//! so the two paths disagreed on identical input. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_node}; +use devup_mcp_figma::{RawNode, SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +/// The masked-asset wrapper: `isAsset`, no fills of its own, one VECTOR child +/// that carries the colour. +fn mask_asset_node() -> Value { + json!({ + "id": "3997:47765", + "type": "FRAME", + "fields": { + "childrenIds": ["3997:47766"], + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "height": 10, + "isAsset": true, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 1321315298", + "visible": true, + "width": 40, + "x": 200, + "y": 136 + } + }) +} + +/// The VECTOR that owns the paint. `paint_opacity` is the only thing varied. +fn vector_child(paint_opacity: Value) -> Value { + json!({ + "id": "3997:47766", + "type": "VECTOR", + "fields": { + "parentId": "3997:47765", + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "fills": [{ + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.064_670_071_005_821_23, + "g": 0.064_670_071_005_821_23, + "r": 0.238_782_152_533_531_2 + }, + "opacity": paint_opacity, + "type": "SOLID", + "visible": true + }], + "height": 10, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector 13", + "strokeAlign": "CENTER", + "visible": true, + "width": 10, + "x": 0, + "y": 0 + } + }) +} + +/// A plain (non-asset) frame carrying the *same* paint directly. This is the +/// path that was already correct, and is what the asset path must agree with. +fn plain_frame_with_same_paint(paint_opacity: Value) -> Value { + json!({ + "id": "plain:1", + "type": "FRAME", + "fields": { + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "fills": [{ + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.064_670_071_005_821_23, + "g": 0.064_670_071_005_821_23, + "r": 0.238_782_152_533_531_2 + }, + "opacity": paint_opacity, + "type": "SOLID", + "visible": true + }], + "height": 10, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Plain", + "visible": true, + "width": 40, + "x": 0, + "y": 0 + } + }) +} + +fn tsx(root_id: &str, nodes: Vec) -> String { + let nodes = nodes + .into_iter() + .map(|node| serde_json::from_value::(node).expect("node deserializes")) + .collect::>(); + let snapshot = merge_chunks(vec![SnapshotChunk { + file_key: "85CgSws3o5XsLv7aAwWJyS".to_owned(), + version: None, + root_ids: vec![root_id.to_owned()], + nodes, + diagnostics: Vec::new(), + }]) + .expect("snapshot merges"); + generate_node(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen succeeds") + .tsx +} + +fn mask_tsx(paint_opacity: Value) -> String { + tsx( + "3997:47765", + vec![mask_asset_node(), vector_child(paint_opacity)], + ) +} + +/// Extracts the single `bg="..."` value so a failure reports the colour, not a +/// whole JSX blob. +fn bg_value(tsx: &str) -> String { + let start = tsx.find("bg=\"").expect("emitted a bg prop") + 4; + let rest = &tsx[start..]; + let end = rest.find('"').expect("bg prop terminates"); + rest[..end].to_owned() +} + +#[test] +fn masked_asset_bg_keeps_the_paint_opacity() { + let bg = bg_value(&mask_tsx(json!(0.850_000_023_841_785_9))); + assert_eq!( + bg, "#3D1010D9", + "0.85 paint opacity must survive as the alpha byte (0.85 * 255 = 217 = 0xD9); \ + dropping it renders the speech-bubble tail fully opaque" + ); +} + +#[test] +fn masked_asset_bg_omits_the_alpha_byte_when_the_paint_is_opaque() { + let bg = bg_value(&mask_tsx(json!(1.0))); + assert_eq!( + bg, "#3D1010", + "an opaque paint must not grow a redundant FF alpha byte" + ); +} + +#[test] +fn the_asset_path_and_the_plain_path_agree_on_the_same_paint() { + for opacity in [json!(1.0), json!(0.85), json!(0.5), json!(0.1)] { + let masked = bg_value(&mask_tsx(opacity.clone())); + let plain = bg_value(&tsx( + "plain:1", + vec![plain_frame_with_same_paint(opacity.clone())], + )); + assert_eq!( + masked, plain, + "identical paint (opacity {opacity}) must produce the identical colour \ + whether it is read through the masked-asset path or a plain fill" + ); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/pinned_size_restatement.rs b/crates/devup-mcp-devup-ui/tests/pinned_size_restatement.rs new file mode 100644 index 00000000..eb8c2068 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/pinned_size_restatement.rs @@ -0,0 +1,110 @@ +//! An absolutely positioned node states its pinned size only when nothing +//! else accounts for it. +//! +//! Where the gap around the children became padding, that padding and the +//! content already add back up to the frame. Where the node was folded into a +//! single asset there are no children at all, so the size is the only thing +//! left to give it one. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +fn card(child: Value) -> Value { + json!([ + { + "id": "1:card", "type": "FRAME", + "fields": { + "name": "Card", "childrenIds": ["1:badge"], + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 125.0, "height": 100.0, + "parentId": "0:page", "parentType": "SECTION" + }, + "extra": {}, "fieldErrors": {} + }, + child, + { + "id": "1:inner", "type": "FRAME", + "fields": { + "name": "Icons", "parentId": "1:badge", "childrenIds": [], + "width": 14.285714149475098, "height": 14.285714149475098, + "x": 2.857142686843872, "y": 2.857142686843872 + }, + "extra": {}, "fieldErrors": {} + } + ]) +} + +#[test] +fn a_padded_container_does_not_also_restate_its_size() { + let tsx = generate( + "1:card", + card(json!({ + "id": "1:badge", "type": "FRAME", + "fields": { + "name": "Badge", "parentId": "1:card", "childrenIds": ["1:inner"], + "layoutMode": "NONE", "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 20.0, "height": 20.0, "x": 6.0, "y": 6.0, + "cornerRadius": 1000.0, + "constraints": {"horizontal": "MIN", "vertical": "MIN"} + }, + "extra": {}, "fieldErrors": {} + })), + ); + + // 2.86 + 14.29 + 2.86 comes back to the 20px Figma pinned. + assert!(tsx.contains("p=\"2.86px\""), "{tsx}"); + assert!( + !tsx.contains("boxSize=\"20px\""), + "padding and content already give the size: {tsx}" + ); +} + +#[test] +fn a_folded_asset_still_states_the_size_it_was_pinned_to() { + // Its children are baked into the exported image, so no padding is derived + // and nothing else would give this box a size. + let tsx = generate( + "1:card", + card(json!({ + "id": "1:badge", "type": "FRAME", + "fields": { + "name": "Logo", "parentId": "1:card", "childrenIds": ["1:inner"], + "layoutMode": "NONE", "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 24.0, "height": 9.0, "x": 93.0, "y": 79.0, + "isAsset": true, + "constraints": {"horizontal": "MAX", "vertical": "MAX"} + }, + "extra": {}, "fieldErrors": {} + })), + ); + + assert!( + tsx.contains("w=\"24px\""), + "a folded asset needs its size: {tsx}" + ); + assert!(tsx.contains("h=\"9px\""), "{tsx}"); + assert!( + !tsx.contains("p=\"2.86px\""), + "an asset's hidden children are not a padding: {tsx}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/provenance.rs b/crates/devup-mcp-devup-ui/tests/provenance.rs index e3b94dae..61b3fd9c 100644 --- a/crates/devup-mcp-devup-ui/tests/provenance.rs +++ b/crates/devup-mcp-devup-ui/tests/provenance.rs @@ -318,6 +318,88 @@ fn strict_fidelity_requires_layout_property_mappings_not_only_node_trace() { assert!(!report.strict_compatible()); } +#[test] +fn asset_boundaries_exclude_internal_and_descendant_layout_fields() { + let snapshot = Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["root".to_owned()], + nodes: [ + node( + "root", + "FRAME", + json!({ + "name": "Host", "childrenIds": ["asset"], + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}] + }), + ), + node( + "asset", + "FRAME", + json!({ + "name": "Folded icon", "parentId": "root", + "childrenIds": ["glyph-left", "glyph-right"], + "layoutMode": "HORIZONTAL", "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "itemSpacing": 4, "paddingTop": 1, "paddingRight": 2, + "paddingBottom": 3, "paddingLeft": 4, + "width": 24, "height": 24, "x": 0, "y": 0 + }), + ), + node( + "glyph-left", + "FRAME", + json!({ + "name": "Left glyph", "parentId": "asset", "childrenIds": [], + "isAsset": true, "width": 10, "height": 20 + }), + ), + node( + "glyph-right", + "FRAME", + json!({ + "name": "Right glyph", "parentId": "asset", "childrenIds": [], + "isAsset": true, "width": 10, "height": 20 + }), + ), + ] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + }; + + let output = generate_component(&snapshot, "root", &CodegenOptions::default()).unwrap(); + + assert!(output.tsx.contains("(tsx: &'a str, entry: &devup_mcp_devup_ui::provenance::ProvenanceEnt let range = entry.generated_range.as_ref().unwrap(); &tsx[range.start..range.end] } + +#[test] +fn a_canvas_root_dimension_is_not_counted_as_an_unmet_layout_fact() { + // The screen's own size is deliberately left unsaid so the result is not + // pinned to the width it was drawn at. Counting it would report a + // shortfall for something the output declines to claim on purpose. + let snapshot = Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["1:1".to_owned()], + nodes: [node( + "1:1", + "FRAME", + json!({ + "name": "Screen", "childrenIds": [], "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 360, "height": 800, + "parentId": "0:page", "parentType": "SECTION" + }), + )] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + }; + + let output = generate_component(&snapshot, "1:1", &CodegenOptions::default()).expect("codegen"); + let report = validate_fidelity(&snapshot, "1:1", &output).expect("fidelity"); + + assert!( + !report + .uncovered_layout + .iter() + .any(|entry| entry.ends_with("#width") || entry.ends_with("#height")), + "canvas geometry must not be reported as unmet: {:?}", + report.uncovered_layout + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_alignment.rs b/crates/devup-mcp-devup-ui/tests/responsive_alignment.rs new file mode 100644 index 00000000..2b3f17e0 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_alignment.rs @@ -0,0 +1,106 @@ +//! The same screen at three widths, lined up. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::responsive::{DivergenceReason, breakpoints, divergences}; +use devup_mcp_figma::Snapshot; + +fn capture(name: &str) -> Option { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens") + .join(name); + let raw = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + serde_json::from_value(value.get("snapshot").cloned().unwrap_or(value)).ok() +} + +/// One width is a screen, not a screen that changes. +#[test] +fn a_single_width_has_nothing_to_line_up() { + let Some(snapshot) = capture("bp-desktop.json") else { + eprintln!("no capture; skipping"); + return; + }; + assert!(breakpoints(&snapshot).is_empty()); +} + +/// In the Section's own order, which the roots carry — desktop first in this +/// file — with each width's rank saying where it lands in the array. +#[test] +fn widths_keep_the_section_s_order_and_carry_their_rank() { + let Some(snapshot) = capture("bp-family.json") else { + eprintln!("no capture; skipping"); + return; + }; + let found = breakpoints(&snapshot); + let names = found + .iter() + .map(|breakpoint| { + snapshot.nodes[&breakpoint.node_id] + .typed_view() + .name() + .unwrap_or_default() + }) + .collect::>(); + assert_eq!( + names, + snapshot + .roots + .iter() + .map(|id| snapshot.nodes[id].typed_view().name().unwrap_or_default()) + .collect::>() + ); + let ranks = found + .iter() + .map(|breakpoint| breakpoint.rank) + .collect::>(); + let mut by_rank = ranks.clone(); + by_rank.sort_unstable(); + assert_eq!(by_rank, [0, 1, 2], "mobile, tablet and desktop, each once"); +} + +/// The reference keeps two of this screen's four children twice — the banner +/// and the content section — each shown at its own widths, and merges the rest. +/// Those are the places the widths part company, and they are what shows up +/// here: the banner at the top level, and three shapes inside the section. +/// +/// Nothing from the Header or Footer appears. Both are instances, both hold a +/// different variant per width, and both are meant to: descending into them +/// reported six differences that were the components doing their job. +#[test] +fn the_places_the_widths_part_company_are_named_and_no_others() { + let Some(snapshot) = capture("bp-family.json") else { + eprintln!("no capture; skipping"); + return; + }; + let found = divergences(&snapshot, &breakpoints(&snapshot)); + let name_of = |id: &String| { + snapshot.nodes[id] + .typed_view() + .name() + .unwrap_or_default() + .to_owned() + }; + + assert_eq!(found.len(), 4, "{found:?}"); + + let banner = &found[0]; + assert_eq!(banner.path, vec![0]); + assert_eq!(banner.reason, DivergenceReason::ChildCount); + assert_eq!(name_of(&banner.node_id), "main banner"); + + // The other three sit under the section, which is the second region the + // reference keeps twice. + assert!( + found[1..].iter().all(|divergence| divergence.path[0] == 2), + "{found:?}" + ); + + // The header is child 1 and the footer child 3; neither is walked into. + assert!( + !found + .iter() + .any(|divergence| matches!(divergence.path.first(), Some(1) | Some(3))), + "an instance was descended into: {found:?}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_merge.rs b/crates/devup-mcp-devup-ui/tests/responsive_merge.rs new file mode 100644 index 00000000..4396bd29 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_merge.rs @@ -0,0 +1,404 @@ +//! Writing one prop across several widths as a devup-ui responsive array. +//! +//! Every expectation here is transcribed from a plugin answer kept under +//! `fixtures/plugin-answers/`, so a reader can check each row against the file +//! it names rather than against this test's opinion. + +use devup_mcp_devup_ui::codegen::responsive::{ + Drawn::{self, Absent, Set, Unset}, + Merged, SLOTS, merge_slots, slot_of_width, +}; + +fn slots(prop: &str, values: [Drawn<'_>; SLOTS]) -> Vec> { + match merge_slots(prop, &values) { + Merged::Array(slots) => slots, + Merged::Same(value) => panic!("{prop}: expected an array, got the value {value:?}"), + } +} + +fn array(values: &[Option<&str>]) -> Vec> { + values + .iter() + .map(|value| value.map(str::to_owned)) + .collect() +} + +/// One prop of one element, as the three widths draw it, and the array the +/// reference writes for it. +struct Case<'a> { + prop: &'a str, + mobile: Drawn<'a>, + tablet: Drawn<'a>, + desktop: Drawn<'a>, + expected: &'a [Option<&'a str>], +} + +/// `popup/responsive.tsx`. Its three widths sit at slots 0, 1 and 4 — slot 1, +/// not slot 2, which is what makes this screen different from `notice`. +#[test] +fn the_popup_screen_is_reproduced_prop_for_prop() { + let case = |prop, mobile, tablet, desktop, expected| Case { + prop, + mobile, + tablet, + desktop, + expected, + }; + let cases = [ + // Every width differs. + case( + "py", + Set("211.5px"), + Set("279.5px"), + Set("287.5px"), + &[ + Some("211.5px"), + Some("279.5px"), + None, + None, + Some("287.5px"), + ], + ), + case( + "gap", + Set("36px"), + Set("40px"), + Set("50px"), + &[Some("36px"), Some("40px"), None, None, Some("50px")], + ), + // Tablet and desktop agree, so the array stops after slot 1. + case( + "p", + Set("30px"), + Set("40px"), + Set("40px"), + &[Some("30px"), Some("40px")], + ), + case( + "gap", + Set("20px"), + Set("30px"), + Set("30px"), + &[Some("20px"), Some("30px")], + ), + case( + "gap", + Set("12px"), + Set("16px"), + Set("16px"), + &[Some("12px"), Some("16px")], + ), + case( + "boxSize", + Set("20px"), + Set("24px"), + Set("24px"), + &[Some("20px"), Some("24px")], + ), + case( + "px", + Set("30px"), + Set("40px"), + Set("40px"), + &[Some("30px"), Some("40px")], + ), + case( + "py", + Set("12px"), + Set("16px"), + Set("16px"), + &[Some("12px"), Some("16px")], + ), + case( + "p", + Set("16px"), + Set("20px"), + Set("20px"), + &[Some("16px"), Some("20px")], + ), + // Mobile and tablet agree, so nothing is written until slot 4. + case( + "flexDir", + Set("column"), + Set("column"), + Set("row"), + &[Some("column"), None, None, None, Some("row")], + ), + case( + "boxSize", + Set("16px"), + Set("16px"), + Set("19px"), + &[Some("16px"), None, None, None, Some("19px")], + ), + // A prop that stops. It cannot simply be dropped, because the narrower + // width's value would be inherited, so the reference clears it. + case( + "pl", + Set("36.5px"), + Unset, + Unset, + &[Some("36.5px"), Some("initial")], + ), + case( + "pr", + Set("35.5px"), + Unset, + Unset, + &[Some("35.5px"), Some("initial")], + ), + case( + "w", + Set("220px"), + Unset, + Unset, + &[Some("220px"), Some("initial")], + ), + // A prop that only the middle width sets. Slot 0 stays `null` because + // nothing is in effect yet to clear, and slot 4 spends an `"initial"` + // because by then something is. + case( + "px", + Unset, + Set("184px"), + Unset, + &[None, Some("184px"), None, None, Some("initial")], + ), + ]; + + for Case { + prop, + mobile, + tablet, + desktop, + expected, + } in cases + { + assert_eq!( + slots(prop, [mobile, tablet, Absent, Absent, desktop]), + array(expected), + "{prop}: {mobile:?} / {tablet:?} / {desktop:?}" + ); + } +} + +/// `notice/responsive.tsx`, whose widths sit at slots 0, 2 and 4. Only +/// `display` varies there, and these are the four arrays it writes. +#[test] +fn the_notice_screen_display_toggles_are_reproduced() { + let cases = [ + // Shown from tablet up: the desktop banner and the desktop section. + ( + [Set("none"), Absent, Set("flex"), Absent, Set("flex")], + vec![Some("none"), None, Some("flex")], + ), + // Hidden from tablet up, on an element that needs no display to show. + ( + [Unset, Absent, Set("none"), Absent, Set("none")], + vec![None, None, Some("none")], + ), + // Hidden from tablet up, on one that states its own. + ( + [Set("flex"), Absent, Set("none"), Absent, Set("none")], + vec![Some("flex"), None, Some("none")], + ), + ]; + for (widths, expected) in cases { + assert_eq!(slots("display", widths), array(&expected), "{widths:?}"); + } +} + +/// `about/responsive.tsx`, whose widths sit at slots 0, 2 and 4 as `notice`'s +/// do. What it adds is the case neither earlier screen has: a width that goes +/// back. `notice` only ever toggles one way, so three slots always suffice +/// there; `popup` only ever moves a number forward. Here a region is shown at +/// tablet and hidden again at desktop, and several values return at desktop to +/// what mobile said. Slot 4 cannot be left off in either case — omitting it +/// would inherit the tablet value — so these are the arrays that need all five. +#[test] +fn the_about_screen_needs_every_slot() { + let case = |prop, mobile, tablet, desktop, expected| Case { + prop, + mobile, + tablet, + desktop, + expected, + }; + let cases = [ + // Shown at tablet only. The closing "none" is not redundant with the + // opening one: without it the tablet "flex" would carry into desktop. + case( + "display", + Set("none"), + Set("flex"), + Set("none"), + &[Some("none"), None, Some("flex"), None, Some("none")], + ), + // Shown at desktop only. Here the middle width repeats slot 0, so it + // drops out and the array is sparse rather than full. + case( + "display", + Set("none"), + Set("none"), + Set("flex"), + &[Some("none"), None, None, None, Some("flex")], + ), + // A value that returns. Mobile and desktop agree, but the agreement is + // across a tablet that differs, so slot 4 has to restate it. + case( + "w", + Set("770px"), + Set("778px"), + Set("770px"), + &[Some("770px"), None, Some("778px"), None, Some("770px")], + ), + case( + "py", + Set("120px"), + Set("100px"), + Set("120px"), + &[Some("120px"), None, Some("100px"), None, Some("120px")], + ), + case( + "gap", + Set("60px"), + Set("40px"), + Set("60px"), + &[Some("60px"), None, Some("40px"), None, Some("60px")], + ), + // Set at tablet only, on a prop that is neither spacing nor layout. + // `textAlign` is in the cleared set, so desktop spends an "initial" + // rather than inheriting "right". + case( + "textAlign", + Unset, + Set("right"), + Unset, + &[None, None, Some("right"), None, Some("initial")], + ), + ]; + + for Case { + prop, + mobile, + tablet, + desktop, + expected, + } in cases + { + assert_eq!( + slots(prop, [mobile, Absent, tablet, Absent, desktop]), + array(expected), + "{prop}: {mobile:?} / {tablet:?} / {desktop:?}" + ); + } +} + +/// A node the design hides at every width is not dropped — the plugin's +/// `getVisibilityProps` turns `visible: false` into `display: 'none'`, and +/// merging three widths that all say so collapses back to the plain literal +/// `about/responsive.tsx` opens with. +#[test] +fn a_node_hidden_at_every_width_stays_a_literal() { + assert_eq!( + merge_slots( + "display", + &[Set("none"), Absent, Set("none"), Absent, Set("none")] + ), + Merged::Same(Some("none".to_owned())) + ); +} + +/// Widths that agree need no array, and that is most props on both screens — +/// `alignItems="center"`, `gap="16px"`, `bg="$cardBg"` are written plainly. +#[test] +fn widths_that_agree_are_left_alone() { + assert_eq!( + merge_slots( + "gap", + &[Set("16px"), Set("16px"), Absent, Absent, Set("16px")] + ), + Merged::Same(Some("16px".to_owned())) + ); + assert_eq!( + merge_slots("gap", &[Unset, Unset, Absent, Absent, Unset]), + Merged::Same(None) + ); + assert_eq!(merge_slots("gap", &[Absent; SLOTS]), Merged::Same(None)); +} + +/// A single width is a screen, not a screen that changes. +#[test] +fn one_width_never_produces_an_array() { + assert_eq!( + merge_slots("p", &[Absent, Absent, Absent, Absent, Set("40px")]), + Merged::Same(Some("40px".to_owned())) + ); +} + +/// Only layout, spacing and position props are cleared when a wider width +/// stops setting them. `bg` is not one, so it is left to inherit — a limit of +/// the reference this repo follows rather than a choice made here. +#[test] +fn a_prop_outside_the_cleared_set_is_left_to_inherit() { + let dropped = [Set("$cardBg"), Unset, Absent, Absent, Unset]; + assert_eq!( + merge_slots("bg", &dropped), + Merged::Same(Some("$cardBg".to_owned())), + "bg inherits rather than resetting" + ); + assert_eq!( + slots("px", dropped), + array(&[Some("$cardBg"), Some("initial")]), + "px is in the cleared set and does reset" + ); +} + +/// A first value that is simply what the prop already is need not be written. +/// `flexDir="row"` and `alignItems="flex-start"` are the two this hits in +/// practice. +#[test] +fn a_leading_default_is_dropped() { + assert_eq!( + slots( + "flexDir", + [Set("row"), Absent, Absent, Absent, Set("column")] + ), + array(&[None, None, None, None, Some("column")]) + ); + assert_eq!( + slots( + "alignItems", + [Set("flex-start"), Absent, Absent, Absent, Set("center")] + ), + array(&[None, None, None, None, Some("center")]) + ); + // The same value on a prop with no registered default stays put. + assert_eq!( + slots( + "justify", + [Set("flex-start"), Absent, Absent, Absent, Set("center")] + ), + array(&[Some("flex-start"), None, None, None, Some("center")]) + ); +} + +/// The frame's width decides its slot, and the plugin's own unit test pins +/// these five. +#[test] +fn a_width_lands_in_the_slot_its_size_falls_into() { + for (width, slot) in [(320, 0), (768, 1), (991, 2), (1280, 3), (1600, 4)] { + assert_eq!(slot_of_width(width), slot, "{width}px"); + } + // The screens this repo has an answer for. `about` is drawn at the same + // three widths as `notice`, measured off the file as 360x7240, 992x5619 + // and 1920x4757 — which is what puts its arrays in slots 0, 2 and 4 rather + // than anywhere else, and is a reading of the frames rather than of their + // names. `popup` is the one that lands differently, on 0, 1 and 4. + assert_eq!( + [360, 992, 1920].map(slot_of_width), + [0, 2, 4], + "notice and about" + ); + assert_eq!([390, 768].map(slot_of_width), [0, 1], "popup"); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_screen.rs b/crates/devup-mcp-devup-ui/tests/responsive_screen.rs new file mode 100644 index 00000000..57ea8fdb --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_screen.rs @@ -0,0 +1,1031 @@ +//! The whole join, run on a real screen. +//! +//! `fixtures/plugin-answers/notice/responsive.tsx` is what the plugin answered +//! for this screen, and the capture it was answered from is +//! `fixtures/local-screens/bp-family.json`. The capture is not committed, so +//! these skip when it is absent rather than pretending to have checked. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, + responsive::{MergedScreen, merge_breakpoints}, +}; +use devup_mcp_figma::{Snapshot, UpstreamResult}; + +fn merged() -> Option { + merged_from("bp-family.json") +} + +/// The merge of a capture in `fixtures/local-screens`, or `None` when that +/// capture is not on this machine. +/// +/// A capture may carry a `payload` beside its `snapshot` — the variables and +/// styles the export collected with it. They are what the converter names +/// tokens from, and without them a `$gray200` fill is written as the tail of +/// its variable ID and a `typography="h4"` as five font props, which is not +/// what the reference wrote and not what the server would write either. A +/// capture without one is converted as before. +fn merged_from(capture: &str) -> Option { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens") + .join(capture); + let raw = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let snapshot: Snapshot = serde_json::from_value(value.get("snapshot")?.clone()).ok()?; + let resource = |name: &str| { + value + .get("payload") + .and_then(|payload| payload.get(name)) + .cloned() + .map(|raw| UpstreamResult { raw }) + }; + let variables = resource("variables"); + let styles = resource("styles"); + let options = + CodegenOptions::default().with_resource_results(variables.as_ref(), styles.as_ref()); + merge_breakpoints(&snapshot, &options).ok()? +} + +/// 360 / 992 / 1920, so slots 0 / 2 / 4 — the same three the reference's arrays +/// are written at. +#[test] +fn the_widths_land_where_their_sizes_put_them() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + assert_eq!(merged.slots, vec![0, 2, 4]); +} + +/// The four arrays in `notice/responsive.tsx`, and no fifth. Each is a region +/// one width draws and another does not, so the copies are kept and shown at +/// their own widths. +#[test] +fn the_display_arrays_are_the_reference_s() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + let folded = folded(&merged.tsx); + let written = folded + .lines() + .filter_map(|line| line.trim().split_once("display=").map(|(_, rest)| rest)) + .map(|value| { + value + .split_once("}") + .map_or(value, |(inside, _)| inside) + .trim_start_matches('{') + .to_owned() + }) + .collect::>(); + assert_eq!( + written, + [ + // the mobile banner, on an element that needs no display to show + r#"[null, null, "none"]"#, + // the desktop banner + r#"["none", null, "flex"]"#, + // the desktop content section + r#"["none", null, "flex"]"#, + // the mobile content section + r#"["flex", null, "none"]"#, + ] + ); +} + +/// A component answers for its own widths, so it is referenced once and not +/// descended into. An instance whose component is only a shape is spelled out +/// instead, which is why no `` or `` appears. +#[test] +fn components_are_referenced_and_shapes_are_spelled_out() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + for referenced in ["Header", "Footer", "Tab", "Pagination"] { + assert!( + merged.components.contains(referenced), + "{referenced} should be referenced: {:?}", + merged.components + ); + } + for inlined in ["Logo", "Icons"] { + assert!( + !merged.components.contains(inlined), + "{inlined} is an asset and should have been spelled out" + ); + } + // Spelling it out loses which component it came from, and that is the one + // thing a reader needs to change it in the right place. The name is the one + // the definition declares: `Icons` types its property `Property 1` where + // every other component in this file uses the Korean `속성 1`, and the two + // sanitize differently. + assert!(merged.tsx.contains("{/* */}"), "{}", merged.tsx); + assert!( + merged + .tsx + .contains(r#"{/* */}"#), + "{}", + merged.tsx + ); + assert!(merged.tsx.contains(r#"
"#)); + // `effect` names the interaction state a variant stands for, and the + // definition folds those into `_hover` / `_active`. A call site has no + // state to pass, so `` would be asking for a prop + // the component does not have. The reference writes it bare. + assert!( + merged.tsx.contains(""), + "Tab should take no props: {}", + merged + .tsx + .lines() + .filter(|line| line.contains(">() + .join("\n") + ); + assert!( + !merged.tsx.contains("effect="), + "no reserved variant key should reach a call site" + ); + // The instance is absolutely placed and `
` has nowhere to put + // that, so it is wrapped. + assert!( + merged + .tsx + .contains(r#""#) + ); +} + +/// The screen asks for a different `Header` and `Footer` variant per width. +/// devup-ui reads an array only where it applies CSS, so a component prop +/// cannot carry one; the widest is kept and both places are named. +#[test] +fn a_variant_that_differs_by_width_is_reported_not_faked() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + let said = merged + .unrepresented + .iter() + .map(|note| note.detail.as_str()) + .collect::>(); + assert_eq!(said.len(), 2, "{said:?}"); + assert!( + said.iter() + .any(|note| note.starts_with("Header takes property1")) + ); + assert!( + said.iter() + .any(|note| note.starts_with("Footer takes property1")) + ); + // Whatever is reported must not also be written as an array, which is the + // thing that does nothing. + assert!(!merged.tsx.contains("property1={[")); +} + +/// A responsive array written one slot to a line, folded back onto one, so a +/// test can say what it expects in a line. +fn folded(tsx: &str) -> String { + let mut out = String::with_capacity(tsx.len()); + let mut items: Option> = None; + for line in tsx.lines() { + let trimmed = line.trim(); + match &mut items { + Some(collected) => { + if let Some(rest) = trimmed.strip_prefix("]}") { + out.push_str(&collected.join(", ")); + out.push_str("]}"); + out.push_str(rest); + out.push('\n'); + items = None; + } else { + collected.push(trimmed.trim_end_matches(',').to_owned()); + } + } + None => { + if trimmed.ends_with("={[") { + out.push_str(line); + items = Some(Vec::new()); + } else { + out.push_str(line); + out.push('\n'); + } + } + } + } + out +} + +/// Each element of a file with the nesting it sits at. +/// +/// Each file indents by its own unit, so nesting is counted rather than +/// measured; both bodies begin two levels in. +fn outline(source: &str, unit: usize) -> Vec<(usize, String)> { + source + .lines() + .filter_map(|line| { + let indent = line.len() - line.trim_start().len(); + let name = line + .trim_start() + .strip_prefix('<')? + .chars() + .take_while(char::is_ascii_alphanumeric) + .collect::(); + name.starts_with(char::is_uppercase) + .then(|| (indent / unit, name)) + }) + .collect() +} + +/// How many times each element appears at each nesting, so that two outlines +/// can be compared without depending on the order they were written in. +fn tally(outline: &[(usize, String)]) -> std::collections::BTreeMap<(usize, String), usize> { + let mut counts = std::collections::BTreeMap::new(); + for entry in outline { + *counts.entry(entry.clone()).or_default() += 1; + } + counts +} + +/// Every element of the reference, at the same nesting. +/// +/// The one accepted difference is that a positioned shape is written as a +/// single `Box` carrying both its placement and its mask, where the reference +/// wraps a second `Box` around it. Both draw the same thing; this is two +/// elements fewer. +#[test] +fn the_shape_of_the_output_is_the_reference_s() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + let reference = fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/plugin-answers/notice/responsive.tsx"), + ) + .expect("the plugin's answer is committed"); + + let ours = outline(&merged.tsx, 2); + let theirs = outline(&reference, 4); + + // Every element, at the nesting it sits at. Order is compared separately, + // because the two files order one pair differently on purpose. + let mut missing = tally(&theirs); + for entry in tally(&ours) { + let held = missing.remove(&entry.0).unwrap_or_default(); + assert!( + held >= entry.1, + "{:?} appears {} times here and {held} in the reference", + entry.0, + entry.1 + ); + if held > entry.1 { + missing.insert(entry.0, held - entry.1); + } + } + assert_eq!( + missing, + std::collections::BTreeMap::from([((5, "Box".to_owned()), 2)]), + "the only elements the reference has and this does not should be the \ + two shape wrappers" + ); +} + +/// The slots come from the widths; which width stands in for a missing one +/// comes from the order. +/// +/// A width that does not draw a node is given a hidden copy of the node from +/// the first width that does — first in the Section's layer order, which is +/// the order the roots are listed in and the order the plugin walks. Every +/// value of that copy lands in the array, not only its `display`, so the +/// order reaches the output and is meant to: the about hero picture is +/// `w={["770px", null, "778px", null, "770px"]}` because desktop comes first +/// in that Section. What the order must not touch is where each width lands +/// and what is drawn. +#[test] +fn the_order_of_the_widths_decides_which_one_stands_in_for_a_missing_one() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens/bp-family.json"); + let Ok(raw) = fs::read_to_string(path) else { + eprintln!("no capture; skipping"); + return; + }; + let mut value: serde_json::Value = serde_json::from_str(&raw).expect("captured screen is json"); + let snapshot: Snapshot = + serde_json::from_value(value["snapshot"].clone()).expect("captured snapshot"); + let forwards = merge_breakpoints(&snapshot, &CodegenOptions::default()) + .expect("merge") + .expect("three widths merge"); + + let roots = value["snapshot"]["roots"] + .as_array() + .expect("roots") + .iter() + .rev() + .cloned() + .collect::>(); + value["snapshot"]["roots"] = serde_json::Value::Array(roots); + let reversed_snapshot: Snapshot = + serde_json::from_value(value["snapshot"].clone()).expect("reversed snapshot"); + let backwards = merge_breakpoints(&reversed_snapshot, &CodegenOptions::default()) + .expect("merge") + .expect("three widths merge"); + + assert_eq!( + forwards.slots, backwards.slots, + "the slots come from the widths" + ); + assert_eq!( + outline(&forwards.tsx, 2), + outline(&backwards.tsx, 2), + "and so does what is drawn" + ); + assert_eq!(forwards.components, backwards.components); + // A note names the node it was raised on, which is the first width's; what + // it says does not depend on the order. + let details = |merged: &MergedScreen| { + merged + .unrepresented + .iter() + .map(|note| note.detail.clone()) + .collect::>() + }; + assert_eq!(details(&forwards), details(&backwards)); + assert_ne!( + forwards.tsx, backwards.tsx, + "the width that stands in for a missing one comes from the order" + ); + + // The about capture makes the rule concrete. Its roots are desktop, + // tablet, mobile, so the hidden mobile copy of the hero carries desktop's + // values; read backwards, tablet stands in and its 778px is the base. + let Some(about) = merged_from("about-family.json") else { + return; + }; + assert!( + folded(&about.tsx).contains(r#"w={["770px", null, "778px", null, "770px"]}"#), + "{}", + about.tsx + ); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens/about-family.json"); + let mut value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(path).expect("about")).expect("json"); + let roots = value["snapshot"]["roots"] + .as_array() + .expect("roots") + .iter() + .rev() + .cloned() + .collect::>(); + value["snapshot"]["roots"] = serde_json::Value::Array(roots); + let snapshot: Snapshot = serde_json::from_value(value["snapshot"].clone()).expect("snapshot"); + let backwards = merge_breakpoints(&snapshot, &CodegenOptions::default()) + .expect("merge") + .expect("three widths merge"); + assert!( + folded(&backwards.tsx).contains(r#"w={["778px", null, null, null, "770px"]}"#), + "{}", + backwards.tsx + ); +} +/// The `about` screen against the plugin's answer, line for line. +/// +/// It runs on nothing until `about-family.json` sits beside the other captures +/// and `about/responsive.tsx` beside the other answers; a screen that costs +/// more reads than a day's allowance holds is one nobody will want to check +/// twice by hand, so the suite decides it instead. +#[test] +fn the_about_screen_matches_the_answer_when_both_are_present() { + matches_the_answer( + "about-family.json", + "AboutPage", + "about", + &[0, 2, 4], + ABOUT_DIFFERS_ON_PURPOSE, + ); +} + +/// The `popup` screen against the plugin's answer. Drawn at 390 / 768 / 1920, +/// so its tablet takes slot 1, the band `about` skips. +#[test] +fn the_popup_screen_matches_the_answer_when_both_are_present() { + matches_the_answer( + "popup-family.json", + "PopupPage", + "popup", + &[0, 1, 4], + POPUP_DIFFERS_ON_PURPOSE, + ); +} + +/// The `notice` screen against the plugin's answer. +#[test] +fn the_notice_screen_matches_the_answer_when_both_are_present() { + matches_the_answer( + "bp-family.json", + "NoticePage", + "notice", + &[0, 2, 4], + NOTICE_DIFFERS_ON_PURPOSE, + ); +} + +/// Every merged module the suite can build passes the validator the server +/// offers as `devup_ui_validate`: it parses, every prop on a primitive is +/// one devup-ui takes — `as="br"` on a break Box, `display` arrays, +/// `maskImage` and the rest — and nothing in it is a runtime value. Without +/// a theme the validator can only *advise* on a hardcoded length or colour, +/// so those warnings are not counted; an error is. An output the validator +/// would reject is not a deliverable. +#[test] +fn every_merged_module_passes_the_validator() { + use devup_mcp_devup_ui::ui_validate::{Severity, validate_devup_ui_tsx}; + let mut checked = 0; + for (capture, name) in [ + ("about-family.json", "AboutPage"), + ("popup-family.json", "PopupPage"), + ("bp-family.json", "NoticePage"), + ] { + let Some(merged) = merged_from(capture) else { + continue; + }; + let module = merged.module(name); + let report = validate_devup_ui_tsx(&module, None, false); + let errors = report + .violations + .iter() + .filter(|violation| violation.severity == Severity::Error) + .map(|violation| format!("{}: {}", violation.rule, violation.message)) + .collect::>(); + assert!( + report.ok && errors.is_empty(), + "{capture} does not validate:\n {}", + errors.join("\n ") + ); + checked += 1; + } + if checked == 0 { + eprintln!("no captures; skipping"); + } +} + +/// A screen's merged module against the plugin's answer for it. +/// +/// Both files are read the same way — indentation dropped, a responsive array +/// folded onto one line, an image fill's file masked — and then every line +/// the answer has that this does not, and the reverse, must be one written +/// down in `differs_on_purpose` with its reason. Anything else is looked at +/// rather than waved through. Skips when either file is not on this machine. +fn matches_the_answer( + capture: &str, + component_name: &str, + answer: &str, + slots: &[usize], + differs_on_purpose: &[(&str, &str)], +) { + let Some(merged) = merged_from(capture) else { + eprintln!("no {answer} capture; skipping"); + return; + }; + let Ok(reference) = fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + format!("../../fixtures/plugin-answers/{answer}/responsive.tsx"), + )) else { + eprintln!("no {answer} answer; skipping"); + return; + }; + + // The widths read off the frames themselves. + assert_eq!(merged.slots, slots, "{answer} is drawn at {slots:?}"); + + let ours = comparable_lines(&merged.module(component_name)); + let theirs = comparable_lines(&reference); + let mut only_ours = ours.clone(); + for line in &theirs { + if let Some(index) = only_ours.iter().position(|other| other == line) { + only_ours.remove(index); + } + } + let mut only_theirs = theirs.clone(); + for line in &ours { + if let Some(index) = only_theirs.iter().position(|other| other == line) { + only_theirs.remove(index); + } + } + let unexplained = |lines: &[String]| { + lines + .iter() + .filter(|line| !differs_on_purpose.iter().any(|(known, _)| known == line)) + .cloned() + .collect::>() + }; + let ours_unexplained = unexplained(&only_ours); + let theirs_unexplained = unexplained(&only_theirs); + assert!( + ours_unexplained.is_empty() && theirs_unexplained.is_empty(), + "{answer}: lines not accounted for.\n written here and not in the answer:\n {}\n in the answer and not here:\n {}", + ours_unexplained.join("\n "), + theirs_unexplained.join("\n ") + ); +} + +/// The lines on which `popup` is written differently on purpose. +/// +/// The three frames lay nothing out, and each holds one child the designer +/// centred — constraints `CENTER` / `CENTER`. Here the frame is a `Center` +/// with the card in flow, centred at every width and as tall as the frame +/// was drawn, `h` one value per width; the answer places the card with the +/// padding Figma inferred at the widths it was drawn (`pl="36.5px"` at 390px, +/// an array of them) and at desktop with none, which leaves the popup at the +/// left of a 1920px screen, in a `VStack`/`Flex` the file no longer infers. +/// The plugin as it is today would write the card `pos="absolute"` at +/// `left="50%" top="50%"` in a frame of no height, its dim never drawn. +const POPUP_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ + ("", "the centred card is centred, not padded"), + ( + "h={[\"800px\", \"1024px\", null, null, \"1080px\"]}", + "a root with nothing in flow keeps its drawn height", + ), + ("", "inferred layout the file no longer has"), + ( + "flexDir={[\"column\", null, null, null, \"row\"]}", + "inferred layout the file no longer has", + ), + ("pl={[\"36.5px\", \"initial\"]}", "centred, not padded"), + ("pr={[\"35.5px\", \"initial\"]}", "centred, not padded"), + ( + "px={[null, \"184px\", null, null, \"initial\"]}", + "centred, not padded", + ), + ( + "py={[\"211.5px\", \"279.5px\", null, null, \"287.5px\"]}", + "centred, not padded", + ), +]; + +/// The lines on which `notice` is written differently on purpose. +/// +/// - The banner logos are `#FFF` at `opacity="0.1"` here and `bg="$text"` +/// with no opacity in the answer. The file decides it: each is a `Logo` +/// instance at opacity 0.1 (`422:6873`, `422:6874` and their siblings at +/// the other widths) whose one vector has no fill and a white stroke bound +/// to nothing, and the plugin's own `analyzeOwnSameColor` reads strokes as +/// fills are read. `$text` is not in that subtree. The answer was written +/// from another state of the file. +/// - The search icon is `$primary` here and `$text` in the answer. The file +/// decides it: the `Union` vector inside every `icons` instance +/// (`I422:6887;13:1876` and the two at the other widths) has its fill +/// bound to `VariableID:422:7203`, whose name in the collected variables +/// is `primary`. The plugin's Pure Code for the same frame writes +/// `$primary` too; only its two component-referencing outputs say `$text`. +/// - A positioned instance that folds to a shape is one `Box` carrying both +/// its place and its mask; the plugin wraps a second `Box` around it. +/// - An instance's asset is named after the instance's own layer; the plugin +/// names it after the variant component, `Property 1=search`, which every +/// component set with a `search` variant would share. +/// - The icon instance's `targetAspectRatio` (24×24) is written; the plugin's +/// component-referencing outputs drop it, its Pure Code keeps it. +const NOTICE_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ + ( + "bg=\"$text\"", + "the file has a white stroke at 10% and a $primary fill", + ), + ("bg=\"#FFF\"", "the logo's vector has a white stroke"), + ("opacity=\"0.1\"", "the logo instance is at 10%"), + ("bg=\"$primary\"", "the icon's fill is bound to `primary`"), + ( + "aspectRatio=\"1\"", + "the icon instance's targetAspectRatio, which Pure Code keeps", + ), + ( + "", + "one Box carries place and mask", + ), + ( + "", + "one Box carries place and mask", + ), + ("", "one Box carries place and mask"), + ("left=\"44px\"", "one Box carries place and mask"), + ("top=\"169px\"", "one Box carries place and mask"), + ("left=\"-212px\"", "one Box carries place and mask"), + ("top=\"112px\"", "one Box carries place and mask"), + ("pos=\"absolute\"", "one Box carries place and mask"), + ("{/* */}", "one Box carries place and mask"), + ( + "{/* */}", + "a shape spelled out says its component", + ), + ( + "maskImage=\"url('/icons/Property 1=search.svg')\"", + "an asset is named after its instance", + ), + ( + "maskImage=\"url(/icons/icons.svg)\"", + "an asset is named after its instance", + ), +]; +/// The lines on which `about` is written differently here on purpose, each +/// with the reason. A line is listed once whichever side it is on; the test +/// above only asks that every unmatched line is one of these. Four reasons: +/// +/// - A line break at the edge of a segment is drawn (`{" "}
`); the +/// plugin counts it as a space (`{" "}`). A soft return (U+2028) is +/// drawn; the plugin passes the character through, which the answer has as +/// a space. +/// - A break that some widths have and others do not is `` +/// shown at the widths that have it; the plugin sets out to do this and +/// instead rewrites the rendered JSX of the hero's coloured span as text, +/// putting a break either side that no width drew. +/// - The hero's coloured span is kept as an element. +/// - A positioned mask icon keeps its height; the plugin writes the width +/// alone, and a mask with no height draws nothing. +const ABOUT_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ + // An export carries the node's own opacity - Figma writes it into an + // SVG as `` and into a PNG's alpha (the landing hero at 0.8 + // exports with its opaque pixels at alpha 204) - so written on the + // element as well it is applied twice. A decoration at 0.2 came out at + // 0.04, which is nothing. The answer writes it on these three. + ( + "opacity=\"0.3\"", + "plugin writes an asset's opacity on the element as well, where the export already carries it, so it is applied twice", + ), + ( + "opacity=\"0.2\"", + "plugin writes an asset's opacity on the element as well, where the export already carries it, so it is applied twice", + ), + // Figma paints children in order; CSS paints a positioned element after + // every in-flow sibling. A background pinned first is under everything + // in Figma and over everything in a browser, so it is sent behind inside + // a stacking context its parent opens. Rendered against Figma's PNGs the + // landing page's hero and badges only sit right once this is written. + ( + "zIndex=\"-1\"", + "plugin writes no stacking order, so a background drawn first paints over the content in a browser", + ), + ( + "zIndex=\"0\"", + "plugin writes no stacking order, so a background drawn first paints over the content in a browser", + ), + // Figma's default counter-axis alignment is MIN, written by leaving the + // field out; CSS's default for the same thing is `stretch`, its + // opposite. A hugging child of such a parent is drawn as wide as the + // parent unless the code says otherwise, and the plugin never does - the + // devup-ui landing page's 247px `Get started` button came out 1360px + // wide. These three centre their own contents, so the wider box moves + // them; the alignment is written for them and for nothing that would + // look the same either way. + ( + "
", + "plugin leaves Figma's MIN cross-axis unsaid, which CSS reads as stretch", + ), + ( + "
", + "plugin leaves Figma's MIN cross-axis unsaid, which CSS reads as stretch", + ), + // Figma keeps a fixed child at its size and lets the line spill past the + // frame, which clips it. CSS shrinks flex children to fit instead, so + // the landing page's seven 240px comparison cards were squeezed into + // 912px, their labels wrapped, and the row came out 58px too tall. + ( + "", + "plugin lets CSS shrink a fixed child that Figma would let overflow", + ), + ( + "", + "plugin lets CSS shrink a fixed child that Figma would let overflow", + ), + ( + "", + "plugin lets CSS shrink a fixed child that Figma would let overflow", + ), + ( + "", + "plugin lets CSS shrink a fixed child that Figma would let overflow", + ), + // The hero column fills its section's height in Figma, and holds its + // picture as a positioned child. A positioned child adds nothing to the + // height of what holds it, so with the fill left unsaid CSS hugs the text + // alone: the column came out 155 tall where Figma has 440, the section + // centred that, and the picture landed 143px down over the heading it is + // meant to sit above. Rendered against Figma's own PNG, it only sits + // right once the fill is written. + ( + "flex=\"1\"", + "plugin leaves a filled height unsaid, which CSS cannot reach past a positioned child", + ), + // A cropped image fill carries its crop as a matrix over the image's own + // 0..1 space. The plugin paints every image fill `center/cover` and drops + // it, showing the whole picture where the designer framed a part of it. + // These are the crops Figma itself draws, read off `imageTransform`: + // rendered against Figma's own PNG of this page, the photographs only sit + // right once the crop is honoured. + ( + "bg=\"url(IMAGEFILL) 53.51% -25.92%/91.26% 104.43% no-repeat, $gray200\"", + "plugin drops the crop matrix and paints center/cover", + ), + ( + "bg=\"url(IMAGEFILL) 0% 41.89%/100% 114.38% no-repeat, $gray200\"", + "plugin drops the crop matrix and paints center/cover", + ), + ( + "bg=\"url(IMAGEFILL) 0% 11.18%/100% 117.53% no-repeat, $gray200\"", + "plugin drops the crop matrix and paints center/cover", + ), + ( + "bg=\"url(IMAGEFILL) 0% 11.43%/100% 117.49% no-repeat, $gray200\"", + "plugin drops the crop matrix and paints center/cover", + ), + ( + "bg=\"url(IMAGEFILL) 0% 4.04%/100% 120.06% no-repeat, $gray200\"", + "plugin drops the crop matrix and paints center/cover", + ), + ( + "bg=\"url(IMAGEFILL) 0% 24.48%/100% 117.53% no-repeat, $gray200\"", + "plugin drops the crop matrix and paints center/cover", + ), + ( + "bg=\"url(IMAGEFILL) 0% 30.1%/100% 117.49% no-repeat, $gray200\"", + "plugin drops the crop matrix and paints center/cover", + ), + ( + "bg=\"url(IMAGEFILL) center/cover no-repeat, $gray200\"", + "plugin drops the crop matrix and paints center/cover", + ), + ( + "성인 ADHD,{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "
성인 ADHD,{\" \"}
우리는 다르게 봅니다.", + "plugin rewrites rendered JSX as text", + ), + ( + "퍼즐핏은 당사자의 경험에서 출발한, 성인 ADHD 전문 플랫폼입니다.", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "저는 성인 ADHD 당사자이자 정신건강간호사입니다. 진단을 받기까지 수년이 걸렸고, 그 과정에서 수많은 좌절과 시행착오를 겪었습니다. 그 경험을 통해 알게 된 것이 있습니다.", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}우리는 단순한 진단 도구를 만드는 것이 아니라, 당사자가 스스로를 이해하고 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 만들고 있습니다", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}같은 어려움을 겪더라도 각자가 만들어온{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "우리는 단순한 검사 도구를 만드는 것이 아닙니다. 퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서, 코칭과 커뮤니티를 통해
당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고 있습니다.", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "우리는 단순한 검사 도구를 만드는 것이 아닙니다. 퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서,
코칭과 커뮤니티를 통해 당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고
있습니다.", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "퍼즐핏이 바라는 미래는
성인 ADHD가 ‘진단명’으로만 불리는 것이 아니라,{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ("w=\"465px\"", "mask icon height"), + ( + "전문가와 함께 만드는, 신뢰 기반의 성인 ADHD 솔루션{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}퍼즐핏은 단순한 검사 도구 제공이 아닌{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "을 통해 당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "전문가와 함께 만드는,
신뢰 기반의 성인 ADHD 솔루션{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ("", "the span kept as an element"), + ( + "성인 ADHD,{\" \"}
", + "a break at a segment edge or a soft return is drawn", + ), + ("
", "the span kept as an element"), + ("우리는 다르게 봅니다.", "the span kept as an element"), + ( + "성인 ADHD,{\" \"}", + "a break some widths have, shown at those widths", + ), + ( + "퍼즐핏은 당사자의 경험에서 출발한, 성인 ADHD 전문 플랫폼입니다.", + "a break some widths have, shown at those widths", + ), + ( + "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.

", + "a break at a segment edge or a soft return is drawn", + ), + ( + "

우리는 단순한 진단 도구를 만드는 것이 아니라,
당사자가 스스로를 이해하고 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 만들고 있습니다", + "a break at a segment edge or a soft return is drawn", + ), + ( + "저는 성인 ADHD 당사자이자 정신건강간호사입니다.
진단을 받기까지 수년이 걸렸고, 그 과정에서 수많은 좌절과 시행착오를 겪었습니다.
그 경험을 통해 알게 된 것이 있습니다.", + "a break at a segment edge or a soft return is drawn", + ), + ( + "
{\" \"}같은 어려움을 겪더라도 각자가 만들어온{\" \"}", + "a break at a segment edge or a soft return is drawn", + ), + ( + "
{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.

", + "a break at a segment edge or a soft return is drawn", + ), + ( + "우리는 단순한 검사 도구를 만드는 것이 아닙니다.
퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서, 코칭과 커뮤니티를 통해
당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고 있습니다.", + "a break at a segment edge or a soft return is drawn", + ), + ( + "우리는 단순한 검사 도구를 만드는 것이 아닙니다.
퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서,
코칭과 커뮤니티를 통해 당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고
있습니다.", + "a break at a segment edge or a soft return is drawn", + ), + ( + "퍼즐핏이 바라는 미래는
성인 ADHD가 ‘진단명’으로만 불리는 것이 아니라,
", + "a break at a segment edge or a soft return is drawn", + ), + ("boxSize=\"465px\"", "mask icon height"), + ( + "전문가와 함께 만드는, 신뢰 기반의 성인 ADHD 솔루션
", + "a break at a segment edge or a soft return is drawn", + ), + ( + "
퍼즐핏은 단순한 검사 도구 제공이 아닌{\" \"}", + "a break at a segment edge or a soft return is drawn", + ), + ( + "을 통해
당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.

", + "a break at a segment edge or a soft return is drawn", + ), + ( + "전문가와 함께 만드는,
신뢰 기반의 성인 ADHD 솔루션
", + "a break at a segment edge or a soft return is drawn", + ), + ( + "을 통해 당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.

", + "a break at a segment edge or a soft return is drawn", + ), +]; + +/// A file's lines as they are compared: indentation and blank lines dropped, +/// a responsive array folded onto one line, and an image fill's file masked — +/// the plugin writes every one as its fixed `/icons/image.png`, and this +/// writes the file the export actually produces. +fn comparable_lines(source: &str) -> Vec { + folded(source) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + let mut line = line.replace("url(/icons/image.png)", "url(IMAGEFILL)"); + while let Some(start) = line + .find("url('/images/") + .or_else(|| line.find("url(/images/")) + { + let Some(end) = line[start..].find(')') else { + break; + }; + line.replace_range(start..start + end + 1, "url(IMAGEFILL)"); + } + line + }) + .collect() +} + +/// Every element the screen draws, as tags, ignoring the ones only mentioned +/// inside a `{/* … */}` comment — those are deliberately not rendered and so +/// deliberately not imported. +fn rendered_tags(tsx: &str) -> std::collections::BTreeSet { + let mut uncommented = String::with_capacity(tsx.len()); + let mut rest = tsx; + while let Some(start) = rest.find("{/*") { + uncommented.push_str(&rest[..start]); + match rest[start..].find("*/}") { + Some(end) => rest = &rest[start + end + 3..], + None => { + rest = ""; + break; + } + } + } + uncommented.push_str(rest); + + let mut tags = std::collections::BTreeSet::new(); + let bytes = uncommented.as_bytes(); + for (index, _) in uncommented.match_indices('<') { + let name = uncommented[index + 1..] + .chars() + .take_while(|character| character.is_alphanumeric() || *character == '_') + .collect::(); + let starts_upper = name.chars().next().is_some_and(char::is_uppercase); + // `
` closes what `>(); + assert_eq!( + declared.len(), + primitives.len() + components.len(), + "a name must be a primitive or a component of this design, not both" + ); + + let drawn = rendered_tags(&merged.tsx); + assert!(!drawn.is_empty(), "the screen draws something"); + let unimported = drawn.difference(&declared).collect::>(); + assert!( + unimported.is_empty(), + "drawn but not imported: {unimported:?}" + ); + + let module = merged.module("AboutPage"); + + // Imports first, then one default export holding the tree. + let export = module + .find("export default function AboutPage()") + .expect("a default export named after the screen"); + for line in module[..export] + .lines() + .filter(|line| !line.trim().is_empty()) + { + assert!( + line.starts_with("import "), + "only imports may precede the export: {line}" + ); + } + assert_eq!( + module.matches("export default function").count(), + 1, + "one screen, one default export" + ); + assert!( + module.contains(&merged.tsx), + "the module must carry the merged tree unchanged" + ); + + // Each component is imported once, from its own file. + let imports = module[..export] + .lines() + .filter(|line| line.starts_with("import ")) + .collect::>(); + let unique = imports.iter().collect::>(); + assert_eq!(unique.len(), imports.len(), "no import written twice"); + for name in &components { + assert!( + module.contains(&format!("import {{ {name} }} from '@/components/{name}'")), + "{name} must be imported from its own file" + ); + } + if !primitives.is_empty() { + assert!( + module.contains("from '@devup-ui/react'"), + "primitives come from devup-ui" + ); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/root_canvas_width.rs b/crates/devup-mcp-devup-ui/tests/root_canvas_width.rs new file mode 100644 index 00000000..784d4596 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/root_canvas_width.rs @@ -0,0 +1,106 @@ +//! A screen's own width is the canvas it was drawn on, not a constraint. +//! +//! The frame being exported sits on a page or section, so its width is simply +//! the size the designer worked at. Emitting it pins the result to a device +//! width that does not exist. The parent that establishes this is outside the +//! collected subtree, so the node carries its parent's type and that is what +//! the decision reads. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +fn screen(parent_type: Option<&str>) -> String { + let mut root = json!({ + "name": "Screen", + "childrenIds": ["1:header"], + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "width": 360.0, + "height": 1238.0, + "parentId": "0:page" + }); + if let Some(parent_type) = parent_type { + root["parentType"] = json!(parent_type); + } + + generate( + "1:screen", + json!([ + {"id": "1:screen", "type": "FRAME", "fields": root, "extra": {}, "fieldErrors": {}}, + { + "id": "1:header", "type": "FRAME", + "fields": { + "name": "Header", "parentId": "1:screen", "childrenIds": [], + "layoutMode": "HORIZONTAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "width": 360.0, "height": 66.0 + }, + "extra": {}, "fieldErrors": {} + } + ]), + ) +} + +/// The root's opening tag. Props are formatted across several lines, so +/// matching a single line would find ` String { + let start = tsx + .find("return (") + .and_then(|from| tsx[from..].find('<').map(|at| from + at)) + .unwrap_or_else(|| panic!("a root element in:\n{tsx}")); + let end = tsx[start..].find('>').expect("a closed tag") + start; + tsx[start..=end].to_owned() +} + +#[test] +fn a_screen_on_a_section_does_not_restate_its_canvas_width() { + let tag = root_tag(&screen(Some("SECTION"))); + + assert!( + !tag.contains("w=\"360px\""), + "the canvas width must not become a constraint: {tag}" + ); +} + +#[test] +fn a_child_that_happens_to_be_full_width_still_states_it() { + let tsx = screen(Some("SECTION")); + + // The header is 360 wide too, but it is a child rather than the canvas, so + // its width is a real measurement and has to survive. + assert!( + tsx[root_tag(&tsx).len()..].contains("w=\"360px\""), + "a child's own width is not canvas geometry: {tsx}" + ); +} + +#[test] +fn without_a_recorded_parent_type_the_width_is_still_emitted() { + // Nothing says this frame is a screen, so the width is all there is to go + // on. This is what the upstream fixtures exercise, and it must not change. + let tag = root_tag(&screen(None)); + + assert!( + tag.contains("w=\"360px\""), + "an unattributed frame keeps its width: {tag}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/single_frame_answers.rs b/crates/devup-mcp-devup-ui/tests/single_frame_answers.rs new file mode 100644 index 00000000..ab3e7bb0 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/single_frame_answers.rs @@ -0,0 +1,253 @@ +//! Single frames against what the plugin wrote for them. +//! +//! `fixtures/plugin-answers//pure.tsx` is the plugin's Pure Code for one +//! frame of `devup-Test`, and `fixtures/local-screens/.json` is the +//! capture of that frame. The captures are not committed, so each comparison +//! skips when its capture is absent rather than pretending to have checked. +//! +//! Every line of the answer has to be written here, and every line written +//! here has to be in the answer, except the ones each frame names as +//! different on purpose, with the reason. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::{ + codegen::{CodegenOptions, generate_component}, + ui_validate::{Severity, validate_devup_ui_tsx}, +}; +use devup_mcp_figma::{Snapshot, UpstreamResult}; + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures") +} + +fn ours(capture: &str) -> Option { + ours_of(capture, None) +} + +/// The Pure Code of one root of a capture — the first, or the one named. +fn ours_of(capture: &str, root: Option<&str>) -> Option { + let raw = fs::read_to_string(fixtures().join(format!("local-screens/{capture}.json"))).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let snapshot: Snapshot = serde_json::from_value(value.get("snapshot")?.clone()).ok()?; + let resource = |name: &str| { + value + .get("payload") + .and_then(|payload| payload.get(name)) + .cloned() + .map(|raw| UpstreamResult { raw }) + }; + let variables = resource("variables"); + let styles = resource("styles"); + // The answer is the plugin's Pure Code, with every instance expanded to + // primitives. + let options = CodegenOptions { + inline_instances: true, + ..CodegenOptions::default() + } + .with_resource_results(variables.as_ref(), styles.as_ref()); + let root = match root { + Some(root) => root.to_owned(), + None => snapshot.roots.first()?.clone(), + }; + Some(generate_component(&snapshot, &root, &options).ok()?.tsx) +} + +/// A file's lines as they are compared: indentation and blank lines dropped. +fn comparable_lines(source: &str) -> Vec { + source + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect() +} + +/// The lines a module has around the JSX the answer is: imports, the +/// function, and its return. +fn module_wrapper(line: &str) -> bool { + line.starts_with("import ") + || line.starts_with("export function ") + || line == "return (" + || line == ");" + || line == "}" +} + +fn matches_the_answer(name: &str, differs_on_purpose: &[(&str, &str)]) { + let Some(tsx) = ours(name) else { + eprintln!("no {name} capture; skipping"); + return; + }; + compare(name, &tsx, differs_on_purpose); +} + +fn compare(name: &str, tsx: &str, differs_on_purpose: &[(&str, &str)]) { + let Ok(answer) = fs::read_to_string(fixtures().join(format!("plugin-answers/{name}/pure.tsx"))) + else { + eprintln!("no {name} answer; skipping"); + return; + }; + let ours = comparable_lines(tsx); + let theirs = comparable_lines(&answer); + let mut only_ours = ours.clone(); + for line in &theirs { + if let Some(index) = only_ours.iter().position(|other| other == line) { + only_ours.remove(index); + } + } + let mut only_theirs = theirs.clone(); + for line in &ours { + if let Some(index) = only_theirs.iter().position(|other| other == line) { + only_theirs.remove(index); + } + } + let unexplained = |lines: &[String]| { + lines + .iter() + .filter(|line| !module_wrapper(line)) + .filter(|line| !differs_on_purpose.iter().any(|(known, _)| known == line)) + .cloned() + .collect::>() + }; + let ours_unexplained = unexplained(&only_ours); + let theirs_unexplained = unexplained(&only_theirs); + assert!( + ours_unexplained.is_empty() && theirs_unexplained.is_empty(), + "{name}: lines not accounted for.\n written here and not in the answer:\n {}\n in the answer and not here:\n {}", + ours_unexplained.join("\n "), + theirs_unexplained.join("\n ") + ); + + let report = validate_devup_ui_tsx(tsx, None, false); + let errors = report + .violations + .iter() + .filter(|violation| violation.severity == Severity::Error) + .map(|violation| format!("{}: {}", violation.rule, violation.message)) + .collect::>(); + assert!( + report.ok && errors.is_empty(), + "{name}: {}", + errors.join("\n") + ); +} + +/// `429:1966`, six pictures in a 3×3 grid: the grid's template, and each +/// picture's cell where it is not the next one. +#[test] +fn the_grid_of_pictures_is_the_plugin_s() { + matches_the_answer("grid", &[]); +} + +/// `446:1971`, the report section: a translucent gradient between two tokens +/// on the backdrop and on each card, a text with a gradient fill, and an +/// illustration pinned below its frame through its children's constraints. +#[test] +fn the_report_section_is_the_plugin_s() { + matches_the_answer( + "report", + &[ + // The illustration is a folded asset drawn at a size, and the + // plugin writes no height for a positioned frame with children; + // this keeps it, as the puzzle icon and the spinner frames keep + // theirs. See `codegen::layout`. + ("h=\"734.76px\"", "a pinned size is a layout fact"), + // See ABOUT_DIFFERS_ON_PURPOSE in responsive_screen.rs: a + // background pinned first is sent behind the content it would + // otherwise cover. + ( + "zIndex=\"-1\"", + "plugin writes no stacking order, so a background drawn first paints over the content in a browser", + ), + ( + "zIndex=\"0\"", + "plugin writes no stacking order, so a background drawn first paints over the content in a browser", + ), + // Three cards hold an `Icons` instance each at a different + // variant - analysis, time, thunder - and the answer names all + // three `/icons/Icons.svg`, one file overwriting the next, so + // every card draws the chart. The render harness showed it. + // Here an instance whose layer name another variant shares + // carries its variant. See `codegen::style::asset_stem`. + ( + "maskImage=\"url(/icons/Icons.svg)\"", + "three icons are three files", + ), + ( + "maskImage=\"url(/icons/Icons=analysis.svg)\"", + "three icons are three files", + ), + ( + "maskImage=\"url(/icons/Icons=time.svg)\"", + "three icons are three files", + ), + ( + "maskImage=\"url(/icons/Icons=thunder.svg)\"", + "three icons are three files", + ), + ], + ); +} + +/// `422:6865`, the notice screen at its desktop width, with the header and +/// footer expanded to primitives: the plugin's Pure Code tab, against the +/// same frame taken out of the three-width capture. +#[test] +fn the_notice_desktop_is_the_plugin_s_pure_code() { + let Some(tsx) = ours_of("bp-family", Some("422:6865")) else { + eprintln!("no notice capture; skipping"); + return; + }; + compare( + "notice", + &tsx, + &[ + // The header is pinned 80 tall in Figma and holds its logo and + // menu centred in that. The plugin says no height for a + // positioned frame with children and lets them size it, so the + // row collapses to its content and the centring puts everything + // high of where Figma draws it — 18px high on the mobile frame, + // where the same header is 60 around a 24px row. + ( + "h=\"80px\"", + "plugin lets children size a frame Figma pinned", + ), + // The theme toggle's knob is a 28px frame that lays nothing out, + // holding one 20px icon the designer centred; it is a `Center`. + // The answer's `Flex` with `p="4px"` is a layout Figma inferred + // when the answer was written and no longer reports for the frame + // (`inferredAutoLayout: null` in the capture) — the same thing + // `popup`'s answer shows. + ( + "
", + "the centred icon is centred, not padded", + ), + ("
", "the centred icon is centred, not padded"), + ("", "inferred layout the file no longer has"), + ("", "inferred layout the file no longer has"), + ( + "aspectRatio=\"1\"", + "inferred layout the file no longer has", + ), + ("bg=\"#2D2926\"", "inferred layout the file no longer has"), + ( + "borderRadius=\"10000px\"", + "inferred layout the file no longer has", + ), + ("boxSize=\"28px\"", "inferred layout the file no longer has"), + ("p=\"4px\"", "inferred layout the file no longer has"), + // The footer's address breaks after the CEO's name with a soft + // return, U+2028, which is drawn as `
`; the plugin passes + // the character through, and the answer has it as a space. + ( + "대표이사 : 이석중
주소 : 13840 경기 과천시 과천대로7나길 60 과천어반허브, C동 5층/6층
TEL : 1899-3058
FAX : 02-3318-3351
이메일 : sales@laonpeople.com{\" \"}", + "a soft return is drawn", + ), + ( + "대표이사 : 이석중 주소 : 13840 경기 과천시 과천대로7나길 60 과천어반허브, C동 5층/6층
TEL : 1899-3058
FAX : 02-3318-3351
이메일 : sales@laonpeople.com{\" \"}", + "a soft return is drawn", + ), + ], + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap index 8a072e2d..a4da6b85 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap @@ -40,7 +40,7 @@ export function AStoryFProofread() {
-
+
공개 설정 : @@ -69,13 +69,14 @@ export function AStoryFProofread() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > 편집 설정 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap index b3dc78c0..58c6100c 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap @@ -34,7 +34,7 @@ export function AStoryFProofread() {
-
+
공개 설정 : @@ -63,13 +63,14 @@ export function AStoryFProofread() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > 편집 설정 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap index 62a36546..92abcdc3 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap @@ -8,7 +8,7 @@ expression: output.source_map { "generatedRange": { "start": 127, - "end": 14425 + "end": 14453 }, "nodeId": "3879:35518", "resolution": "node" @@ -80,7 +80,7 @@ expression: output.source_map { "generatedRange": { "start": 250, - "end": 4286 + "end": 4315 }, "nodeId": "3879:35519", "resolution": "node" @@ -583,39 +583,9 @@ expression: output.source_map "start": 1035, "end": 1057 }, - "nodeId": "I3879:35525;17:2032", - "property": "node", - "assetId": "I3879:35525;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 1035, - "end": 1057 - }, - "nodeId": "I3879:35525;17:2034", - "property": "node", - "assetId": "I3879:35525;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 1035, - "end": 1057 - }, - "nodeId": "I3879:35525;17:2036", - "property": "node", - "assetId": "I3879:35525;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 1035, - "end": 1057 - }, - "nodeId": "I3879:35525;17:2038", + "nodeId": "3879:35525", "property": "node", - "assetId": "I3879:35525;17:2038:node", + "assetId": "3879:35525:node", "resolution": "asset" }, { @@ -1001,15 +971,15 @@ expression: output.source_map "start": 1620, "end": 1653 }, - "nodeId": "3879:35531", + "nodeId": "3879:35530", "property": "node", - "assetId": "3879:35531:node", + "assetId": "3879:35530:node", "resolution": "asset" }, { "generatedRange": { "start": 1882, - "end": 3210 + "end": 3239 }, "nodeId": "3879:35532", "resolution": "node" @@ -1053,7 +1023,7 @@ expression: output.source_map { "generatedRange": { "start": 1919, - "end": 2967 + "end": 2996 }, "nodeId": "3879:35533", "resolution": "node" @@ -1171,7 +1141,7 @@ expression: output.source_map { "generatedRange": { "start": 2173, - "end": 2441 + "end": 2470 }, "nodeId": "3879:35534", "resolution": "node" @@ -1384,26 +1354,26 @@ expression: output.source_map }, { "generatedRange": { - "start": 2292, - "end": 2325 + "start": 2321, + "end": 2354 }, - "nodeId": "I3879:35534;20:3849", + "nodeId": "3879:35534", "property": "node", - "assetId": "I3879:35534;20:3849:node", + "assetId": "3879:35534:node", "resolution": "asset" }, { "generatedRange": { - "start": 2446, - "end": 2947 + "start": 2475, + "end": 2976 }, "nodeId": "3879:35535", "resolution": "node" }, { "generatedRange": { - "start": 2455, - "end": 2459 + "start": 2484, + "end": 2488 }, "nodeId": "3879:35535", "property": "type", @@ -1411,8 +1381,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2474, - "end": 2492 + "start": 2503, + "end": 2521 }, "nodeId": "3879:35535", "property": "fills", @@ -1421,8 +1391,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2474, - "end": 2492 + "start": 2503, + "end": 2521 }, "nodeId": "3879:35535", "property": "styledTextSegments", @@ -1431,8 +1401,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2474, - "end": 2492 + "start": 2503, + "end": 2521 }, "nodeId": "3879:35535", "property": "styledTextSegments", @@ -1441,8 +1411,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2507, - "end": 2525 + "start": 2536, + "end": 2554 }, "nodeId": "3879:35535", "property": "textAlignHorizontal", @@ -1450,8 +1420,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2540, - "end": 2560 + "start": 2569, + "end": 2589 }, "nodeId": "3879:35535", "property": "styledTextSegments", @@ -1460,8 +1430,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2540, - "end": 2560 + "start": 2569, + "end": 2589 }, "nodeId": "3879:35535", "property": "textStyleId", @@ -1470,8 +1440,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2575, - "end": 2583 + "start": 2604, + "end": 2612 }, "nodeId": "3879:35535", "property": "width", @@ -1479,8 +1449,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2598, - "end": 2618 + "start": 2627, + "end": 2647 }, "nodeId": "3879:35535", "property": "characters", @@ -1488,8 +1458,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2653, - "end": 2669 + "start": 2682, + "end": 2698 }, "nodeId": "3879:35535", "property": "styledTextSegments", @@ -1498,8 +1468,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2670, - "end": 2698 + "start": 2699, + "end": 2727 }, "nodeId": "3879:35535", "property": "styledTextSegments", @@ -1508,8 +1478,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2716, - "end": 2724 + "start": 2745, + "end": 2753 }, "nodeId": "3879:35535", "property": "characters", @@ -1517,8 +1487,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2761, - "end": 2927 + "start": 2790, + "end": 2956 }, "nodeId": "3879:35535", "property": "characters", @@ -1526,16 +1496,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 2972, - "end": 3192 + "start": 3001, + "end": 3221 }, "nodeId": "3879:35536", "resolution": "node" }, { "generatedRange": { - "start": 2979, - "end": 2983 + "start": 3008, + "end": 3012 }, "nodeId": "3879:35536", "property": "type", @@ -1543,8 +1513,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2984, - "end": 3002 + "start": 3013, + "end": 3031 }, "nodeId": "3879:35536", "property": "fills", @@ -1553,8 +1523,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2984, - "end": 3002 + "start": 3013, + "end": 3031 }, "nodeId": "3879:35536", "property": "styledTextSegments", @@ -1563,8 +1533,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 2984, - "end": 3002 + "start": 3013, + "end": 3031 }, "nodeId": "3879:35536", "property": "styledTextSegments", @@ -1573,8 +1543,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3003, - "end": 3025 + "start": 3032, + "end": 3054 }, "nodeId": "3879:35536", "property": "styledTextSegments", @@ -1583,8 +1553,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3003, - "end": 3025 + "start": 3032, + "end": 3054 }, "nodeId": "3879:35536", "property": "textStyleId", @@ -1593,8 +1563,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3026, - "end": 3046 + "start": 3055, + "end": 3075 }, "nodeId": "3879:35536", "property": "characters", @@ -1602,8 +1572,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3060, - "end": 3174 + "start": 3089, + "end": 3203 }, "nodeId": "3879:35536", "property": "characters", @@ -1611,16 +1581,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 3215, - "end": 4270 + "start": 3244, + "end": 4299 }, "nodeId": "3879:35537", "resolution": "node" }, { "generatedRange": { - "start": 3220, - "end": 3226 + "start": 3249, + "end": 3255 }, "nodeId": "3879:35537", "property": "layoutMode", @@ -1628,8 +1598,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3220, - "end": 3226 + "start": 3249, + "end": 3255 }, "nodeId": "3879:35537", "property": "type", @@ -1637,8 +1607,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3237, - "end": 3253 + "start": 3266, + "end": 3282 }, "nodeId": "3879:35537", "property": "fills", @@ -1647,8 +1617,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3264, - "end": 3283 + "start": 3293, + "end": 3312 }, "nodeId": "3879:35537", "property": "cornerRadius", @@ -1656,8 +1626,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3294, - "end": 3303 + "start": 3323, + "end": 3332 }, "nodeId": "3879:35537", "property": "itemSpacing", @@ -1665,8 +1635,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3314, - "end": 3322 + "start": 3343, + "end": 3351 }, "nodeId": "3879:35537", "property": "paddingBottom", @@ -1674,8 +1644,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3314, - "end": 3322 + "start": 3343, + "end": 3351 }, "nodeId": "3879:35537", "property": "paddingLeft", @@ -1683,8 +1653,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3314, - "end": 3322 + "start": 3343, + "end": 3351 }, "nodeId": "3879:35537", "property": "paddingRight", @@ -1692,8 +1662,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3314, - "end": 3322 + "start": 3343, + "end": 3351 }, "nodeId": "3879:35537", "property": "paddingTop", @@ -1701,8 +1671,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3333, - "end": 3347 + "start": 3362, + "end": 3376 }, "nodeId": "3879:35537", "property": "layoutPositioning", @@ -1710,8 +1680,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3358, - "end": 3366 + "start": 3387, + "end": 3395 }, "nodeId": "3879:35537", "property": "width", @@ -1719,16 +1689,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 3381, - "end": 3469 + "start": 3410, + "end": 3498 }, "nodeId": "3879:35538", "resolution": "node" }, { "generatedRange": { - "start": 3388, - "end": 3392 + "start": 3417, + "end": 3421 }, "nodeId": "3879:35538", "property": "type", @@ -1736,8 +1706,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3393, - "end": 3406 + "start": 3422, + "end": 3435 }, "nodeId": "3879:35538", "property": "fills", @@ -1746,8 +1716,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3393, - "end": 3406 + "start": 3422, + "end": 3435 }, "nodeId": "3879:35538", "property": "styledTextSegments", @@ -1756,8 +1726,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3407, - "end": 3434 + "start": 3436, + "end": 3463 }, "nodeId": "3879:35538", "property": "styledTextSegments", @@ -1766,8 +1736,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3407, - "end": 3434 + "start": 3436, + "end": 3463 }, "nodeId": "3879:35538", "property": "textStyleId", @@ -1776,8 +1746,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3448, - "end": 3451 + "start": 3477, + "end": 3480 }, "nodeId": "3879:35538", "property": "characters", @@ -1785,16 +1755,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 3474, - "end": 3740 + "start": 3503, + "end": 3769 }, "nodeId": "3879:35539", "resolution": "node" }, { "generatedRange": { - "start": 3481, - "end": 3485 + "start": 3510, + "end": 3514 }, "nodeId": "3879:35539", "property": "type", @@ -1802,8 +1772,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3486, - "end": 3499 + "start": 3515, + "end": 3528 }, "nodeId": "3879:35539", "property": "fills", @@ -1812,8 +1782,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3486, - "end": 3499 + "start": 3515, + "end": 3528 }, "nodeId": "3879:35539", "property": "styledTextSegments", @@ -1822,8 +1792,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3500, - "end": 3519 + "start": 3529, + "end": 3548 }, "nodeId": "3879:35539", "property": "styledTextSegments", @@ -1832,8 +1802,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3500, - "end": 3519 + "start": 3529, + "end": 3548 }, "nodeId": "3879:35539", "property": "textStyleId", @@ -1842,8 +1812,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3520, - "end": 3540 + "start": 3549, + "end": 3569 }, "nodeId": "3879:35539", "property": "characters", @@ -1851,8 +1821,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3554, - "end": 3722 + "start": 3583, + "end": 3751 }, "nodeId": "3879:35539", "property": "characters", @@ -1860,16 +1830,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 3745, - "end": 4252 + "start": 3774, + "end": 4281 }, "nodeId": "3879:35540", "resolution": "node" }, { "generatedRange": { - "start": 3752, - "end": 3756 + "start": 3781, + "end": 3785 }, "nodeId": "3879:35540", "property": "layoutMode", @@ -1877,8 +1847,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3752, - "end": 3756 + "start": 3781, + "end": 3785 }, "nodeId": "3879:35540", "property": "type", @@ -1886,8 +1856,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3769, - "end": 3788 + "start": 3798, + "end": 3817 }, "nodeId": "3879:35540", "property": "counterAxisAlignItems", @@ -1895,8 +1865,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3801, - "end": 3822 + "start": 3830, + "end": 3851 }, "nodeId": "3879:35540", "property": "cornerRadius", @@ -1904,8 +1874,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3835, - "end": 3843 + "start": 3864, + "end": 3872 }, "nodeId": "3879:35540", "property": "paddingBottom", @@ -1913,8 +1883,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3835, - "end": 3843 + "start": 3864, + "end": 3872 }, "nodeId": "3879:35540", "property": "paddingLeft", @@ -1922,8 +1892,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3835, - "end": 3843 + "start": 3864, + "end": 3872 }, "nodeId": "3879:35540", "property": "paddingRight", @@ -1931,8 +1901,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3835, - "end": 3843 + "start": 3864, + "end": 3872 }, "nodeId": "3879:35540", "property": "paddingTop", @@ -1940,8 +1910,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3856, - "end": 3870 + "start": 3885, + "end": 3899 }, "nodeId": "3879:35540", "property": "layoutPositioning", @@ -1949,8 +1919,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3883, - "end": 3894 + "start": 3912, + "end": 3923 }, "nodeId": "3879:35540", "property": "x", @@ -1958,8 +1928,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3907, - "end": 3916 + "start": 3936, + "end": 3945 }, "nodeId": "3879:35540", "property": "y", @@ -1967,16 +1937,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 3933, - "end": 4234 + "start": 3962, + "end": 4263 }, "nodeId": "3879:35541", "resolution": "node" }, { "generatedRange": { - "start": 3942, - "end": 3945 + "start": 3971, + "end": 3974 }, "nodeId": "3879:35541", "property": "type", @@ -1984,8 +1954,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3960, - "end": 3975 + "start": 3989, + "end": 4004 }, "nodeId": "3879:35541", "property": "targetAspectRatio", @@ -1993,8 +1963,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3990, - "end": 4000 + "start": 4019, + "end": 4029 }, "nodeId": "3879:35541", "property": "fills", @@ -2003,8 +1973,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 3990, - "end": 4000 + "start": 4019, + "end": 4029 }, "nodeId": "3879:35542", "property": "fills", @@ -2013,8 +1983,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4015, - "end": 4029 + "start": 4044, + "end": 4058 }, "nodeId": "3879:35541", "property": "height", @@ -2022,8 +1992,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4015, - "end": 4029 + "start": 4044, + "end": 4058 }, "nodeId": "3879:35541", "property": "width", @@ -2031,26 +2001,26 @@ expression: output.source_map }, { "generatedRange": { - "start": 4044, - "end": 4077 + "start": 4073, + "end": 4106 }, - "nodeId": "3879:35542", + "nodeId": "3879:35541", "property": "node", - "assetId": "3879:35542:node", + "assetId": "3879:35541:node", "resolution": "asset" }, { "generatedRange": { - "start": 4291, - "end": 13586 + "start": 4320, + "end": 13615 }, "nodeId": "3879:35543", "resolution": "node" }, { "generatedRange": { - "start": 4294, - "end": 4300 + "start": 4323, + "end": 4329 }, "nodeId": "3879:35543", "property": "layoutMode", @@ -2058,8 +2028,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4294, - "end": 4300 + "start": 4323, + "end": 4329 }, "nodeId": "3879:35543", "property": "type", @@ -2067,16 +2037,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 4306, - "end": 5191 + "start": 4335, + "end": 5220 }, "nodeId": "3879:35544", "resolution": "node" }, { "generatedRange": { - "start": 4311, - "end": 4317 + "start": 4340, + "end": 4346 }, "nodeId": "3879:35544", "property": "layoutMode", @@ -2084,8 +2054,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4311, - "end": 4317 + "start": 4340, + "end": 4346 }, "nodeId": "3879:35544", "property": "type", @@ -2093,16 +2063,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 4323, - "end": 4946 + "start": 4352, + "end": 4975 }, "nodeId": "3879:35545", "resolution": "node" }, { "generatedRange": { - "start": 4330, - "end": 4334 + "start": 4359, + "end": 4363 }, "nodeId": "3879:35545", "property": "layoutMode", @@ -2110,8 +2080,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4330, - "end": 4334 + "start": 4359, + "end": 4363 }, "nodeId": "3879:35545", "property": "type", @@ -2119,8 +2089,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4347, - "end": 4366 + "start": 4376, + "end": 4395 }, "nodeId": "3879:35545", "property": "counterAxisAlignItems", @@ -2128,8 +2098,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4379, - "end": 4400 + "start": 4408, + "end": 4429 }, "nodeId": "3879:35545", "property": "fills", @@ -2138,8 +2108,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4413, - "end": 4443 + "start": 4442, + "end": 4472 }, "nodeId": "3879:35545", "property": "primaryAxisAlignItems", @@ -2147,8 +2117,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4456, - "end": 4465 + "start": 4485, + "end": 4494 }, "nodeId": "3879:35545", "property": "paddingLeft", @@ -2156,8 +2126,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4456, - "end": 4465 + "start": 4485, + "end": 4494 }, "nodeId": "3879:35545", "property": "paddingRight", @@ -2165,8 +2135,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4478, - "end": 4487 + "start": 4507, + "end": 4516 }, "nodeId": "3879:35545", "property": "paddingBottom", @@ -2174,8 +2144,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4478, - "end": 4487 + "start": 4507, + "end": 4516 }, "nodeId": "3879:35545", "property": "paddingTop", @@ -2183,8 +2153,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4500, - "end": 4509 + "start": 4529, + "end": 4538 }, "nodeId": "3879:35545", "property": "width", @@ -2192,16 +2162,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 4526, - "end": 4644 + "start": 4555, + "end": 4673 }, "nodeId": "I3879:35545;1690:32933", "resolution": "node" }, { "generatedRange": { - "start": 4535, - "end": 4539 + "start": 4564, + "end": 4568 }, "nodeId": "I3879:35545;1690:32933", "property": "type", @@ -2209,8 +2179,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4540, - "end": 4553 + "start": 4569, + "end": 4582 }, "nodeId": "I3879:35545;1690:32933", "property": "fills", @@ -2219,8 +2189,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4540, - "end": 4553 + "start": 4569, + "end": 4582 }, "nodeId": "I3879:35545;1690:32933", "property": "styledTextSegments", @@ -2229,8 +2199,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4554, - "end": 4581 + "start": 4583, + "end": 4610 }, "nodeId": "I3879:35545;1690:32933", "property": "styledTextSegments", @@ -2239,8 +2209,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4554, - "end": 4581 + "start": 4583, + "end": 4610 }, "nodeId": "I3879:35545;1690:32933", "property": "textStyleId", @@ -2249,8 +2219,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4582, - "end": 4602 + "start": 4611, + "end": 4631 }, "nodeId": "I3879:35545;1690:32933", "property": "characters", @@ -2258,8 +2228,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4618, - "end": 4624 + "start": 4647, + "end": 4653 }, "nodeId": "I3879:35545;1690:32933", "property": "characters", @@ -2267,16 +2237,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 4649, - "end": 4928 + "start": 4678, + "end": 4957 }, "nodeId": "I3879:35545;1690:32948", "resolution": "node" }, { "generatedRange": { - "start": 4658, - "end": 4662 + "start": 4687, + "end": 4691 }, "nodeId": "I3879:35545;1690:32948", "property": "layoutMode", @@ -2284,8 +2254,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4658, - "end": 4662 + "start": 4687, + "end": 4691 }, "nodeId": "I3879:35545;1690:32948", "property": "type", @@ -2293,8 +2263,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4663, - "end": 4682 + "start": 4692, + "end": 4711 }, "nodeId": "I3879:35545;1690:32948", "property": "counterAxisAlignItems", @@ -2302,8 +2272,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4683, - "end": 4692 + "start": 4712, + "end": 4721 }, "nodeId": "I3879:35545;1690:32948", "property": "itemSpacing", @@ -2311,16 +2281,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 4698, - "end": 4771 + "start": 4727, + "end": 4800 }, "nodeId": "I3879:35545;1690:32934", "resolution": "node" }, { "generatedRange": { - "start": 4709, - "end": 4714 + "start": 4738, + "end": 4743 }, "nodeId": "I3879:35545;1690:32934", "property": "type", @@ -2328,8 +2298,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4715, - "end": 4730 + "start": 4744, + "end": 4759 }, "nodeId": "I3879:35545;1690:32934", "property": "targetAspectRatio", @@ -2337,8 +2307,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4731, - "end": 4745 + "start": 4760, + "end": 4774 }, "nodeId": "I3879:35545;1690:32934", "property": "height", @@ -2346,8 +2316,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4731, - "end": 4745 + "start": 4760, + "end": 4774 }, "nodeId": "I3879:35545;1690:32934", "property": "width", @@ -2355,56 +2325,26 @@ expression: output.source_map }, { "generatedRange": { - "start": 4746, - "end": 4768 - }, - "nodeId": "I3879:35545;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35545;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 4746, - "end": 4768 - }, - "nodeId": "I3879:35545;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35545;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 4746, - "end": 4768 + "start": 4775, + "end": 4797 }, - "nodeId": "I3879:35545;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35545;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 4746, - "end": 4768 - }, - "nodeId": "I3879:35545;1690:32934;17:2038", + "nodeId": "I3879:35545;1690:32934", "property": "node", - "assetId": "I3879:35545;1690:32934;17:2038:node", + "assetId": "I3879:35545;1690:32934:node", "resolution": "asset" }, { "generatedRange": { - "start": 4776, - "end": 4908 + "start": 4805, + "end": 4937 }, "nodeId": "I3879:35545;1690:32947", "resolution": "node" }, { "generatedRange": { - "start": 4787, - "end": 4791 + "start": 4816, + "end": 4820 }, "nodeId": "I3879:35545;1690:32947", "property": "type", @@ -2412,8 +2352,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4792, - "end": 4810 + "start": 4821, + "end": 4839 }, "nodeId": "I3879:35545;1690:32947", "property": "fills", @@ -2422,8 +2362,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4792, - "end": 4810 + "start": 4821, + "end": 4839 }, "nodeId": "I3879:35545;1690:32947", "property": "styledTextSegments", @@ -2432,8 +2372,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4792, - "end": 4810 + "start": 4821, + "end": 4839 }, "nodeId": "I3879:35545;1690:32947", "property": "styledTextSegments", @@ -2442,8 +2382,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4811, - "end": 4841 + "start": 4840, + "end": 4870 }, "nodeId": "I3879:35545;1690:32947", "property": "styledTextSegments", @@ -2452,8 +2392,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4811, - "end": 4841 + "start": 4840, + "end": 4870 }, "nodeId": "I3879:35545;1690:32947", "property": "textStyleId", @@ -2462,8 +2402,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4842, - "end": 4862 + "start": 4871, + "end": 4891 }, "nodeId": "I3879:35545;1690:32947", "property": "characters", @@ -2471,8 +2411,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4880, - "end": 4886 + "start": 4909, + "end": 4915 }, "nodeId": "I3879:35545;1690:32947", "property": "characters", @@ -2480,16 +2420,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 4951, - "end": 5173 + "start": 4980, + "end": 5202 }, "nodeId": "3879:35546", "resolution": "node" }, { "generatedRange": { - "start": 4958, - "end": 4964 + "start": 4987, + "end": 4993 }, "nodeId": "3879:35546", "property": "layoutMode", @@ -2497,8 +2437,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4958, - "end": 4964 + "start": 4987, + "end": 4993 }, "nodeId": "3879:35546", "property": "type", @@ -2506,8 +2446,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4965, - "end": 4990 + "start": 4994, + "end": 5019 }, "nodeId": "3879:35546", "property": "fills", @@ -2516,8 +2456,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4991, - "end": 5000 + "start": 5020, + "end": 5029 }, "nodeId": "3879:35546", "property": "paddingLeft", @@ -2525,8 +2465,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 4991, - "end": 5000 + "start": 5020, + "end": 5029 }, "nodeId": "3879:35546", "property": "paddingRight", @@ -2534,8 +2474,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5001, - "end": 5010 + "start": 5030, + "end": 5039 }, "nodeId": "3879:35546", "property": "paddingBottom", @@ -2543,8 +2483,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5001, - "end": 5010 + "start": 5030, + "end": 5039 }, "nodeId": "3879:35546", "property": "paddingTop", @@ -2552,16 +2492,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 5016, - "end": 5153 + "start": 5045, + "end": 5182 }, "nodeId": "3879:35547", "resolution": "node" }, { "generatedRange": { - "start": 5025, - "end": 5029 + "start": 5054, + "end": 5058 }, "nodeId": "3879:35547", "property": "type", @@ -2569,8 +2509,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5030, - "end": 5046 + "start": 5059, + "end": 5075 }, "nodeId": "3879:35547", "property": "fills", @@ -2579,8 +2519,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5030, - "end": 5046 + "start": 5059, + "end": 5075 }, "nodeId": "3879:35547", "property": "styledTextSegments", @@ -2589,8 +2529,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5047, - "end": 5055 + "start": 5076, + "end": 5084 }, "nodeId": "3879:35547", "property": "layoutGrow", @@ -2598,8 +2538,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5056, - "end": 5071 + "start": 5085, + "end": 5100 }, "nodeId": "3879:35547", "property": "styledTextSegments", @@ -2608,8 +2548,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5056, - "end": 5071 + "start": 5085, + "end": 5100 }, "nodeId": "3879:35547", "property": "textStyleId", @@ -2618,8 +2558,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5072, - "end": 5092 + "start": 5101, + "end": 5121 }, "nodeId": "3879:35547", "property": "characters", @@ -2627,8 +2567,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5108, - "end": 5133 + "start": 5137, + "end": 5162 }, "nodeId": "3879:35547", "property": "characters", @@ -2636,16 +2576,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 5196, - "end": 6802 + "start": 5225, + "end": 6831 }, "nodeId": "3879:35548", "resolution": "node" }, { "generatedRange": { - "start": 5201, - "end": 5207 + "start": 5230, + "end": 5236 }, "nodeId": "3879:35548", "property": "layoutMode", @@ -2653,8 +2593,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5201, - "end": 5207 + "start": 5230, + "end": 5236 }, "nodeId": "3879:35548", "property": "type", @@ -2662,16 +2602,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 5213, - "end": 5837 + "start": 5242, + "end": 5866 }, "nodeId": "3879:35549", "resolution": "node" }, { "generatedRange": { - "start": 5220, - "end": 5224 + "start": 5249, + "end": 5253 }, "nodeId": "3879:35549", "property": "layoutMode", @@ -2679,8 +2619,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5220, - "end": 5224 + "start": 5249, + "end": 5253 }, "nodeId": "3879:35549", "property": "type", @@ -2688,8 +2628,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5237, - "end": 5256 + "start": 5266, + "end": 5285 }, "nodeId": "3879:35549", "property": "counterAxisAlignItems", @@ -2697,8 +2637,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5269, - "end": 5290 + "start": 5298, + "end": 5319 }, "nodeId": "3879:35549", "property": "fills", @@ -2707,8 +2647,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5303, - "end": 5333 + "start": 5332, + "end": 5362 }, "nodeId": "3879:35549", "property": "primaryAxisAlignItems", @@ -2716,8 +2656,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5346, - "end": 5355 + "start": 5375, + "end": 5384 }, "nodeId": "3879:35549", "property": "paddingLeft", @@ -2725,8 +2665,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5346, - "end": 5355 + "start": 5375, + "end": 5384 }, "nodeId": "3879:35549", "property": "paddingRight", @@ -2734,8 +2674,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5368, - "end": 5377 + "start": 5397, + "end": 5406 }, "nodeId": "3879:35549", "property": "paddingBottom", @@ -2743,8 +2683,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5368, - "end": 5377 + "start": 5397, + "end": 5406 }, "nodeId": "3879:35549", "property": "paddingTop", @@ -2752,8 +2692,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5390, - "end": 5399 + "start": 5419, + "end": 5428 }, "nodeId": "3879:35549", "property": "width", @@ -2761,16 +2701,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 5416, - "end": 5535 + "start": 5445, + "end": 5564 }, "nodeId": "I3879:35549;1690:32933", "resolution": "node" }, { "generatedRange": { - "start": 5425, - "end": 5429 + "start": 5454, + "end": 5458 }, "nodeId": "I3879:35549;1690:32933", "property": "type", @@ -2778,8 +2718,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5430, - "end": 5443 + "start": 5459, + "end": 5472 }, "nodeId": "I3879:35549;1690:32933", "property": "fills", @@ -2788,8 +2728,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5430, - "end": 5443 + "start": 5459, + "end": 5472 }, "nodeId": "I3879:35549;1690:32933", "property": "styledTextSegments", @@ -2798,8 +2738,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5444, - "end": 5471 + "start": 5473, + "end": 5500 }, "nodeId": "I3879:35549;1690:32933", "property": "styledTextSegments", @@ -2808,8 +2748,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5444, - "end": 5471 + "start": 5473, + "end": 5500 }, "nodeId": "I3879:35549;1690:32933", "property": "textStyleId", @@ -2818,8 +2758,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5472, - "end": 5492 + "start": 5501, + "end": 5521 }, "nodeId": "I3879:35549;1690:32933", "property": "characters", @@ -2827,8 +2767,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5508, - "end": 5515 + "start": 5537, + "end": 5544 }, "nodeId": "I3879:35549;1690:32933", "property": "characters", @@ -2836,16 +2776,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 5540, - "end": 5819 + "start": 5569, + "end": 5848 }, "nodeId": "I3879:35549;1690:32948", "resolution": "node" }, { "generatedRange": { - "start": 5549, - "end": 5553 + "start": 5578, + "end": 5582 }, "nodeId": "I3879:35549;1690:32948", "property": "layoutMode", @@ -2853,8 +2793,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5549, - "end": 5553 + "start": 5578, + "end": 5582 }, "nodeId": "I3879:35549;1690:32948", "property": "type", @@ -2862,8 +2802,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5554, - "end": 5573 + "start": 5583, + "end": 5602 }, "nodeId": "I3879:35549;1690:32948", "property": "counterAxisAlignItems", @@ -2871,8 +2811,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5574, - "end": 5583 + "start": 5603, + "end": 5612 }, "nodeId": "I3879:35549;1690:32948", "property": "itemSpacing", @@ -2880,16 +2820,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 5589, - "end": 5662 + "start": 5618, + "end": 5691 }, "nodeId": "I3879:35549;1690:32934", "resolution": "node" }, { "generatedRange": { - "start": 5600, - "end": 5605 + "start": 5629, + "end": 5634 }, "nodeId": "I3879:35549;1690:32934", "property": "type", @@ -2897,8 +2837,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5606, - "end": 5621 + "start": 5635, + "end": 5650 }, "nodeId": "I3879:35549;1690:32934", "property": "targetAspectRatio", @@ -2906,8 +2846,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5622, - "end": 5636 + "start": 5651, + "end": 5665 }, "nodeId": "I3879:35549;1690:32934", "property": "height", @@ -2915,8 +2855,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5622, - "end": 5636 + "start": 5651, + "end": 5665 }, "nodeId": "I3879:35549;1690:32934", "property": "width", @@ -2924,56 +2864,26 @@ expression: output.source_map }, { "generatedRange": { - "start": 5637, - "end": 5659 + "start": 5666, + "end": 5688 }, - "nodeId": "I3879:35549;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35549;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 5637, - "end": 5659 - }, - "nodeId": "I3879:35549;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35549;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 5637, - "end": 5659 - }, - "nodeId": "I3879:35549;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35549;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 5637, - "end": 5659 - }, - "nodeId": "I3879:35549;1690:32934;17:2038", + "nodeId": "I3879:35549;1690:32934", "property": "node", - "assetId": "I3879:35549;1690:32934;17:2038:node", + "assetId": "I3879:35549;1690:32934:node", "resolution": "asset" }, { "generatedRange": { - "start": 5667, - "end": 5799 + "start": 5696, + "end": 5828 }, "nodeId": "I3879:35549;1690:32947", "resolution": "node" }, { "generatedRange": { - "start": 5678, - "end": 5682 + "start": 5707, + "end": 5711 }, "nodeId": "I3879:35549;1690:32947", "property": "type", @@ -2981,8 +2891,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5683, - "end": 5701 + "start": 5712, + "end": 5730 }, "nodeId": "I3879:35549;1690:32947", "property": "fills", @@ -2991,8 +2901,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5683, - "end": 5701 + "start": 5712, + "end": 5730 }, "nodeId": "I3879:35549;1690:32947", "property": "styledTextSegments", @@ -3001,8 +2911,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5683, - "end": 5701 + "start": 5712, + "end": 5730 }, "nodeId": "I3879:35549;1690:32947", "property": "styledTextSegments", @@ -3011,8 +2921,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5702, - "end": 5732 + "start": 5731, + "end": 5761 }, "nodeId": "I3879:35549;1690:32947", "property": "styledTextSegments", @@ -3021,8 +2931,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5702, - "end": 5732 + "start": 5731, + "end": 5761 }, "nodeId": "I3879:35549;1690:32947", "property": "textStyleId", @@ -3031,8 +2941,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5733, - "end": 5753 + "start": 5762, + "end": 5782 }, "nodeId": "I3879:35549;1690:32947", "property": "characters", @@ -3040,8 +2950,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5771, - "end": 5777 + "start": 5800, + "end": 5806 }, "nodeId": "I3879:35549;1690:32947", "property": "characters", @@ -3049,16 +2959,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 5842, - "end": 6784 + "start": 5871, + "end": 6813 }, "nodeId": "3879:35550", "resolution": "node" }, { "generatedRange": { - "start": 5849, - "end": 5855 + "start": 5878, + "end": 5884 }, "nodeId": "3879:35550", "property": "layoutMode", @@ -3066,8 +2976,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5849, - "end": 5855 + "start": 5878, + "end": 5884 }, "nodeId": "3879:35550", "property": "type", @@ -3075,8 +2985,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5856, - "end": 5881 + "start": 5885, + "end": 5910 }, "nodeId": "3879:35550", "property": "fills", @@ -3085,8 +2995,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5882, - "end": 5898 + "start": 5911, + "end": 5927 }, "nodeId": "3879:35550", "property": "layoutMode", @@ -3094,8 +3004,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5899, - "end": 5908 + "start": 5928, + "end": 5937 }, "nodeId": "3879:35550", "property": "paddingLeft", @@ -3103,8 +3013,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5899, - "end": 5908 + "start": 5928, + "end": 5937 }, "nodeId": "3879:35550", "property": "paddingRight", @@ -3112,8 +3022,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5909, - "end": 5918 + "start": 5938, + "end": 5947 }, "nodeId": "3879:35550", "property": "paddingBottom", @@ -3121,8 +3031,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5909, - "end": 5918 + "start": 5938, + "end": 5947 }, "nodeId": "3879:35550", "property": "paddingTop", @@ -3130,16 +3040,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 5924, - "end": 6764 + "start": 5953, + "end": 6793 }, "nodeId": "3879:35551", "resolution": "node" }, { "generatedRange": { - "start": 5933, - "end": 5937 + "start": 5962, + "end": 5966 }, "nodeId": "3879:35551", "property": "type", @@ -3147,8 +3057,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5938, - "end": 5951 + "start": 5967, + "end": 5980 }, "nodeId": "3879:35551", "property": "fills", @@ -3157,8 +3067,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5938, - "end": 5951 + "start": 5967, + "end": 5980 }, "nodeId": "3879:35551", "property": "styledTextSegments", @@ -3167,8 +3077,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5952, - "end": 5969 + "start": 5981, + "end": 5998 }, "nodeId": "3879:35551", "property": "styledTextSegments", @@ -3177,8 +3087,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5952, - "end": 5969 + "start": 5981, + "end": 5998 }, "nodeId": "3879:35551", "property": "textStyleId", @@ -3187,8 +3097,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5970, - "end": 5978 + "start": 5999, + "end": 6007 }, "nodeId": "3879:35551", "property": "width", @@ -3196,8 +3106,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 5979, - "end": 5999 + "start": 6008, + "end": 6028 }, "nodeId": "3879:35551", "property": "characters", @@ -3205,8 +3115,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6015, - "end": 6744 + "start": 6044, + "end": 6773 }, "nodeId": "3879:35551", "property": "characters", @@ -3214,16 +3124,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 6807, - "end": 8893 + "start": 6836, + "end": 8922 }, "nodeId": "3879:35552", "resolution": "node" }, { "generatedRange": { - "start": 6812, - "end": 6818 + "start": 6841, + "end": 6847 }, "nodeId": "3879:35552", "property": "layoutMode", @@ -3231,8 +3141,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6812, - "end": 6818 + "start": 6841, + "end": 6847 }, "nodeId": "3879:35552", "property": "type", @@ -3240,16 +3150,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 6824, - "end": 7448 + "start": 6853, + "end": 7477 }, "nodeId": "3879:35553", "resolution": "node" }, { "generatedRange": { - "start": 6831, - "end": 6835 + "start": 6860, + "end": 6864 }, "nodeId": "3879:35553", "property": "layoutMode", @@ -3257,8 +3167,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6831, - "end": 6835 + "start": 6860, + "end": 6864 }, "nodeId": "3879:35553", "property": "type", @@ -3266,8 +3176,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6848, - "end": 6867 + "start": 6877, + "end": 6896 }, "nodeId": "3879:35553", "property": "counterAxisAlignItems", @@ -3275,8 +3185,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6880, - "end": 6901 + "start": 6909, + "end": 6930 }, "nodeId": "3879:35553", "property": "fills", @@ -3285,8 +3195,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6914, - "end": 6944 + "start": 6943, + "end": 6973 }, "nodeId": "3879:35553", "property": "primaryAxisAlignItems", @@ -3294,8 +3204,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6957, - "end": 6966 + "start": 6986, + "end": 6995 }, "nodeId": "3879:35553", "property": "paddingLeft", @@ -3303,8 +3213,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6957, - "end": 6966 + "start": 6986, + "end": 6995 }, "nodeId": "3879:35553", "property": "paddingRight", @@ -3312,8 +3222,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6979, - "end": 6988 + "start": 7008, + "end": 7017 }, "nodeId": "3879:35553", "property": "paddingBottom", @@ -3321,8 +3231,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 6979, - "end": 6988 + "start": 7008, + "end": 7017 }, "nodeId": "3879:35553", "property": "paddingTop", @@ -3330,8 +3240,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7001, - "end": 7010 + "start": 7030, + "end": 7039 }, "nodeId": "3879:35553", "property": "width", @@ -3339,16 +3249,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 7027, - "end": 7146 + "start": 7056, + "end": 7175 }, "nodeId": "I3879:35553;1690:32933", "resolution": "node" }, { "generatedRange": { - "start": 7036, - "end": 7040 + "start": 7065, + "end": 7069 }, "nodeId": "I3879:35553;1690:32933", "property": "type", @@ -3356,8 +3266,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7041, - "end": 7054 + "start": 7070, + "end": 7083 }, "nodeId": "I3879:35553;1690:32933", "property": "fills", @@ -3366,8 +3276,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7041, - "end": 7054 + "start": 7070, + "end": 7083 }, "nodeId": "I3879:35553;1690:32933", "property": "styledTextSegments", @@ -3376,8 +3286,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7055, - "end": 7082 + "start": 7084, + "end": 7111 }, "nodeId": "I3879:35553;1690:32933", "property": "styledTextSegments", @@ -3386,8 +3296,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7055, - "end": 7082 + "start": 7084, + "end": 7111 }, "nodeId": "I3879:35553;1690:32933", "property": "textStyleId", @@ -3396,8 +3306,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7083, - "end": 7103 + "start": 7112, + "end": 7132 }, "nodeId": "I3879:35553;1690:32933", "property": "characters", @@ -3405,8 +3315,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7119, - "end": 7126 + "start": 7148, + "end": 7155 }, "nodeId": "I3879:35553;1690:32933", "property": "characters", @@ -3414,16 +3324,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 7151, - "end": 7430 + "start": 7180, + "end": 7459 }, "nodeId": "I3879:35553;1690:32948", "resolution": "node" }, { "generatedRange": { - "start": 7160, - "end": 7164 + "start": 7189, + "end": 7193 }, "nodeId": "I3879:35553;1690:32948", "property": "layoutMode", @@ -3431,8 +3341,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7160, - "end": 7164 + "start": 7189, + "end": 7193 }, "nodeId": "I3879:35553;1690:32948", "property": "type", @@ -3440,8 +3350,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7165, - "end": 7184 + "start": 7194, + "end": 7213 }, "nodeId": "I3879:35553;1690:32948", "property": "counterAxisAlignItems", @@ -3449,8 +3359,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7185, - "end": 7194 + "start": 7214, + "end": 7223 }, "nodeId": "I3879:35553;1690:32948", "property": "itemSpacing", @@ -3458,16 +3368,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 7200, - "end": 7273 + "start": 7229, + "end": 7302 }, "nodeId": "I3879:35553;1690:32934", "resolution": "node" }, { "generatedRange": { - "start": 7211, - "end": 7216 + "start": 7240, + "end": 7245 }, "nodeId": "I3879:35553;1690:32934", "property": "type", @@ -3475,8 +3385,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7217, - "end": 7232 + "start": 7246, + "end": 7261 }, "nodeId": "I3879:35553;1690:32934", "property": "targetAspectRatio", @@ -3484,8 +3394,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7233, - "end": 7247 + "start": 7262, + "end": 7276 }, "nodeId": "I3879:35553;1690:32934", "property": "height", @@ -3493,8 +3403,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7233, - "end": 7247 + "start": 7262, + "end": 7276 }, "nodeId": "I3879:35553;1690:32934", "property": "width", @@ -3502,56 +3412,26 @@ expression: output.source_map }, { "generatedRange": { - "start": 7248, - "end": 7270 - }, - "nodeId": "I3879:35553;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35553;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 7248, - "end": 7270 - }, - "nodeId": "I3879:35553;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35553;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 7248, - "end": 7270 - }, - "nodeId": "I3879:35553;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35553;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 7248, - "end": 7270 + "start": 7277, + "end": 7299 }, - "nodeId": "I3879:35553;1690:32934;17:2038", + "nodeId": "I3879:35553;1690:32934", "property": "node", - "assetId": "I3879:35553;1690:32934;17:2038:node", + "assetId": "I3879:35553;1690:32934:node", "resolution": "asset" }, { "generatedRange": { - "start": 7278, - "end": 7410 + "start": 7307, + "end": 7439 }, "nodeId": "I3879:35553;1690:32947", "resolution": "node" }, { "generatedRange": { - "start": 7289, - "end": 7293 + "start": 7318, + "end": 7322 }, "nodeId": "I3879:35553;1690:32947", "property": "type", @@ -3559,8 +3439,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7294, - "end": 7312 + "start": 7323, + "end": 7341 }, "nodeId": "I3879:35553;1690:32947", "property": "fills", @@ -3569,8 +3449,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7294, - "end": 7312 + "start": 7323, + "end": 7341 }, "nodeId": "I3879:35553;1690:32947", "property": "styledTextSegments", @@ -3579,8 +3459,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7294, - "end": 7312 + "start": 7323, + "end": 7341 }, "nodeId": "I3879:35553;1690:32947", "property": "styledTextSegments", @@ -3589,8 +3469,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7313, - "end": 7343 + "start": 7342, + "end": 7372 }, "nodeId": "I3879:35553;1690:32947", "property": "styledTextSegments", @@ -3599,8 +3479,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7313, - "end": 7343 + "start": 7342, + "end": 7372 }, "nodeId": "I3879:35553;1690:32947", "property": "textStyleId", @@ -3609,8 +3489,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7344, - "end": 7364 + "start": 7373, + "end": 7393 }, "nodeId": "I3879:35553;1690:32947", "property": "characters", @@ -3618,8 +3498,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7382, - "end": 7388 + "start": 7411, + "end": 7417 }, "nodeId": "I3879:35553;1690:32947", "property": "characters", @@ -3627,16 +3507,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 7453, - "end": 8875 + "start": 7482, + "end": 8904 }, "nodeId": "3879:35554", "resolution": "node" }, { "generatedRange": { - "start": 7460, - "end": 7466 + "start": 7489, + "end": 7495 }, "nodeId": "3879:35554", "property": "layoutMode", @@ -3644,8 +3524,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7460, - "end": 7466 + "start": 7489, + "end": 7495 }, "nodeId": "3879:35554", "property": "type", @@ -3653,8 +3533,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7467, - "end": 7492 + "start": 7496, + "end": 7521 }, "nodeId": "3879:35554", "property": "fills", @@ -3663,8 +3543,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7493, - "end": 7509 + "start": 7522, + "end": 7538 }, "nodeId": "3879:35554", "property": "layoutMode", @@ -3672,8 +3552,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7510, - "end": 7519 + "start": 7539, + "end": 7548 }, "nodeId": "3879:35554", "property": "paddingLeft", @@ -3681,8 +3561,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7510, - "end": 7519 + "start": 7539, + "end": 7548 }, "nodeId": "3879:35554", "property": "paddingRight", @@ -3690,8 +3570,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7520, - "end": 7529 + "start": 7549, + "end": 7558 }, "nodeId": "3879:35554", "property": "paddingBottom", @@ -3699,8 +3579,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7520, - "end": 7529 + "start": 7549, + "end": 7558 }, "nodeId": "3879:35554", "property": "paddingTop", @@ -3708,16 +3588,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 7535, - "end": 8855 + "start": 7564, + "end": 8884 }, "nodeId": "3879:35555", "resolution": "node" }, { "generatedRange": { - "start": 7544, - "end": 7548 + "start": 7573, + "end": 7577 }, "nodeId": "3879:35555", "property": "type", @@ -3725,8 +3605,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7549, - "end": 7562 + "start": 7578, + "end": 7591 }, "nodeId": "3879:35555", "property": "fills", @@ -3735,8 +3615,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7549, - "end": 7562 + "start": 7578, + "end": 7591 }, "nodeId": "3879:35555", "property": "styledTextSegments", @@ -3745,8 +3625,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7563, - "end": 7580 + "start": 7592, + "end": 7609 }, "nodeId": "3879:35555", "property": "styledTextSegments", @@ -3755,8 +3635,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7563, - "end": 7580 + "start": 7592, + "end": 7609 }, "nodeId": "3879:35555", "property": "textStyleId", @@ -3765,8 +3645,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7581, - "end": 7589 + "start": 7610, + "end": 7618 }, "nodeId": "3879:35555", "property": "width", @@ -3774,8 +3654,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7590, - "end": 7610 + "start": 7619, + "end": 7639 }, "nodeId": "3879:35555", "property": "characters", @@ -3783,8 +3663,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 7626, - "end": 8109 + "start": 7655, + "end": 8138 }, "nodeId": "3879:35555", "property": "characters", @@ -3792,8 +3672,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8130, - "end": 8151 + "start": 8159, + "end": 8180 }, "nodeId": "3879:35555", "property": "styledTextSegments", @@ -3802,8 +3682,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8130, - "end": 8151 + "start": 8159, + "end": 8180 }, "nodeId": "3879:35555", "property": "styledTextSegments", @@ -3812,8 +3692,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8152, - "end": 8177 + "start": 8181, + "end": 8206 }, "nodeId": "3879:35555", "property": "styledTextSegments", @@ -3822,8 +3702,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8195, - "end": 8206 + "start": 8224, + "end": 8235 }, "nodeId": "3879:35555", "property": "characters", @@ -3831,8 +3711,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8243, - "end": 8662 + "start": 8272, + "end": 8691 }, "nodeId": "3879:35555", "property": "characters", @@ -3840,8 +3720,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8683, - "end": 8704 + "start": 8712, + "end": 8733 }, "nodeId": "3879:35555", "property": "styledTextSegments", @@ -3850,8 +3730,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8683, - "end": 8704 + "start": 8712, + "end": 8733 }, "nodeId": "3879:35555", "property": "styledTextSegments", @@ -3860,8 +3740,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8705, - "end": 8730 + "start": 8734, + "end": 8759 }, "nodeId": "3879:35555", "property": "styledTextSegments", @@ -3870,8 +3750,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8748, - "end": 8759 + "start": 8777, + "end": 8788 }, "nodeId": "3879:35555", "property": "characters", @@ -3879,8 +3759,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8796, - "end": 8835 + "start": 8825, + "end": 8864 }, "nodeId": "3879:35555", "property": "characters", @@ -3888,16 +3768,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 8898, - "end": 11146 + "start": 8927, + "end": 11175 }, "nodeId": "3879:35556", "resolution": "node" }, { "generatedRange": { - "start": 8903, - "end": 8909 + "start": 8932, + "end": 8938 }, "nodeId": "3879:35556", "property": "layoutMode", @@ -3905,8 +3785,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8903, - "end": 8909 + "start": 8932, + "end": 8938 }, "nodeId": "3879:35556", "property": "type", @@ -3914,16 +3794,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 8915, - "end": 9539 + "start": 8944, + "end": 9568 }, "nodeId": "3879:35557", "resolution": "node" }, { "generatedRange": { - "start": 8922, - "end": 8926 + "start": 8951, + "end": 8955 }, "nodeId": "3879:35557", "property": "layoutMode", @@ -3931,8 +3811,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8922, - "end": 8926 + "start": 8951, + "end": 8955 }, "nodeId": "3879:35557", "property": "type", @@ -3940,8 +3820,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8939, - "end": 8958 + "start": 8968, + "end": 8987 }, "nodeId": "3879:35557", "property": "counterAxisAlignItems", @@ -3949,8 +3829,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 8971, - "end": 8992 + "start": 9000, + "end": 9021 }, "nodeId": "3879:35557", "property": "fills", @@ -3959,8 +3839,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9005, - "end": 9035 + "start": 9034, + "end": 9064 }, "nodeId": "3879:35557", "property": "primaryAxisAlignItems", @@ -3968,8 +3848,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9048, - "end": 9057 + "start": 9077, + "end": 9086 }, "nodeId": "3879:35557", "property": "paddingLeft", @@ -3977,8 +3857,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9048, - "end": 9057 + "start": 9077, + "end": 9086 }, "nodeId": "3879:35557", "property": "paddingRight", @@ -3986,8 +3866,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9070, - "end": 9079 + "start": 9099, + "end": 9108 }, "nodeId": "3879:35557", "property": "paddingBottom", @@ -3995,8 +3875,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9070, - "end": 9079 + "start": 9099, + "end": 9108 }, "nodeId": "3879:35557", "property": "paddingTop", @@ -4004,8 +3884,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9092, - "end": 9101 + "start": 9121, + "end": 9130 }, "nodeId": "3879:35557", "property": "width", @@ -4013,16 +3893,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 9118, - "end": 9237 + "start": 9147, + "end": 9266 }, "nodeId": "I3879:35557;1690:32933", "resolution": "node" }, { "generatedRange": { - "start": 9127, - "end": 9131 + "start": 9156, + "end": 9160 }, "nodeId": "I3879:35557;1690:32933", "property": "type", @@ -4030,8 +3910,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9132, - "end": 9145 + "start": 9161, + "end": 9174 }, "nodeId": "I3879:35557;1690:32933", "property": "fills", @@ -4040,8 +3920,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9132, - "end": 9145 + "start": 9161, + "end": 9174 }, "nodeId": "I3879:35557;1690:32933", "property": "styledTextSegments", @@ -4050,8 +3930,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9146, - "end": 9173 + "start": 9175, + "end": 9202 }, "nodeId": "I3879:35557;1690:32933", "property": "styledTextSegments", @@ -4060,8 +3940,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9146, - "end": 9173 + "start": 9175, + "end": 9202 }, "nodeId": "I3879:35557;1690:32933", "property": "textStyleId", @@ -4070,8 +3950,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9174, - "end": 9194 + "start": 9203, + "end": 9223 }, "nodeId": "I3879:35557;1690:32933", "property": "characters", @@ -4079,8 +3959,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9210, - "end": 9217 + "start": 9239, + "end": 9246 }, "nodeId": "I3879:35557;1690:32933", "property": "characters", @@ -4088,16 +3968,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 9242, - "end": 9521 + "start": 9271, + "end": 9550 }, "nodeId": "I3879:35557;1690:32948", "resolution": "node" }, { "generatedRange": { - "start": 9251, - "end": 9255 + "start": 9280, + "end": 9284 }, "nodeId": "I3879:35557;1690:32948", "property": "layoutMode", @@ -4105,8 +3985,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9251, - "end": 9255 + "start": 9280, + "end": 9284 }, "nodeId": "I3879:35557;1690:32948", "property": "type", @@ -4114,8 +3994,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9256, - "end": 9275 + "start": 9285, + "end": 9304 }, "nodeId": "I3879:35557;1690:32948", "property": "counterAxisAlignItems", @@ -4123,8 +4003,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9276, - "end": 9285 + "start": 9305, + "end": 9314 }, "nodeId": "I3879:35557;1690:32948", "property": "itemSpacing", @@ -4132,16 +4012,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 9291, - "end": 9364 + "start": 9320, + "end": 9393 }, "nodeId": "I3879:35557;1690:32934", "resolution": "node" }, { "generatedRange": { - "start": 9302, - "end": 9307 + "start": 9331, + "end": 9336 }, "nodeId": "I3879:35557;1690:32934", "property": "type", @@ -4149,8 +4029,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9308, - "end": 9323 + "start": 9337, + "end": 9352 }, "nodeId": "I3879:35557;1690:32934", "property": "targetAspectRatio", @@ -4158,8 +4038,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9324, - "end": 9338 + "start": 9353, + "end": 9367 }, "nodeId": "I3879:35557;1690:32934", "property": "height", @@ -4167,8 +4047,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9324, - "end": 9338 + "start": 9353, + "end": 9367 }, "nodeId": "I3879:35557;1690:32934", "property": "width", @@ -4176,56 +4056,26 @@ expression: output.source_map }, { "generatedRange": { - "start": 9339, - "end": 9361 - }, - "nodeId": "I3879:35557;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35557;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 9339, - "end": 9361 + "start": 9368, + "end": 9390 }, - "nodeId": "I3879:35557;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35557;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 9339, - "end": 9361 - }, - "nodeId": "I3879:35557;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35557;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 9339, - "end": 9361 - }, - "nodeId": "I3879:35557;1690:32934;17:2038", + "nodeId": "I3879:35557;1690:32934", "property": "node", - "assetId": "I3879:35557;1690:32934;17:2038:node", + "assetId": "I3879:35557;1690:32934:node", "resolution": "asset" }, { "generatedRange": { - "start": 9369, - "end": 9501 + "start": 9398, + "end": 9530 }, "nodeId": "I3879:35557;1690:32947", "resolution": "node" }, { "generatedRange": { - "start": 9380, - "end": 9384 + "start": 9409, + "end": 9413 }, "nodeId": "I3879:35557;1690:32947", "property": "type", @@ -4233,8 +4083,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9385, - "end": 9403 + "start": 9414, + "end": 9432 }, "nodeId": "I3879:35557;1690:32947", "property": "fills", @@ -4243,8 +4093,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9385, - "end": 9403 + "start": 9414, + "end": 9432 }, "nodeId": "I3879:35557;1690:32947", "property": "styledTextSegments", @@ -4253,8 +4103,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9385, - "end": 9403 + "start": 9414, + "end": 9432 }, "nodeId": "I3879:35557;1690:32947", "property": "styledTextSegments", @@ -4263,8 +4113,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9404, - "end": 9434 + "start": 9433, + "end": 9463 }, "nodeId": "I3879:35557;1690:32947", "property": "styledTextSegments", @@ -4273,8 +4123,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9404, - "end": 9434 + "start": 9433, + "end": 9463 }, "nodeId": "I3879:35557;1690:32947", "property": "textStyleId", @@ -4283,8 +4133,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9435, - "end": 9455 + "start": 9464, + "end": 9484 }, "nodeId": "I3879:35557;1690:32947", "property": "characters", @@ -4292,8 +4142,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9473, - "end": 9479 + "start": 9502, + "end": 9508 }, "nodeId": "I3879:35557;1690:32947", "property": "characters", @@ -4301,16 +4151,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 9544, - "end": 11128 + "start": 9573, + "end": 11157 }, "nodeId": "3879:35558", "resolution": "node" }, { "generatedRange": { - "start": 9551, - "end": 9557 + "start": 9580, + "end": 9586 }, "nodeId": "3879:35558", "property": "layoutMode", @@ -4318,8 +4168,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9551, - "end": 9557 + "start": 9580, + "end": 9586 }, "nodeId": "3879:35558", "property": "type", @@ -4327,8 +4177,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9558, - "end": 9583 + "start": 9587, + "end": 9612 }, "nodeId": "3879:35558", "property": "fills", @@ -4337,8 +4187,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9584, - "end": 9600 + "start": 9613, + "end": 9629 }, "nodeId": "3879:35558", "property": "layoutMode", @@ -4346,8 +4196,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9601, - "end": 9610 + "start": 9630, + "end": 9639 }, "nodeId": "3879:35558", "property": "paddingLeft", @@ -4355,8 +4205,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9601, - "end": 9610 + "start": 9630, + "end": 9639 }, "nodeId": "3879:35558", "property": "paddingRight", @@ -4364,8 +4214,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9611, - "end": 9620 + "start": 9640, + "end": 9649 }, "nodeId": "3879:35558", "property": "paddingBottom", @@ -4373,8 +4223,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9611, - "end": 9620 + "start": 9640, + "end": 9649 }, "nodeId": "3879:35558", "property": "paddingTop", @@ -4382,16 +4232,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 9626, - "end": 11108 + "start": 9655, + "end": 11137 }, "nodeId": "3879:35559", "resolution": "node" }, { "generatedRange": { - "start": 9635, - "end": 9639 + "start": 9664, + "end": 9668 }, "nodeId": "3879:35559", "property": "type", @@ -4399,8 +4249,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9640, - "end": 9653 + "start": 9669, + "end": 9682 }, "nodeId": "3879:35559", "property": "fills", @@ -4409,8 +4259,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9640, - "end": 9653 + "start": 9669, + "end": 9682 }, "nodeId": "3879:35559", "property": "styledTextSegments", @@ -4419,8 +4269,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9654, - "end": 9671 + "start": 9683, + "end": 9700 }, "nodeId": "3879:35559", "property": "styledTextSegments", @@ -4429,8 +4279,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9654, - "end": 9671 + "start": 9683, + "end": 9700 }, "nodeId": "3879:35559", "property": "textStyleId", @@ -4439,8 +4289,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9672, - "end": 9680 + "start": 9701, + "end": 9709 }, "nodeId": "3879:35559", "property": "width", @@ -4448,8 +4298,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9681, - "end": 9701 + "start": 9710, + "end": 9730 }, "nodeId": "3879:35559", "property": "characters", @@ -4457,8 +4307,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 9717, - "end": 11088 + "start": 9746, + "end": 11117 }, "nodeId": "3879:35559", "property": "characters", @@ -4466,16 +4316,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 11151, - "end": 13570 + "start": 11180, + "end": 13599 }, "nodeId": "3879:35560", "resolution": "node" }, { "generatedRange": { - "start": 11156, - "end": 11162 + "start": 11185, + "end": 11191 }, "nodeId": "3879:35560", "property": "layoutMode", @@ -4483,8 +4333,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11156, - "end": 11162 + "start": 11185, + "end": 11191 }, "nodeId": "3879:35560", "property": "type", @@ -4492,16 +4342,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 11168, - "end": 11792 + "start": 11197, + "end": 11821 }, "nodeId": "3879:35561", "resolution": "node" }, { "generatedRange": { - "start": 11175, - "end": 11179 + "start": 11204, + "end": 11208 }, "nodeId": "3879:35561", "property": "layoutMode", @@ -4509,8 +4359,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11175, - "end": 11179 + "start": 11204, + "end": 11208 }, "nodeId": "3879:35561", "property": "type", @@ -4518,8 +4368,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11192, - "end": 11211 + "start": 11221, + "end": 11240 }, "nodeId": "3879:35561", "property": "counterAxisAlignItems", @@ -4527,8 +4377,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11224, - "end": 11245 + "start": 11253, + "end": 11274 }, "nodeId": "3879:35561", "property": "fills", @@ -4537,8 +4387,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11258, - "end": 11288 + "start": 11287, + "end": 11317 }, "nodeId": "3879:35561", "property": "primaryAxisAlignItems", @@ -4546,8 +4396,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11301, - "end": 11310 + "start": 11330, + "end": 11339 }, "nodeId": "3879:35561", "property": "paddingLeft", @@ -4555,8 +4405,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11301, - "end": 11310 + "start": 11330, + "end": 11339 }, "nodeId": "3879:35561", "property": "paddingRight", @@ -4564,8 +4414,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11323, - "end": 11332 + "start": 11352, + "end": 11361 }, "nodeId": "3879:35561", "property": "paddingBottom", @@ -4573,8 +4423,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11323, - "end": 11332 + "start": 11352, + "end": 11361 }, "nodeId": "3879:35561", "property": "paddingTop", @@ -4582,8 +4432,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11345, - "end": 11354 + "start": 11374, + "end": 11383 }, "nodeId": "3879:35561", "property": "width", @@ -4591,16 +4441,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 11371, - "end": 11490 + "start": 11400, + "end": 11519 }, "nodeId": "I3879:35561;1690:32933", "resolution": "node" }, { "generatedRange": { - "start": 11380, - "end": 11384 + "start": 11409, + "end": 11413 }, "nodeId": "I3879:35561;1690:32933", "property": "type", @@ -4608,8 +4458,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11385, - "end": 11398 + "start": 11414, + "end": 11427 }, "nodeId": "I3879:35561;1690:32933", "property": "fills", @@ -4618,8 +4468,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11385, - "end": 11398 + "start": 11414, + "end": 11427 }, "nodeId": "I3879:35561;1690:32933", "property": "styledTextSegments", @@ -4628,8 +4478,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11399, - "end": 11426 + "start": 11428, + "end": 11455 }, "nodeId": "I3879:35561;1690:32933", "property": "styledTextSegments", @@ -4638,8 +4488,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11399, - "end": 11426 + "start": 11428, + "end": 11455 }, "nodeId": "I3879:35561;1690:32933", "property": "textStyleId", @@ -4648,8 +4498,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11427, - "end": 11447 + "start": 11456, + "end": 11476 }, "nodeId": "I3879:35561;1690:32933", "property": "characters", @@ -4657,8 +4507,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11463, - "end": 11470 + "start": 11492, + "end": 11499 }, "nodeId": "I3879:35561;1690:32933", "property": "characters", @@ -4666,16 +4516,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 11495, - "end": 11774 + "start": 11524, + "end": 11803 }, "nodeId": "I3879:35561;1690:32948", "resolution": "node" }, { "generatedRange": { - "start": 11504, - "end": 11508 + "start": 11533, + "end": 11537 }, "nodeId": "I3879:35561;1690:32948", "property": "layoutMode", @@ -4683,8 +4533,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11504, - "end": 11508 + "start": 11533, + "end": 11537 }, "nodeId": "I3879:35561;1690:32948", "property": "type", @@ -4692,8 +4542,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11509, - "end": 11528 + "start": 11538, + "end": 11557 }, "nodeId": "I3879:35561;1690:32948", "property": "counterAxisAlignItems", @@ -4701,8 +4551,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11529, - "end": 11538 + "start": 11558, + "end": 11567 }, "nodeId": "I3879:35561;1690:32948", "property": "itemSpacing", @@ -4710,16 +4560,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 11544, - "end": 11617 + "start": 11573, + "end": 11646 }, "nodeId": "I3879:35561;1690:32934", "resolution": "node" }, { "generatedRange": { - "start": 11555, - "end": 11560 + "start": 11584, + "end": 11589 }, "nodeId": "I3879:35561;1690:32934", "property": "type", @@ -4727,8 +4577,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11561, - "end": 11576 + "start": 11590, + "end": 11605 }, "nodeId": "I3879:35561;1690:32934", "property": "targetAspectRatio", @@ -4736,8 +4586,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11577, - "end": 11591 + "start": 11606, + "end": 11620 }, "nodeId": "I3879:35561;1690:32934", "property": "height", @@ -4745,8 +4595,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11577, - "end": 11591 + "start": 11606, + "end": 11620 }, "nodeId": "I3879:35561;1690:32934", "property": "width", @@ -4754,56 +4604,26 @@ expression: output.source_map }, { "generatedRange": { - "start": 11592, - "end": 11614 + "start": 11621, + "end": 11643 }, - "nodeId": "I3879:35561;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35561;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 11592, - "end": 11614 - }, - "nodeId": "I3879:35561;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35561;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 11592, - "end": 11614 - }, - "nodeId": "I3879:35561;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35561;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 11592, - "end": 11614 - }, - "nodeId": "I3879:35561;1690:32934;17:2038", + "nodeId": "I3879:35561;1690:32934", "property": "node", - "assetId": "I3879:35561;1690:32934;17:2038:node", + "assetId": "I3879:35561;1690:32934:node", "resolution": "asset" }, { "generatedRange": { - "start": 11622, - "end": 11754 + "start": 11651, + "end": 11783 }, "nodeId": "I3879:35561;1690:32947", "resolution": "node" }, { "generatedRange": { - "start": 11633, - "end": 11637 + "start": 11662, + "end": 11666 }, "nodeId": "I3879:35561;1690:32947", "property": "type", @@ -4811,8 +4631,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11638, - "end": 11656 + "start": 11667, + "end": 11685 }, "nodeId": "I3879:35561;1690:32947", "property": "fills", @@ -4821,8 +4641,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11638, - "end": 11656 + "start": 11667, + "end": 11685 }, "nodeId": "I3879:35561;1690:32947", "property": "styledTextSegments", @@ -4831,8 +4651,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11638, - "end": 11656 + "start": 11667, + "end": 11685 }, "nodeId": "I3879:35561;1690:32947", "property": "styledTextSegments", @@ -4841,8 +4661,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11657, - "end": 11687 + "start": 11686, + "end": 11716 }, "nodeId": "I3879:35561;1690:32947", "property": "styledTextSegments", @@ -4851,8 +4671,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11657, - "end": 11687 + "start": 11686, + "end": 11716 }, "nodeId": "I3879:35561;1690:32947", "property": "textStyleId", @@ -4861,8 +4681,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11688, - "end": 11708 + "start": 11717, + "end": 11737 }, "nodeId": "I3879:35561;1690:32947", "property": "characters", @@ -4870,8 +4690,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11726, - "end": 11732 + "start": 11755, + "end": 11761 }, "nodeId": "I3879:35561;1690:32947", "property": "characters", @@ -4879,16 +4699,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 11797, - "end": 13552 + "start": 11826, + "end": 13581 }, "nodeId": "3879:35562", "resolution": "node" }, { "generatedRange": { - "start": 11804, - "end": 11810 + "start": 11833, + "end": 11839 }, "nodeId": "3879:35562", "property": "layoutMode", @@ -4896,8 +4716,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11804, - "end": 11810 + "start": 11833, + "end": 11839 }, "nodeId": "3879:35562", "property": "type", @@ -4905,8 +4725,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11811, - "end": 11836 + "start": 11840, + "end": 11865 }, "nodeId": "3879:35562", "property": "fills", @@ -4915,8 +4735,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11837, - "end": 11853 + "start": 11866, + "end": 11882 }, "nodeId": "3879:35562", "property": "layoutMode", @@ -4924,8 +4744,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11854, - "end": 11863 + "start": 11883, + "end": 11892 }, "nodeId": "3879:35562", "property": "paddingLeft", @@ -4933,8 +4753,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11854, - "end": 11863 + "start": 11883, + "end": 11892 }, "nodeId": "3879:35562", "property": "paddingRight", @@ -4942,8 +4762,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11864, - "end": 11873 + "start": 11893, + "end": 11902 }, "nodeId": "3879:35562", "property": "paddingBottom", @@ -4951,8 +4771,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11864, - "end": 11873 + "start": 11893, + "end": 11902 }, "nodeId": "3879:35562", "property": "paddingTop", @@ -4960,16 +4780,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 11879, - "end": 13532 + "start": 11908, + "end": 13561 }, "nodeId": "3879:35563", "resolution": "node" }, { "generatedRange": { - "start": 11888, - "end": 11892 + "start": 11917, + "end": 11921 }, "nodeId": "3879:35563", "property": "type", @@ -4977,8 +4797,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11893, - "end": 11906 + "start": 11922, + "end": 11935 }, "nodeId": "3879:35563", "property": "fills", @@ -4987,8 +4807,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11893, - "end": 11906 + "start": 11922, + "end": 11935 }, "nodeId": "3879:35563", "property": "styledTextSegments", @@ -4997,8 +4817,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11907, - "end": 11924 + "start": 11936, + "end": 11953 }, "nodeId": "3879:35563", "property": "styledTextSegments", @@ -5007,8 +4827,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11907, - "end": 11924 + "start": 11936, + "end": 11953 }, "nodeId": "3879:35563", "property": "textStyleId", @@ -5017,8 +4837,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11925, - "end": 11933 + "start": 11954, + "end": 11962 }, "nodeId": "3879:35563", "property": "width", @@ -5026,8 +4846,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11934, - "end": 11954 + "start": 11963, + "end": 11983 }, "nodeId": "3879:35563", "property": "characters", @@ -5035,8 +4855,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 11970, - "end": 13512 + "start": 11999, + "end": 13541 }, "nodeId": "3879:35563", "property": "characters", @@ -5044,16 +4864,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 13591, - "end": 14411 + "start": 13620, + "end": 14439 }, "nodeId": "3879:35564", "resolution": "node" }, { "generatedRange": { - "start": 13594, - "end": 13598 + "start": 13623, + "end": 13627 }, "nodeId": "3879:35564", "property": "layoutMode", @@ -5061,8 +4881,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13594, - "end": 13598 + "start": 13623, + "end": 13627 }, "nodeId": "3879:35564", "property": "type", @@ -5070,8 +4890,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13607, - "end": 13626 + "start": 13636, + "end": 13655 }, "nodeId": "3879:35564", "property": "counterAxisAlignItems", @@ -5079,8 +4899,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13635, - "end": 13660 + "start": 13664, + "end": 13689 }, "nodeId": "3879:35564", "property": "fills", @@ -5089,8 +4909,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13669, - "end": 13698 + "start": 13698, + "end": 13727 }, "nodeId": "3879:35564", "property": "strokes", @@ -5099,8 +4919,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13707, - "end": 13719 + "start": 13736, + "end": 13748 }, "nodeId": "3879:35564", "property": "y", @@ -5108,8 +4928,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13728, - "end": 13758 + "start": 13757, + "end": 13787 }, "nodeId": "3879:35564", "property": "primaryAxisAlignItems", @@ -5117,8 +4937,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13767, - "end": 13777 + "start": 13796, + "end": 13806 }, "nodeId": "3879:35564", "property": "x", @@ -5126,8 +4946,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13786, - "end": 13803 + "start": 13815, + "end": 13832 }, "nodeId": "3879:35564", "property": "clipsContent", @@ -5135,8 +4955,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13812, - "end": 13826 + "start": 13841, + "end": 13855 }, "nodeId": "3879:35564", "property": "layoutPositioning", @@ -5144,8 +4964,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13835, - "end": 13844 + "start": 13864, + "end": 13873 }, "nodeId": "3879:35564", "property": "paddingLeft", @@ -5153,8 +4973,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13835, - "end": 13844 + "start": 13864, + "end": 13873 }, "nodeId": "3879:35564", "property": "paddingRight", @@ -5162,8 +4982,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13853, - "end": 13861 + "start": 13882, + "end": 13890 }, "nodeId": "3879:35564", "property": "paddingBottom", @@ -5171,8 +4991,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13853, - "end": 13861 + "start": 13882, + "end": 13890 }, "nodeId": "3879:35564", "property": "paddingTop", @@ -5180,8 +5000,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13870, - "end": 13878 + "start": 13899, + "end": 13907 }, "nodeId": "3879:35564", "property": "width", @@ -5189,16 +5009,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 13891, - "end": 14187 + "start": 13920, + "end": 14215 }, "nodeId": "3879:35565", "resolution": "node" }, { "generatedRange": { - "start": 13896, - "end": 13902 + "start": 13925, + "end": 13931 }, "nodeId": "3879:35565", "property": "layoutMode", @@ -5206,8 +5026,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13896, - "end": 13902 + "start": 13925, + "end": 13931 }, "nodeId": "3879:35565", "property": "type", @@ -5215,8 +5035,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13913, - "end": 13926 + "start": 13942, + "end": 13955 }, "nodeId": "3879:35565", "property": "fills", @@ -5225,8 +5045,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13937, - "end": 13967 + "start": 13966, + "end": 13996 }, "nodeId": "3879:35565", "property": "strokes", @@ -5235,8 +5055,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 13978, - "end": 13997 + "start": 14007, + "end": 14026 }, "nodeId": "3879:35565", "property": "cornerRadius", @@ -5244,8 +5064,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14008, - "end": 14017 + "start": 14037, + "end": 14046 }, "nodeId": "3879:35565", "property": "paddingLeft", @@ -5253,8 +5073,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14008, - "end": 14017 + "start": 14037, + "end": 14046 }, "nodeId": "3879:35565", "property": "paddingRight", @@ -5262,8 +5082,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14028, - "end": 14037 + "start": 14057, + "end": 14065 }, "nodeId": "3879:35565", "property": "paddingBottom", @@ -5271,8 +5091,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14028, - "end": 14037 + "start": 14057, + "end": 14065 }, "nodeId": "3879:35565", "property": "paddingTop", @@ -5280,16 +5100,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 14052, - "end": 14169 + "start": 14080, + "end": 14197 }, "nodeId": "3879:35566", "resolution": "node" }, { "generatedRange": { - "start": 14059, - "end": 14063 + "start": 14087, + "end": 14091 }, "nodeId": "3879:35566", "property": "type", @@ -5297,8 +5117,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14064, - "end": 14077 + "start": 14092, + "end": 14105 }, "nodeId": "3879:35566", "property": "fills", @@ -5307,8 +5127,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14064, - "end": 14077 + "start": 14092, + "end": 14105 }, "nodeId": "3879:35566", "property": "styledTextSegments", @@ -5317,8 +5137,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14078, - "end": 14103 + "start": 14106, + "end": 14131 }, "nodeId": "3879:35566", "property": "styledTextSegments", @@ -5327,8 +5147,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14078, - "end": 14103 + "start": 14106, + "end": 14131 }, "nodeId": "3879:35566", "property": "textStyleId", @@ -5337,8 +5157,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14104, - "end": 14124 + "start": 14132, + "end": 14152 }, "nodeId": "3879:35566", "property": "characters", @@ -5346,8 +5166,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14138, - "end": 14151 + "start": 14166, + "end": 14179 }, "nodeId": "3879:35566", "property": "characters", @@ -5355,16 +5175,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 14192, - "end": 14397 + "start": 14220, + "end": 14425 }, "nodeId": "3879:35567", "resolution": "node" }, { "generatedRange": { - "start": 14197, - "end": 14203 + "start": 14225, + "end": 14231 }, "nodeId": "3879:35567", "property": "layoutMode", @@ -5372,8 +5192,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14197, - "end": 14203 + "start": 14225, + "end": 14231 }, "nodeId": "3879:35567", "property": "type", @@ -5381,8 +5201,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14204, - "end": 14217 + "start": 14232, + "end": 14245 }, "nodeId": "3879:35567", "property": "fills", @@ -5391,8 +5211,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14218, - "end": 14237 + "start": 14246, + "end": 14265 }, "nodeId": "3879:35567", "property": "cornerRadius", @@ -5400,8 +5220,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14238, - "end": 14247 + "start": 14266, + "end": 14275 }, "nodeId": "3879:35567", "property": "paddingLeft", @@ -5409,8 +5229,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14238, - "end": 14247 + "start": 14266, + "end": 14275 }, "nodeId": "3879:35567", "property": "paddingRight", @@ -5418,8 +5238,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14248, - "end": 14257 + "start": 14276, + "end": 14285 }, "nodeId": "3879:35567", "property": "paddingBottom", @@ -5427,8 +5247,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14248, - "end": 14257 + "start": 14276, + "end": 14285 }, "nodeId": "3879:35567", "property": "paddingTop", @@ -5436,16 +5256,16 @@ expression: output.source_map }, { "generatedRange": { - "start": 14263, - "end": 14379 + "start": 14291, + "end": 14407 }, "nodeId": "3879:35568", "resolution": "node" }, { "generatedRange": { - "start": 14270, - "end": 14274 + "start": 14298, + "end": 14302 }, "nodeId": "3879:35568", "property": "type", @@ -5453,8 +5273,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14275, - "end": 14287 + "start": 14303, + "end": 14315 }, "nodeId": "3879:35568", "property": "fills", @@ -5462,8 +5282,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14288, - "end": 14313 + "start": 14316, + "end": 14341 }, "nodeId": "3879:35568", "property": "styledTextSegments", @@ -5472,8 +5292,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14288, - "end": 14313 + "start": 14316, + "end": 14341 }, "nodeId": "3879:35568", "property": "textStyleId", @@ -5482,8 +5302,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14314, - "end": 14334 + "start": 14342, + "end": 14362 }, "nodeId": "3879:35568", "property": "characters", @@ -5491,8 +5311,8 @@ expression: output.source_map }, { "generatedRange": { - "start": 14348, - "end": 14361 + "start": 14376, + "end": 14389 }, "nodeId": "3879:35568", "property": "characters", diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap index 44892acd..3e618694 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap @@ -1,5 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +assertion_line: 218 expression: output.tsx --- import { Box, Center, Image, Text, VStack } from "@devup-ui/react"; @@ -14,13 +15,7 @@ export function Wquw151Frame387935503() { overflow="hidden" w="360px" > -
+
-
+
공개 설정 : @@ -71,13 +71,14 @@ export function Wquw151Frame387935518() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > 편집 설정 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap index 854353c0..4e58b5a0 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap @@ -87,7 +87,7 @@ export function Wquw151Frame387935569() { -
+
공개 설정 : @@ -116,13 +116,14 @@ export function Wquw151Frame387935569() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > - + @@ -472,7 +473,7 @@ export function Wquw151Frame387935569() { maskRepeat="no-repeat" maskSize="contain" /> - + 서정체 @@ -492,7 +493,7 @@ export function Wquw151Frame387935569() { maskRepeat="no-repeat" maskSize="contain" /> - + 강건체 @@ -568,7 +569,7 @@ export function Wquw151Frame387935569() { maskRepeat="no-repeat" maskSize="contain" /> - + 내 문장 그대로 맞춤법만 다듬기 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap index f9eb53db..33dc17d4 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap @@ -87,7 +87,7 @@ export function Wquw151Frame387935652() { -
+
공개 설정 : @@ -116,13 +116,14 @@ export function Wquw151Frame387935652() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > - + @@ -472,7 +473,7 @@ export function Wquw151Frame387935652() { maskRepeat="no-repeat" maskSize="contain" /> - + 서정체 @@ -492,7 +493,7 @@ export function Wquw151Frame387935652() { maskRepeat="no-repeat" maskSize="contain" /> - + 강건체 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap index bde8a814..568c2129 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap @@ -87,7 +87,7 @@ export function Wquw151Frame387935729() { -
+
공개 설정 : @@ -116,13 +116,14 @@ export function Wquw151Frame387935729() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > - + @@ -472,7 +473,7 @@ export function Wquw151Frame387935729() { maskRepeat="no-repeat" maskSize="contain" /> - + 내 문장 그대로 맞춤법만 다듬기 diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap index a88be3fa..b80eb0ee 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap @@ -87,7 +87,7 @@ export function Wquw151Frame387935887() { -
+
공개 설정 : @@ -116,13 +116,14 @@ export function Wquw151Frame387935887() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > - + @@ -557,7 +558,7 @@ export function Wquw151Frame387935887() { maskRepeat="no-repeat" maskSize="contain" /> - + 내 문장 그대로 맞춤법만 다듬기 @@ -574,6 +575,8 @@ export function Wquw151Frame387935887() { left="50%" overflow="hidden" pos="absolute" + px="20px" + py="233.5px" top="0px" transform="translateX(-50%)" w="100%" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap index fea50e7e..fed25fbf 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap @@ -87,7 +87,7 @@ export function Wquw151Frame387935973() { -
+
공개 설정 : @@ -116,13 +116,14 @@ export function Wquw151Frame387935973() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > - + @@ -557,7 +558,7 @@ export function Wquw151Frame387935973() { maskRepeat="no-repeat" maskSize="contain" /> - + 내 문장 그대로 맞춤법만 다듬기 @@ -573,7 +574,10 @@ export function Wquw151Frame387935973() { bg="#000000B2" left="50%" overflow="hidden" + pb="218.5px" pos="absolute" + pt="219.5px" + px="20px" top="0px" transform="translateX(-50%)" w="100%" @@ -603,7 +607,7 @@ export function Wquw151Frame387935973() { {" "}{"'"}담담체 - {"'"}를
이야기 전체에 적용하시겠습니까?
글을 다시 정리하므로
시간이 조금 소요될 수 있습니다.
기존에 정리된 글은 사라집니다. + {"'"}를
이야기 전체에 적용하시겠습니까?
글을 다시 정리하므로
시간이 조금 소요될 수 있습니다.
기존에 정리된 글은 사라집니다.
diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap index 12428853..93ac2b0d 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap @@ -1,5 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +assertion_line: 218 expression: output.tsx --- import { Box, Center, Flex, Image, Text, VStack } from "@devup-ui/react"; @@ -18,23 +19,20 @@ export function Wquw151Frame387936059() { h="66px" justifyContent="space-between" overflow="hidden" - px="0px" py="8px" w="360px" > -
- -
-
+ +
-
- -
-
+ +
정리되었어요 -
+
공개 설정 : @@ -62,13 +62,14 @@ export function Wquw151Frame387936144() { flexDir="column" gap="8px" h="185px" - px="16px" - py="24px" + px="14px" + py="22px" > 교정 설정 diff --git a/crates/devup-mcp-devup-ui/tests/support/mod.rs b/crates/devup-mcp-devup-ui/tests/support/mod.rs index ce4b80dc..a715cf37 100644 --- a/crates/devup-mcp-devup-ui/tests/support/mod.rs +++ b/crates/devup-mcp-devup-ui/tests/support/mod.rs @@ -75,8 +75,8 @@ pub enum FixtureError { impl std::fmt::Display for FixtureError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Io(error) => write!(formatter, "fixture를 읽지 못했습니다: {error}"), - Self::Json(error) => write!(formatter, "fixture JSON이 올바르지 않습니다: {error}"), + Self::Io(error) => write!(formatter, "failed to read fixture: {error}"), + Self::Json(error) => write!(formatter, "fixture JSON is invalid: {error}"), Self::Invalid(message) => formatter.write_str(message), } } @@ -94,13 +94,13 @@ pub fn load_case(path: impl AsRef) -> Result { fn validate_case(case: &FixtureCase) -> Result<(), FixtureError> { if case.schema_version != 1 { return Err(FixtureError::Invalid(format!( - "지원하지 않는 fixture schemaVersion입니다: {}", + "unsupported fixture schemaVersion: {}", case.schema_version ))); } if case.id.trim().is_empty() || case.source.test_id.trim().is_empty() { return Err(FixtureError::Invalid( - "fixture id와 source.testId는 비어 있을 수 없습니다.".to_owned(), + "fixture id and source.testId must not be empty.".to_owned(), )); } if case.source.commit.len() != 40 @@ -111,7 +111,7 @@ fn validate_case(case: &FixtureCase) -> Result<(), FixtureError> { .all(|byte| byte.is_ascii_hexdigit()) { return Err(FixtureError::Invalid( - "source.commit은 40자리 git SHA여야 합니다.".to_owned(), + "source.commit must be a 40-character git SHA.".to_owned(), )); } if !case @@ -121,7 +121,7 @@ fn validate_case(case: &FixtureCase) -> Result<(), FixtureError> { .contains_key(&case.request.root_id) { return Err(FixtureError::Invalid(format!( - "rootId '{}'가 payload에 없습니다.", + "rootId '{}' is missing from the payload.", case.request.root_id ))); } @@ -155,7 +155,7 @@ pub fn run_case(case: &FixtureCase) -> Result { let variables = case.payload.variables.as_ref().ok_or_else(|| { DevupError::new( devup_mcp_figma::ErrorCode::DevupThemeConflict, - "devup-json fixture에는 variables payload가 필요합니다.", + "devup-json fixture requires a variables payload.", false, ) })?; @@ -178,7 +178,7 @@ pub fn run_case(case: &FixtureCase) -> Result { Err(error) => serde_json::to_value(error).map_err(|_| { DevupError::new( devup_mcp_figma::ErrorCode::DevupCodegenFailed, - "오류 fixture 결과를 직렬화하지 못했습니다.", + "Failed to serialize the error fixture result.", false, ) }), @@ -418,7 +418,7 @@ pub fn validate_coverage_registry(root: &Path) -> Result Result Result Result Result {}", + "coverage evidence test symbol is missing: {} -> {}", evidence.rust_test, evidence.source_path )); } } Err(error) => violations.push(format!( - "coverage source를 읽을 수 없습니다: {}: {error}", + "cannot read coverage source: {}: {error}", evidence.source_path )), } @@ -513,7 +516,7 @@ pub fn validate_coverage_registry(root: &Path) -> Result {} _ => violations.push(format!( - "rust_snapshot이 실행 가능한 snapshot parity test를 참조하지 않습니다: {} -> {}", + "rust_snapshot does not reference an executable snapshot parity test: {} -> {}", entry.test_id, entry.rust_test )), } @@ -524,7 +527,7 @@ pub fn validate_coverage_registry(root: &Path) -> Result {} _ => violations.push(format!( - "rust_assertion이 등록된 대표 Rust test를 참조하지 않습니다: {} -> {}", + "rust_assertion does not reference a registered representative Rust test: {} -> {}", entry.test_id, entry.rust_test )), } @@ -537,7 +540,7 @@ pub fn validate_coverage_registry(root: &Path) -> Result { @@ -547,19 +550,19 @@ pub fn validate_coverage_registry(root: &Path) -> Result {} _ => violations.push(format!( - "비-parity 경계가 등록된 Rust contract를 참조하지 않습니다: {} -> {}", + "non-parity boundary does not reference a registered Rust contract: {} -> {}", entry.test_id, entry.rust_test )), } } LedgerClassification::Contract => violations.push(format!( - "모호한 contract 분류를 실행 evidence 또는 명시적 비-parity로 바꿔야 합니다: {}", + "ambiguous contract classification must become executable evidence or explicit non-parity: {}", entry.test_id )), } @@ -567,17 +570,17 @@ pub fn validate_coverage_registry(root: &Path) -> Result Result> { Err(error) => return Err(vec![error]), }; if manifest.schema_version != 1 || ledger.schema_version != 1 { - violations.push("manifest와 ledger schemaVersion은 1이어야 합니다.".to_owned()); + violations.push("manifest and ledger schemaVersion must be 1.".to_owned()); } if manifest.source.commit.len() != 40 || !manifest @@ -617,7 +620,7 @@ pub fn validate_corpus(root: &Path) -> Result> { .bytes() .all(|byte| byte.is_ascii_hexdigit()) { - violations.push("manifest source.commit이 40자리 git SHA가 아닙니다.".to_owned()); + violations.push("manifest source.commit is not a 40-character git SHA.".to_owned()); } if manifest.baseline.test_files != 54 || manifest.baseline.passed != 978 @@ -625,10 +628,10 @@ pub fn validate_corpus(root: &Path) -> Result> { || manifest.baseline.snapshots != 268 || manifest.baseline.assertions != 1_974 { - violations.push("고정 upstream baseline 수치가 일치하지 않습니다.".to_owned()); + violations.push("pinned upstream baseline counts do not match.".to_owned()); } if manifest.source_test_files.len() != manifest.baseline.test_files { - violations.push("source test file 수가 baseline과 일치하지 않습니다.".to_owned()); + violations.push("source test file count does not match the baseline.".to_owned()); } duplicate_values( manifest.source_test_files.iter().map(String::as_str), @@ -649,10 +652,12 @@ pub fn validate_corpus(root: &Path) -> Result> { .map(|file| file.path.clone()) .collect::>(); for path in discovered.difference(&declared) { - violations.push(format!("manifest에 없는 orphan 파일: {path}")); + violations.push(format!("orphan file missing from the manifest: {path}")); } for path in declared.difference(&discovered) { - violations.push(format!("실제로 존재하지 않는 manifest 파일: {path}")); + violations.push(format!( + "manifest file that does not actually exist: {path}" + )); } for file in &manifest.files { let path = root.join(file.path.replace('/', std::path::MAIN_SEPARATOR_STR)); @@ -660,10 +665,10 @@ pub fn validate_corpus(root: &Path) -> Result> { Ok(bytes) => { let actual = hex_sha256(&bytes); if actual != file.sha256 { - violations.push(format!("checksum 불일치: {}", file.path)); + violations.push(format!("checksum mismatch: {}", file.path)); } } - Err(error) => violations.push(format!("{} 읽기 실패: {error}", file.path)), + Err(error) => violations.push(format!("{} read failed: {error}", file.path)), } } @@ -674,7 +679,7 @@ pub fn validate_corpus(root: &Path) -> Result> { Ok(case) => { if let Some(first) = case_ids.insert(case.id.clone(), relative.clone()) { violations.push(format!( - "중복 fixture id '{}': {first}, {relative}", + "duplicate fixture id '{}': {first}, {relative}", case.id )); } @@ -686,15 +691,15 @@ pub fn validate_corpus(root: &Path) -> Result> { let mut ledger_ids = BTreeSet::new(); for entry in &ledger.entries { if !ledger_ids.insert(entry.test_id.as_str()) { - violations.push(format!("중복 ledger test id: {}", entry.test_id)); + violations.push(format!("duplicate ledger test id: {}", entry.test_id)); } if entry.source_file.trim().is_empty() || entry.rust_test.trim().is_empty() { - violations.push(format!("ledger 경로가 비어 있습니다: {}", entry.test_id)); + violations.push(format!("ledger path is empty: {}", entry.test_id)); } for fixture_id in &entry.fixture_ids { if !case_ids.contains_key(fixture_id) { violations.push(format!( - "ledger가 없는 fixture를 참조합니다: {} -> {fixture_id}", + "ledger references a missing fixture: {} -> {fixture_id}", entry.test_id )); } @@ -702,7 +707,7 @@ pub fn validate_corpus(root: &Path) -> Result> { match entry.classification { LedgerClassification::RustSnapshot if entry.fixture_ids.is_empty() => { violations.push(format!( - "rust_snapshot ledger에 fixture가 없습니다: {}", + "rust_snapshot ledger entry has no fixture: {}", entry.test_id )) } @@ -714,7 +719,10 @@ pub fn validate_corpus(root: &Path) -> Result> { .as_deref() .is_none_or(|value| value.trim().is_empty()) => { - violations.push(format!("분류 근거가 없습니다: {}", entry.test_id)); + violations.push(format!( + "classification has no rationale: {}", + entry.test_id + )); } _ => {} } @@ -724,11 +732,11 @@ pub fn validate_corpus(root: &Path) -> Result> { || manifest.counts.snapshots != snapshot_files.len() || manifest.counts.ledger_entries != ledger.entries.len() { - violations.push("manifest counts가 발견된 corpus와 일치하지 않습니다.".to_owned()); + violations.push("manifest counts do not match the discovered corpus.".to_owned()); } if ledger.entries.len() != manifest.baseline.passed { violations - .push("ledger entry 수가 upstream passing test 수와 일치하지 않습니다.".to_owned()); + .push("ledger entry count does not match the upstream passing test count.".to_owned()); } if violations.is_empty() { @@ -794,7 +802,7 @@ fn duplicate_values<'a>( let mut seen = BTreeSet::new(); for value in values { if !seen.insert(value) { - violations.push(format!("중복 {label}: {value}")); + violations.push(format!("duplicate {label}: {value}")); } } } diff --git a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs new file mode 100644 index 00000000..77b96eca --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs @@ -0,0 +1,275 @@ +//! Compares generated code against the code the design itself carries. +//! +//! The devup-Test file states, next to each case, the devup-ui it is meant to +//! produce. That makes it ground truth of a kind the pinned corpus cannot be: +//! the corpus records what the plugin did, this records what the case is for. +//! +//! Captures live in `fixtures/local-screens/testcase-*.json` and are ignored by +//! git — with none present the test reports that and passes. +//! +//! It reports rather than asserts, and the reason matters. The stated code is +//! written by hand and describes the intent, not the output: against the +//! Gradient section every case differs, and in every one the pinned corpus +//! holds exactly what we emit — `-47deg` where the note reads `313deg`, `43% +//! 21%` where it reads `33.84% 33.84%`. Those are the same gradients said two +//! ways, and normalising toward the note would have broken three goldens and +//! moved away from the reference implementation. +//! +//! The size a case states is the same kind of shorthand. Every note here ends +//! `boxSize="150px"`, we emit nothing, and reading that as a defect and +//! restoring the size broke thirty-eight goldens — among them the very cases +//! being read. A shape on a page carries the canvas it was drawn on, not a size +//! anyone chose, and the plugin drops it; the note writes down what was drawn. +//! +//! Token names differ for a third reason, and it is this harness rather than +//! the code. `rawSnapshot` carries the snapshot alone — the collected variables +//! and styles are not in it — so a replay has no table to turn +//! `VariableID:…/19:40` into `$primary` with, and falls back to the literal +//! colour. A note asking for `bg="$primaryBgLight"` against an emitted +//! `bg="$227"` or `#871FE6` is that gap, not a defect: devup-mcp itself passes +//! the tokens through `CodegenOptions::with_payload_tokens` and does resolve +//! them. +//! +//! A fourth kind is a prop the note leaves out because it does not change what +//! is drawn. Against `SVG - detail`, every difference is one of these and each +//! was checked against the plugin's own output in `fixtures/plugin-answers/`, +//! which writes the same props we do: +//! +//! - `flexDir="column"` on a `Center`. `Center` is a row, so a vertical stack +//! needs it; with a single child nothing moves either way, and the note omits +//! it. +//! - `maskPos="center"` beside every `maskImage`. +//! - `boxSize` where the note gives `w` and leans on `aspectRatio="1"` for the +//! other side. +//! - `border="solid 1px …"` where the note writes `1px solid …`. +//! +//! That section is also where the icon split is stated most plainly: the same +//! `cog.svg` is an `` in one case and a masked `` in another, and +//! the two are told apart by whether the shape carries its own paint. Both come +//! out right, which is what the section is for. +//! +//! So a difference here is a question: check the corpus and the plugin's +//! answers before treating it as a defect. Where they agree with us the note is +//! shorthand; where they agree with the note, that is ours to fix. + +use std::{collections::BTreeMap, fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; + +/// The JSX inside `export function X() { return ( ... ) }`. +fn body(tsx: &str) -> String { + let after_return = tsx.find("return (").map(|at| at + "return (".len()); + let start = after_return + .and_then(|from| tsx[from..].find('<').map(|at| from + at)) + .unwrap_or(0); + let end = tsx.rfind(");").unwrap_or(tsx.len()); + normalise(&tsx[start..end.max(start)]) +} + +/// Collapses the formatting so a comparison is about the code, not its layout. +fn normalise(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +struct Case { + expected: String, + node_id: String, +} + +/// The generated JSX for one node, or nothing if it will not convert. +fn render(snapshot: &Snapshot, node_id: &str) -> Option { + let options = CodegenOptions { + inline_instances: true, + ..CodegenOptions::default() + }; + generate_component(snapshot, node_id, &options) + .ok() + .map(|output| body(&output.tsx)) +} + +fn cases(snapshot: &Snapshot) -> Vec { + let centre = |id: &str| { + let view = snapshot.nodes.get(id)?.typed_view(); + let (x, y) = (view.number("x")?, view.number("y")?); + let (w, h) = ( + view.number("width").unwrap_or(0.0), + view.number("height").unwrap_or(0.0), + ); + Some((x + w / 2.0, y + h / 2.0)) + }; + + // A case frame holds the shape; a Code frame beside it holds the text that + // says what the shape should produce. + let mut expectations: Vec<((f64, f64), String)> = Vec::new(); + let mut shapes: Vec<((f64, f64), String, Option)> = Vec::new(); + for root in &snapshot.roots { + let Some(raw) = snapshot.nodes.get(root) else { + continue; + }; + let Some(at) = centre(root) else { continue }; + let view = raw.typed_view(); + let text = view + .child_ids() + .filter_map(|child| snapshot.nodes.get(child)) + .find_map(|child| { + child + .typed_view() + .value("characters") + .and_then(|value| value.as_str()) + .filter(|text| text.trim_start().starts_with('<')) + .map(str::to_owned) + }); + match text { + Some(text) => expectations.push((at, normalise(&text))), + None => { + // A case is sometimes wrapped in a frame that only positions it + // and sometimes stands as the root itself, and nothing about the + // frame says which. The note does: one that opens a container + // and puts something inside is describing the frame, one that is + // a single element is describing what the frame holds. So keep + // both readings and let the note pick. + let lone_child = match view.child_ids().collect::>().as_slice() { + [only] => Some((*only).to_owned()), + _ => None, + }; + shapes.push((at, root.clone(), lone_child)); + } + } + } + + // Pair by proximity rather than by a fixed direction: a case sits above its + // note in one section and beside it in another, so any rule about which way + // to look holds for one layout and silently pairs nothing in the next. + // + // One note, one case. Letting each note take whatever is nearest lets them + // crowd onto the same case, and a note whose case is far away claims the + // commentary lying beside it instead — a difference reported against a + // paragraph of Korean prose. Closest pairs are settled first, and each side + // is spoken for once. + // Distance alone still goes wrong, because a section holds more than its + // cases: commentary explaining a rule, and frames kept alongside to show a + // difference. Either can lie closer to a note than the case it describes, + // and the comparison then reports a `` against a paragraph of prose. + // What a note opens with says what it is describing, so a candidate that + // starts the same way is preferred over one that merely sits closer. + let opening_tag = |source: &str| { + source + .split_once('<') + .map(|(_, rest)| { + rest.trim_start_matches('/') + .split(|c: char| !c.is_ascii_alphanumeric()) + .next() + .unwrap_or_default() + .to_owned() + }) + .unwrap_or_default() + }; + + let mut pairs = Vec::with_capacity(expectations.len() * shapes.len()); + for (note, (at, expected)) in expectations.iter().enumerate() { + let wanted = opening_tag(expected); + for (case, (case_at, root, lone_child)) in shapes.iter().enumerate() { + let describes_a_container = expected.matches('<').count() >= 3; + let node_id = match lone_child { + Some(child) if !describes_a_container => child, + _ => root, + }; + let same_kind = render(snapshot, node_id) + .map(|rendered| opening_tag(&rendered) == wanted) + .unwrap_or(false); + let distance = (case_at.0 - at.0).powi(2) + (case_at.1 - at.1).powi(2); + pairs.push((!same_kind, distance, note, case)); + } + } + pairs.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| left.1.total_cmp(&right.1)) + .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left.3.cmp(&right.3)) + }); + + let mut spoken_for_note = vec![false; expectations.len()]; + let mut spoken_for_case = vec![false; shapes.len()]; + let mut cases = Vec::new(); + for (_, _, note, case) in pairs { + if spoken_for_note[note] || spoken_for_case[case] { + continue; + } + spoken_for_note[note] = true; + spoken_for_case[case] = true; + let expected = expectations[note].1.clone(); + // Three angle brackets means an element opened, something placed inside + // it, and the element closed — a container. One or two is a single + // element, with or without text of its own. + let describes_a_container = expected.matches('<').count() >= 3; + let (_, root, lone_child) = &shapes[case]; + let node_id = match lone_child { + Some(child) if !describes_a_container => child.clone(), + _ => root.clone(), + }; + cases.push(Case { expected, node_id }); + } + cases +} + +#[test] +fn generated_code_matches_what_each_case_states() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/local-screens"); + let Ok(entries) = fs::read_dir(&root) else { + eprintln!("no captures; skipping"); + return; + }; + + let mut agreed = 0usize; + let mut differed: BTreeMap> = BTreeMap::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().and_then(|v| v.to_str()).unwrap_or(""); + if !name.starts_with("testcase-") { + continue; + } + let raw = fs::read_to_string(&path).expect("captured section"); + let value: serde_json::Value = + serde_json::from_str(&raw).expect("captured section is json"); + let snapshot: Snapshot = + serde_json::from_value(value["snapshot"].clone()).expect("captured snapshot"); + let label = value["label"].as_str().unwrap_or(name).to_owned(); + + for case in cases(&snapshot) { + let options = CodegenOptions { + inline_instances: true, + ..CodegenOptions::default() + }; + let actual = match generate_component(&snapshot, &case.node_id, &options) { + Ok(output) => body(&output.tsx), + Err(error) => format!(""), + }; + if actual == case.expected { + agreed += 1; + } else { + differed.entry(label.clone()).or_default().push(( + case.node_id.clone(), + case.expected, + actual, + )); + } + } + } + + let total = agreed + differed.values().map(Vec::len).sum::(); + if total == 0 { + eprintln!("no captured test cases; skipping"); + return; + } + eprintln!("cases: {total}, matching what the design states: {agreed}"); + for (label, entries) in &differed { + eprintln!("\n=== {label}"); + for (node_id, expected, actual) in entries { + eprintln!(" node : {node_id}"); + eprintln!(" states : {expected}"); + eprintln!(" we emit: {actual}\n"); + } + } +} diff --git a/crates/devup-mcp-devup-ui/tests/theme.rs b/crates/devup-mcp-devup-ui/tests/theme.rs index c6871842..955a1b0e 100644 --- a/crates/devup-mcp-devup-ui/tests/theme.rs +++ b/crates/devup-mcp-devup-ui/tests/theme.rs @@ -35,11 +35,19 @@ fn maps_variables_modes_aliases_and_styles_to_devup_json() { "valuesByMode": {"mode:light": 16, "mode:dark": 20} } ], + // A style's value is what the snapshot script serialises: the text + // style itself, and an effect style's `effects`. "styles": [ {"id": "style:text", "name": "Heading/H1", "styleType": "TEXT", "value": { - "fontFamily": "Pretendard", "fontSize": "32px", "fontWeight": 700, "lineHeight": 1.3 + "fontName": {"family": "Pretendard", "style": "Bold"}, "fontSize": 32, + "lineHeight": {"unit": "PERCENT", "value": 130.00000476837158}, + "letterSpacing": {"unit": "PERCENT", "value": -2}, + "textCase": "ORIGINAL", "textDecoration": "NONE", "boundVariables": {} }}, - {"id": "style:effect", "name": "Elevation/Card", "styleType": "EFFECT", "value": "0 4px 12px #0000001a"} + {"id": "style:effect", "name": "Elevation/Card", "styleType": "EFFECT", "value": [ + {"type": "DROP_SHADOW", "visible": true, "offset": {"x": 0, "y": 4}, "radius": 12, "spread": 0, + "color": {"r": 0, "g": 0, "b": 0, "a": 0.1}, "blendMode": "NORMAL"} + ]} ], "usedRemoteVariables": [], "localComplete": true, @@ -49,40 +57,52 @@ fn maps_variables_modes_aliases_and_styles_to_devup_json() { let output = generate_devup_json(&variables, ThemeScope::File).expect("devup theme"); assert_eq!(output.completeness, Completeness::FullLocalPlusUsedRemote); + // The collection's default mode is written first: devup-ui takes the + // first theme under `colors` as the one in effect without a `data-theme`, + // so `light` before `darkMode` here is what makes a Light-by-default file + // light by default. Sorted by name it was the other way round. assert_eq!( output.json, concat!( "{\n", " \"theme\": {\n", " \"colors\": {\n", - " \"darkMode\": {\n", - " \"accent\": \"#3291ff\",\n", - " \"primary\": \"#3291ff\"\n", - " },\n", " \"light\": {\n", " \"accent\": \"#0070f3\",\n", " \"primary\": \"#0070f3\"\n", + " },\n", + " \"darkMode\": {\n", + " \"accent\": \"#3291ff\",\n", + " \"primary\": \"#3291ff\"\n", " }\n", " },\n", + // In the shape devup-ui reads: pixels, a line-height ratio, an em + // of letter-spacing, and the weight the font style's name means. " \"typography\": {\n", " \"headingH1\": {\n", " \"fontFamily\": \"Pretendard\",\n", - " \"fontSize\": \"32px\",\n", " \"fontWeight\": 700,\n", - " \"lineHeight\": 1.3\n", + " \"fontSize\": \"32px\",\n", + " \"lineHeight\": 1.3,\n", + " \"letterSpacing\": \"-0.02em\"\n", " }\n", " },\n", " \"length\": {\n", - " \"darkMode\": {\n", - " \"gutter\": \"20px\"\n", - " },\n", " \"light\": {\n", " \"gutter\": \"16px\"\n", + " },\n", + " \"darkMode\": {\n", + " \"gutter\": \"20px\"\n", " }\n", " },\n", + // A shadow is the same at every colour theme, and devup-ui keys it + // by one, so each theme gets it. " \"shadow\": {\n", - " \"default\": {\n", - " \"elevationCard\": \"0 4px 12px #0000001a\"\n", + " \"light\": {\n", + " \"elevationCard\": \"0 4px 12px 0 #0000001a\"\n", + " },\n", + " \"darkMode\": {\n", + " \"elevationCard\": \"0 4px 12px 0 #0000001a\"\n", " }\n", " }\n", " }\n", @@ -93,6 +113,82 @@ fn maps_variables_modes_aliases_and_styles_to_devup_json() { assert_eq!(output.counts.styles, 2); } +/// Styles named for a breakpoint are one entry with a slot each: the +/// plugin's `styleNameToTypography` puts `mobile/`, `tablet/` and `desktop/` +/// at slots 0, 2 and 4 and a leading number at that slot, and the slots are +/// gathered into a responsive array with the empty tail dropped. A text +/// style that sets its line height automatically is `normal`, one in pixels +/// stays in pixels, `Bold Italic` is bold and italic, and a bound font size +/// is its variable's token. +#[test] +fn styles_named_for_a_breakpoint_become_one_responsive_entry() { + let variables: VariableSnapshot = serde_json::from_value(json!({ + "collections": [{"id": "c", "name": "C", "defaultModeId": "m", "modes": [{"modeId": "m", "name": "Default"}]}], + "variables": [{ + "id": "var:size", "name": "Font/Size Lg", "resolvedType": "FLOAT", + "variableCollectionId": "c", "codeSyntax": {}, "valuesByMode": {"m": 40} + }], + "styles": [ + {"id": "s:0", "name": "mobile/h1", "styleType": "TEXT", "value": { + "fontName": {"family": "Pretendard", "style": "Bold Italic"}, "fontSize": 24, + "lineHeight": {"unit": "AUTO"}, "letterSpacing": {"unit": "PIXELS", "value": 0.5}, + "textCase": "UPPER", "textDecoration": "UNDERLINE", "boundVariables": {} + }}, + {"id": "s:4", "name": "desktop/h1", "styleType": "TEXT", "value": { + "fontName": {"family": "Pretendard", "style": "ExtraBold"}, "fontSize": 40, + "lineHeight": {"unit": "PIXELS", "value": 48}, "letterSpacing": {"unit": "PERCENT", "value": 0}, + "textCase": "ORIGINAL", "textDecoration": "NONE", + "boundVariables": {"fontSize": {"type": "VARIABLE_ALIAS", "id": "var:size"}} + }}, + {"id": "s:3", "name": "3/label", "styleType": "TEXT", "value": { + "fontName": {"family": "Noto Sans KR", "style": "Medium"}, "fontSize": 13, + "lineHeight": {"unit": "PERCENT", "value": 150}, "letterSpacing": {"unit": "PERCENT", "value": -4}, + "textCase": "ORIGINAL", "textDecoration": "NONE", "boundVariables": {} + }}, + {"id": "e:0", "name": "card", "styleType": "EFFECT", "value": [ + {"type": "INNER_SHADOW", "visible": true, "offset": {"x": 1, "y": 1}, "radius": 2, "spread": 0, "color": {"r": 1, "g": 1, "b": 1, "a": 1}}, + {"type": "DROP_SHADOW", "visible": false, "offset": {"x": 0, "y": 9}, "radius": 9, "spread": 0, "color": {"r": 0, "g": 0, "b": 0, "a": 1}}, + {"type": "LAYER_BLUR", "visible": true, "radius": 4} + ]}, + {"id": "e:4", "name": "desktop/card", "styleType": "EFFECT", "value": [ + {"type": "DROP_SHADOW", "visible": true, "offset": {"x": 0, "y": 8}, "radius": 24, "spread": -4, "color": {"r": 0, "g": 0, "b": 0, "a": 0.15}} + ]}, + {"id": "e:blur", "name": "glow", "styleType": "EFFECT", "value": [ + {"type": "LAYER_BLUR", "visible": true, "radius": 4} + ]} + ], + "usedRemoteVariables": [], + "localComplete": true, + "usedRemoteComplete": true + })) + .expect("variable snapshot"); + + let output = generate_devup_json(&variables, ThemeScope::File).expect("devup theme"); + let json: serde_json::Value = serde_json::from_str(&output.json).expect("json"); + assert_eq!( + json["theme"]["typography"], + json!({ + "h1": [ + {"fontFamily": "Pretendard", "fontStyle": "italic", "fontWeight": 700, "fontSize": "24px", + "textDecoration": "underline", "textTransform": "uppercase", "lineHeight": "normal", "letterSpacing": "0.5px"}, + null, + null, + null, + // the token is named as every `$token` here is, by the + // variable's last path segment + {"fontFamily": "Pretendard", "fontWeight": 800, "fontSize": "$sizeLg", "lineHeight": "48px", "letterSpacing": "0em"} + ], + // one slot only is the value alone, wherever the slot was + "label": {"fontFamily": "Noto Sans KR", "fontWeight": 500, "fontSize": "13px", "lineHeight": 1.5, "letterSpacing": "-0.04em"} + }) + ); + // A blur is not a shadow; an effect style with only blurs is no entry. + assert_eq!( + json["theme"]["shadow"], + json!({"default": {"card": ["inset 1px 1px 2px 0 #ffffff", null, null, null, "0 8px 24px -4px #00000026"]}}) + ); +} + #[test] fn reports_alias_cycles_instead_of_inventing_values() { let variables: VariableSnapshot = serde_json::from_value(json!({ diff --git a/crates/devup-mcp-devup-ui/tests/validation.rs b/crates/devup-mcp-devup-ui/tests/validation.rs index a07227a4..e48472b5 100644 --- a/crates/devup-mcp-devup-ui/tests/validation.rs +++ b/crates/devup-mcp-devup-ui/tests/validation.rs @@ -6,7 +6,7 @@ fn syntax_accepts_nested_typescript_jsx() { let source = r#" import { Text, VStack } from "@devup-ui/react"; export function Proofread(): JSX.Element { - return 본문; + return Body; } "#; let report = validate_tsx(source).expect("valid TSX"); @@ -16,13 +16,13 @@ fn syntax_accepts_nested_typescript_jsx() { #[test] fn syntax_rejects_invalid_tsx_without_echoing_source_text() { for source in [ - "export function Broken() { return 비밀 본문; }", - "export function Broken() { return 비밀 본문; }", - "export function Broken() { return {비밀 본문 + }; }", + "export function Broken() { return secret body; }", + "export function Broken() { return secret body; }", + "export function Broken() { return {secret body + }; }", ] { let error = validate_tsx(source).expect_err("invalid TSX"); assert_eq!(error.code, ErrorCode::DevupCodegenFailed); - assert!(!error.to_string().contains("비밀 본문")); + assert!(!error.to_string().contains("secret body")); assert!( error.details["errorCount"] .as_u64() diff --git a/crates/devup-mcp-devup-ui/tests/variant_nesting.rs b/crates/devup-mcp-devup-ui/tests/variant_nesting.rs new file mode 100644 index 00000000..37b98228 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/variant_nesting.rs @@ -0,0 +1,220 @@ +//! A component set whose props depend on more than one dimension at a time. +//! +//! `fixtures/plugin-answers/button/components.tsx` is what the plugin answered +//! for `devup-Test`'s `Button` set, and the capture it was answered from is +//! `fixtures/local-components/button-set.json`. The capture is not committed, +//! so these skip when it is absent rather than pretending to have checked. +//! +//! The values asserted here are the ones that carry no design token, because +//! an offline replay has no variable table and would render `$primary` as its +//! numeric id. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component_set_target}; +use devup_mcp_figma::Snapshot; + +fn button() -> Option { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let raw = fs::read_to_string(root.join("fixtures/local-components/button-set.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let snapshot: Snapshot = serde_json::from_value(value.get("snapshot")?.clone()).ok()?; + generate_component_set_target( + &snapshot, + "582:2137", + "Button", + &CodegenOptions { + component_name: Some("Button".to_owned()), + ..CodegenOptions::default() + }, + ) + .ok() + .map(|output| output.tsx) +} + +/// `px` is decided by `size` and by `varient` together. Writing it as one map +/// is impossible and writing the default's literal loses every other +/// combination, so it is a map inside a map — and `size` is on the outside +/// because that nests less deeply than the transpose would. +/// +/// `white` is the one variant drawn with a 1px stroke inside, and Figma +/// paints an inside stroke over the padding where CSS adds a border around +/// it, so `white` is padded a pixel less to put its label where Figma has +/// it. That is what makes `tag` a map as well. +#[test] +fn a_prop_two_dimensions_decide_is_written_as_a_map_inside_a_map() { + let Some(tsx) = button() else { + eprintln!("no capture; skipping"); + return; + }; + let expected = r#"px={{ + lg: { + primary: "24px", + white: "23px", + ghost: "10px", + disabled: "24px", + error: "24px" + }[varient], + md: { + primary: "16px", + white: "15px", + ghost: "12px", + disabled: "16px", + error: "16px" + }[varient], + sm: { + primary: "12px", + white: "11px", + ghost: "10px", + disabled: "12px", + error: "12px" + }[varient], + tag: { + primary: "10px", + white: "9px", + disabled: "10px", + error: "10px" + }[varient] + }[size]}"#; + assert!(tsx.contains(expected), "{tsx}"); +} + +/// Only one of the four sizes actually varies by `varient`, so the other three +/// stay plain values rather than each growing a map of five identical entries. +#[test] +fn a_branch_whose_options_agree_collapses_to_the_value() { + let Some(tsx) = button() else { + eprintln!("no capture; skipping"); + return; + }; + let expected = r#"gap={{ + lg: "10px", + md: { + primary: "10px", + white: "10px", + ghost: "8px", + disabled: "10px", + error: "10px" + }[varient], + sm: "8px" + }[size]}"#; + assert!(tsx.contains(expected), "{tsx}"); +} + +/// An option with no value means one of two things, and they want opposite +/// answers. `tag` collapses because every variant drawn at that size agrees — +/// `ghost` has no `tag` at all, so its absence is not a hole. `border` does not +/// collapse, because the other four variants exist and refuse a border. +#[test] +fn a_combination_never_drawn_is_not_a_hole_but_a_refused_value_is() { + let Some(tsx) = button() else { + eprintln!("no capture; skipping"); + return; + }; + assert!( + tsx.contains( + r#"borderRadius={{ + lg: "8px", + md: "8px", + sm: "8px", + tag: "6px" + }[size]}"# + ), + "{tsx}" + ); + assert!( + tsx.contains("border={varient === 'white' && "), + "a value only one variant sets stays a condition: {tsx}" + ); +} + +/// A boolean property is a switch, not a choice. It is optional, because +/// leaving it out is how a caller says the child should not be drawn, and the +/// child names it rather than the set describing what it does. +#[test] +fn a_boolean_property_becomes_an_optional_prop_and_a_guard() { + let Some(tsx) = button() else { + eprintln!("no capture; skipping"); + return; + }; + assert!( + tsx.contains( + r#"export interface ButtonProps { + leftIcon?: boolean + rightIcon?: boolean + size: 'lg' | 'md' | 'sm' | 'tag' + varient: 'primary' | 'white' | 'ghost' | 'disabled' | 'error' +}"# + ), + "{tsx}" + ); + assert!( + tsx.contains( + r#"export function Button({ leftIcon, rightIcon, size, varient }: ButtonProps)"# + ), + "{tsx}" + ); +} + +/// Not every variant holds every node — a `tag` button has no icon — so the +/// merged tree, which is built from a variant that does, has to say when the +/// node is drawn or it would draw one at every size. +#[test] +fn a_node_some_variants_do_not_hold_is_guarded_by_the_ones_that_do() { + let Some(tsx) = button() else { + eprintln!("no capture; skipping"); + return; + }; + for property in ["leftIcon", "rightIcon"] { + assert!( + tsx.contains(&format!( + r#"{{{property} && (size === "lg" || size === "md" || size === "sm") && ("# + )), + "{property} should be guarded by the sizes that have an icon: {tsx}" + ); + } + // And nothing every variant holds should have grown a guard. + assert_eq!( + tsx.matches("&& (size ===").count(), + 2, + "only the two icons are conditional: {tsx}" + ); +} + +/// A text node's own words vary like anything else around them. +/// +/// `tag` is the case that needs a node to be found by what it is rather than +/// where it sits: a `tag` button has no icons, so its text is the first child +/// where every other size has it third. Addressed by position, the lookup read +/// the icon and `tag` fell out of the map. +#[test] +fn what_a_text_node_says_is_a_variant_too() { + let Some(tsx) = button() else { + eprintln!("no capture; skipping"); + return; + }; + assert!( + tsx.contains( + r#"{{ + lg: "buttonLg", + md: "button", + sm: "button", + tag: "Tag" + }[size]}"# + ), + "{tsx}" + ); +} + +/// A component set's grid is how Figma arranges its variants on the canvas. +/// Carrying it would place every button at the coordinates of its row in the +/// sheet. +#[test] +fn the_sheet_a_variant_was_laid_out_on_does_not_reach_the_component() { + let Some(tsx) = button() else { + eprintln!("no capture; skipping"); + return; + }; + assert!(!tsx.contains("gridColumn"), "{tsx}"); + assert!(!tsx.contains("gridRow"), "{tsx}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/variant_typography.rs b/crates/devup-mcp-devup-ui/tests/variant_typography.rs new file mode 100644 index 00000000..8e74f1ae --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/variant_typography.rs @@ -0,0 +1,120 @@ +//! `typography` is the one prop a variant map has to be frozen for. +//! +//! Built here rather than captured. A design token only reaches the converter +//! through the style table that comes with a real acquisition, and the captures +//! that carry one are not committed — so the set is written out, which also +//! means this rule is checked wherever the tests run rather than only where +//! someone has a capture. + +use std::collections::BTreeMap; + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component_set_target}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +/// A set with one real dimension, `size`, and the `effect` dimension that makes +/// it a set worth folding. Each size labels its text with a different style. +fn button_set() -> Snapshot { + let mut nodes = json!({ + "set": { + "id": "set", + "type": "COMPONENT_SET", + "fields": { + "name": "Label", + "childrenIds": ["lg-default", "sm-default"], + "componentPropertyDefinitions": { + "size": { + "type": "VARIANT", + "defaultValue": "lg", + "variantOptions": ["lg", "sm"] + }, + "effect": { + "type": "VARIANT", + "defaultValue": "default", + "variantOptions": ["default"] + } + } + }, + "extra": {}, + "fieldErrors": {} + } + }); + for (size, style) in [("lg", "style-lg"), ("sm", "style-sm")] { + let variant = format!("{size}-default"); + let text = format!("{size}-text"); + nodes[&variant] = json!({ + "id": variant, + "type": "COMPONENT", + "fields": { + "name": format!("size={size}, effect=default"), + "parentId": "set", + "childrenIds": [text], + "variantProperties": {"size": size, "effect": "default"}, + "width": 100, "height": 20 + }, + "extra": {}, + "fieldErrors": {} + }); + nodes[&text] = json!({ + "id": text, + "type": "TEXT", + "fields": { + "name": "label", + "parentId": variant, + "childrenIds": [], + "characters": "Label", + "width": 80, "height": 16, + "styledTextSegments": [{"characters": "Label", "textStyleId": style}] + }, + "extra": {}, + "fieldErrors": {} + }); + } + serde_json::from_value(json!({ + "fileKey": "FileKey123", + "version": "v1", + "roots": ["set"], + "nodes": nodes, + "diagnostics": [] + })) + .expect("snapshot") +} + +fn generated() -> String { + let options = CodegenOptions { + component_name: Some("Label".to_owned()), + text_style_tokens: BTreeMap::from([ + ("style-lg".to_owned(), "buttonLg".to_owned()), + ("style-sm".to_owned(), "buttonSm".to_owned()), + ]), + ..CodegenOptions::default() + }; + generate_component_set_target(&button_set(), "set", "Label", &options) + .expect("codegen") + .tsx +} + +/// devup-ui types `typography` by the literal it is given. A map read at +/// runtime widens every entry to `string`, and the token stops being one, so +/// the object is frozen before it is indexed. +#[test] +fn a_typography_map_is_frozen_before_it_is_indexed() { + let tsx = generated(); + assert!( + tsx.contains( + r#"typography={({ + lg: "buttonLg", + sm: "buttonSm" + } as const)[size]}"# + ), + "{tsx}" + ); +} + +/// And no other prop is, because no other prop needs it. Widening a colour or +/// a length to `string` costs nothing. +#[test] +fn nothing_else_is_frozen() { + let tsx = generated(); + assert_eq!(tsx.matches("as const").count(), 1, "{tsx}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/wquw_151.rs b/crates/devup-mcp-devup-ui/tests/wquw_151.rs index 41438b33..9a75220b 100644 --- a/crates/devup-mcp-devup-ui/tests/wquw_151.rs +++ b/crates/devup-mcp-devup-ui/tests/wquw_151.rs @@ -51,6 +51,7 @@ fn actual_wquw_151_screen_preserves_children_tokens_and_typography() { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), }; let output = generate_component( &payload.snapshot, diff --git a/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs b/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs index c918c049..4a4b1a42 100644 --- a/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +++ b/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs @@ -189,6 +189,7 @@ fn every_actual_frame_generates_reviewed_devup_ui() { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), }; let output = generate_component( &payload.snapshot, diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index 51714012..14002e62 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -1,9 +1,12 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use crate::{DevupError, Diagnostic, ErrorCode, Snapshot, UpstreamResult}; +use crate::{ + DevupError, Diagnostic, ErrorCode, LargeValueCursor, LargeValueDescriptor, RawNode, Snapshot, + UpstreamResult, +}; pub const MAX_ASSET_BYTES: usize = 8 * 1024 * 1024; @@ -42,6 +45,10 @@ pub enum AssetStatus { Available, Exported, Failed, + /// Announced by the export script for an SVG too large for one answer: + /// the bytes follow in fragments, and the collector reports the asset as + /// `Exported` or `Failed` once they have. Never in a manifest. + Chunked, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -89,6 +96,11 @@ pub struct AssetManifestEntry { pub output_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, + /// Where the generated code refers to this asset, `/icons/x.svg` or + /// `/images/x.png`, filled in by the server from the code generator's + /// naming so the bytes can be written where the code will look. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -99,59 +111,51 @@ pub struct AssetManifest { pub diagnostics: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum AssetNode { + Svg, + Png { + fill_index: usize, + image_hash: Option, + }, + /// A container that draws nothing of its own and holds one picture. It + /// is that picture, but it does not carry the fill, so its bytes come + /// from rendering the node rather than from a fill index it lacks. + PngNode { + image_hash: Option, + }, +} + pub fn discover_asset_manifest(snapshot: &Snapshot) -> AssetManifest { let mut assets = Vec::new(); - for node in snapshot.nodes.values() { - if let Some(fills) = node.typed_view().value("fills").and_then(Value::as_array) { - for (index, fill) in fills.iter().enumerate() { - if fill.get("type").and_then(Value::as_str) != Some("IMAGE") { - continue; - } - let image_hash = fill - .get("imageHash") - .or_else(|| fill.get("imageRef")) - .and_then(Value::as_str) - .map(str::to_owned); - assets.push(AssetManifestEntry { - asset_id: format!("{}:fills:{index}", node.id), - node_id: node.id.clone(), - field: format!("fills/{index}"), - source_kind: "image-fill".to_owned(), - image_hash, - format: None, - scale: None, - status: AssetStatus::Available, - byte_length: None, - sha256: None, - mime_type: None, - data_base64: None, - output_path: None, - error_code: None, - }); - } + let mut pending = snapshot.roots.iter().rev().cloned().collect::>(); + let mut visited = std::collections::BTreeSet::new(); + + while let Some(node_id) = pending.pop() { + let Some(node) = snapshot.nodes.get(&node_id) else { + continue; + }; + if !visited.insert(node_id) { + continue; } - if matches!( - node.node_type.as_str(), - "VECTOR" | "BOOLEAN_OPERATION" | "STAR" | "LINE" | "ELLIPSE" | "POLYGON" - ) { - assets.push(AssetManifestEntry { - asset_id: format!("{}:node", node.id), - node_id: node.id.clone(), - field: "node".to_owned(), - source_kind: "vector-node".to_owned(), - image_hash: None, - format: None, - scale: None, - status: AssetStatus::Available, - byte_length: None, - sha256: None, - mime_type: None, - data_base64: None, - output_path: None, - error_code: None, - }); + + if let Some(asset) = compute_asset_node(snapshot, node, false) { + assets.push(manifest_entry(node, asset)); + continue; } + + // A container carries pictures of its own: a section drawn over a + // photograph is painted `bg: url(...)` by the code generator, yet the + // node is a layout box rather than an asset, so the walk goes on into + // its children. Listed here, the picture the code points at can be + // exported; unlisted, nothing can deliver it and the screen renders + // with a hole where the photograph belongs. + assets.extend(image_fill_entries(node)); + + let child_ids = node.typed_view().child_ids().collect::>(); + pending.extend(child_ids.into_iter().rev().map(str::to_owned)); } + assets.sort_by(|left, right| left.asset_id.cmp(&right.asset_id)); AssetManifest { version: 1, @@ -160,19 +164,295 @@ pub fn discover_asset_manifest(snapshot: &Snapshot) -> AssetManifest { } } +fn compute_asset_node(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Option { + let view = node.typed_view(); + if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") + || view + .value("inferredAutoLayout") + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str) + == Some("GRID") + { + return None; + } + + if has_smart_animate_reaction(node) + || view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .is_some_and(has_smart_animate_reaction) + { + return None; + } + + if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") { + return Some(AssetNode::Svg); + } + + if view.node_type() == "ELLIPSE" + && view + .value("arcData") + .and_then(|arc_data| arc_data.get("innerRadius")) + .and_then(Value::as_f64) + .is_some_and(|inner_radius| inner_radius != 0.0) + { + return Some(AssetNode::Svg); + } + + let child_ids = view.child_ids().collect::>(); + if child_ids.is_empty() { + return compute_leaf_asset(node, nested); + } + + if child_ids.len() == 1 { + if ["paddingLeft", "paddingRight", "paddingTop", "paddingBottom"] + .into_iter() + .any(|field| view.number(field).is_some_and(|padding| padding > 0.0)) + || fills(node).is_some_and(|fills| fills.iter().any(is_visible_fill)) + { + return None; + } + + // The container stands in for the child, and the code names the + // file after the container. Asking Figma for `fills/0` of a node + // whose fills are empty is a request it can only refuse, and it did: + // the devup-ui landing page's footer logo sits in such a frame and + // was the one asset of 182 that never arrived. Rendering the node + // gives the same picture, and is a request the node can answer. + return match snapshot + .nodes + .get(child_ids[0]) + .and_then(|child| compute_asset_node(snapshot, child, true)) + { + Some(AssetNode::Svg) => Some(AssetNode::Svg), + Some(AssetNode::Png { image_hash, .. } | AssetNode::PngNode { image_hash }) => { + Some(AssetNode::PngNode { image_hash }) + } + None => None, + }; + } + + let mut visible_children = Vec::new(); + for child_id in child_ids { + let child = snapshot.nodes.get(child_id)?; + if child.typed_view().bool("visible") != Some(false) { + visible_children.push(child); + } + } + + visible_children + .into_iter() + .all(|child| compute_asset_node(snapshot, child, true) == Some(AssetNode::Svg)) + .then_some(AssetNode::Svg) +} + +/// Every picture a node paints from an image fill. The code generator writes +/// a `url(...)` for each visible image fill whatever its scale mode, so each +/// one is an asset a caller has to be able to fetch. A node that is an asset +/// in its own right never reaches this: its bytes come from the node itself. +fn image_fill_entries(node: &RawNode) -> Vec { + fills(node) + .into_iter() + .flatten() + .enumerate() + .filter(|(_, fill)| is_visible_fill(fill) && fill_type(fill) == Some("IMAGE")) + .map(|(fill_index, fill)| { + manifest_entry( + node, + AssetNode::Png { + fill_index, + image_hash: fill + .get("imageHash") + .or_else(|| fill.get("imageRef")) + .and_then(Value::as_str) + .map(str::to_owned), + }, + ) + }) + .collect() +} + +fn compute_leaf_asset(node: &RawNode, nested: bool) -> Option { + let node_fills = fills(node); + if node_fills.is_some_and(|fills| { + fills.iter().any(|fill| { + is_visible_fill(fill) + && (fill_type(fill) == Some("PATTERN") + || (fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) == Some("TILE"))) + }) + }) { + return None; + } + + if node.typed_view().bool("isAsset") == Some(true) { + if let Some((fill_index, fill)) = node_fills.and_then(|fills| { + fills.iter().enumerate().find(|(_, fill)| { + is_visible_fill(fill) + && fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") + }) + }) { + if node_fills.is_some_and(|fills| fills.len() == 1) { + return Some(AssetNode::Png { + fill_index, + image_hash: fill + .get("imageHash") + .or_else(|| fill.get("imageRef")) + .and_then(Value::as_str) + .map(str::to_owned), + }); + } + return None; + } + + if node_fills.is_none_or(|fills| { + fills + .iter() + .all(|fill| is_visible_fill(fill) && fill_type(fill) == Some("SOLID")) + }) { + return nested.then_some(AssetNode::Svg); + } + + return Some(AssetNode::Svg); + } + + (nested + && node_fills.is_some_and(|fills| { + fills.iter().all(|fill| { + !is_visible_fill(fill) + || !matches!(fill_type(fill), Some("IMAGE" | "VIDEO" | "PATTERN")) + }) + })) + .then_some(AssetNode::Svg) +} + +fn fills(node: &RawNode) -> Option<&Vec> { + node.typed_view().value("fills").and_then(Value::as_array) +} + +fn fill_type(fill: &Value) -> Option<&str> { + fill.get("type").and_then(Value::as_str) +} + +fn is_visible_fill(fill: &Value) -> bool { + fill.get("visible").and_then(Value::as_bool) != Some(false) +} + +fn has_smart_animate_reaction(node: &RawNode) -> bool { + node.typed_view() + .value("reactions") + .and_then(Value::as_array) + .is_some_and(|reactions| { + reactions.iter().any(|reaction| { + reaction + .get("actions") + .and_then(Value::as_array) + .is_some_and(|actions| { + actions.iter().any(|action| { + action.get("type").and_then(Value::as_str) == Some("NODE") + && action + .get("transition") + .and_then(|transition| transition.get("type")) + .and_then(Value::as_str) + == Some("SMART_ANIMATE") + }) + }) + }) + }) +} + +fn manifest_entry(node: &RawNode, asset: AssetNode) -> AssetManifestEntry { + let (asset_id, field, source_kind, image_hash) = match asset { + AssetNode::Svg => ( + format!("{}:node", node.id), + "node".to_owned(), + "vector-node".to_owned(), + None, + ), + AssetNode::Png { + fill_index, + image_hash, + } => ( + format!("{}:fills:{fill_index}", node.id), + format!("fills/{fill_index}"), + "image-fill".to_owned(), + image_hash, + ), + AssetNode::PngNode { image_hash } => ( + format!("{}:node", node.id), + "node".to_owned(), + "image-node".to_owned(), + image_hash, + ), + }; + + // Figma refuses to export a node that has no visible layers, so a node + // that draws nothing can never produce bytes. Advertising it as available + // promised something the export would always refuse, and the caller only + // found out once the failure surfaced from inside Figma, far from its + // cause. + // + // Drawing nothing is not only being hidden. A node left fully transparent + // is `visible`, and renders no pixel all the same: the about page carries + // two such icons, and they were the only two exports Figma turned down. + // + // Nor is it only about the node itself. One that sits entirely outside an + // ancestor that clips is visible, opaque, and still draws nothing, and + // Figma says so by leaving `absoluteRenderBounds` off it - that field is + // the bounds of what the node actually renders, and there are none. The + // devup-ui landing page's mobile and tablet each carry one such icon, + // pushed past the edge of a clipped panel, and they were the only two + // exports of 215 that Figma turned down. + let view = node.typed_view(); + let draws_nothing = view.value("absoluteBoundingBox").is_some() + && view + .value("absoluteRenderBounds") + .is_none_or(Value::is_null); + let hidden = view.bool("visible") == Some(false) + || view.number("opacity").is_some_and(|opacity| opacity <= 0.0) + || draws_nothing; + let (status, error_code) = if hidden { + ( + AssetStatus::Failed, + Some("DEVUP_ASSET_NODE_HIDDEN".to_owned()), + ) + } else { + (AssetStatus::Available, None) + }; + + AssetManifestEntry { + asset_id, + node_id: node.id.clone(), + field, + source_kind, + image_hash, + format: None, + scale: None, + status, + byte_length: None, + sha256: None, + mime_type: None, + data_base64: None, + output_path: None, + error_code, + path: None, + } +} + pub fn validate_asset_requests( snapshot: &Snapshot, requests: &[AssetRequest], ) -> Result<(), DevupError> { if requests.len() > 16 { - return Err(invalid("한 번에 export할 asset은 16개 이하여야 합니다.")); + return Err(invalid("At most 16 assets can be exported at once.")); } let available = discover_asset_manifest(snapshot); let mut seen = std::collections::BTreeSet::new(); for request in requests { if request.scale == 0 || request.scale > 4 || !seen.insert(request.asset_id.as_str()) { return Err(invalid( - "asset 요청의 scale 또는 중복 ID가 올바르지 않습니다.", + "asset request has an invalid scale or a duplicate ID.", )); } let Some(candidate) = available @@ -180,13 +460,21 @@ pub fn validate_asset_requests( .iter() .find(|asset| asset.asset_id == request.asset_id) else { - return Err(invalid("요청한 asset이 snapshot에 없습니다.")); + return Err(invalid("The requested asset is not in the snapshot.")); }; if candidate.node_id != request.node_id || candidate.field != request.field || candidate.image_hash != request.image_hash { - return Err(invalid("asset 요청이 snapshot source와 일치하지 않습니다.")); + return Err(invalid("asset request does not match the snapshot source.")); + } + // Reject what the manifest already knows cannot be exported, so the + // reason travels with the rejection instead of arriving later as an + // opaque failure from inside Figma. + if candidate.status == AssetStatus::Failed { + return Err(invalid( + "The requested asset cannot be exported: the node is hidden in Figma.", + )); } } Ok(()) @@ -197,7 +485,7 @@ pub fn resolve_asset_selections( selections: &[AssetSelection], ) -> Result, DevupError> { if selections.len() > 16 { - return Err(invalid("한 번에 export할 asset은 16개 이하여야 합니다.")); + return Err(invalid("At most 16 assets can be exported at once.")); } let manifest = discover_asset_manifest(snapshot); let mut seen = std::collections::BTreeSet::new(); @@ -209,14 +497,14 @@ pub fn resolve_asset_selections( || !seen.insert(selection.asset_id.as_str()) { return Err(invalid( - "asset 선택의 scale 또는 중복 ID가 올바르지 않습니다.", + "asset selection has an invalid scale or a duplicate ID.", )); } let asset = manifest .assets .iter() .find(|asset| asset.asset_id == selection.asset_id) - .ok_or_else(|| invalid("선택한 asset이 snapshot에 없습니다."))?; + .ok_or_else(|| invalid("The selected asset is not in the snapshot."))?; Ok(AssetRequest { asset_id: asset.asset_id.clone(), node_id: asset.node_id.clone(), @@ -236,9 +524,9 @@ pub fn asset_export_from_result( file_key: &str, version: Option<&str>, request: &AssetRequest, -) -> Result { +) -> Result { let descriptor = find_descriptor(&result.raw) - .ok_or_else(|| invalid("Figma MCP 응답에서 asset descriptor를 찾지 못했습니다."))?; + .ok_or_else(|| invalid("asset descriptor not found in the Figma MCP response."))?; if descriptor.file_key != file_key || descriptor.version.as_deref() != version || descriptor.asset_id != request.asset_id @@ -249,16 +537,43 @@ pub fn asset_export_from_result( || descriptor.scale != request.scale { return Err(invalid( - "asset descriptor가 요청 대상 또는 버전과 다릅니다.", + "asset descriptor target or version does not match the request.", )); } - let source_kind = if request.image_hash.is_some() { - "image-fill" - } else { - "vector-node" - }; + let source_kind = source_kind_of(request); + if descriptor.status == AssetStatus::Chunked { + let (Some(byte_length), Some(sha256), Some(cursor)) = ( + descriptor.byte_length, + descriptor.sha256.clone(), + descriptor.cursor.clone(), + ) else { + return Err(invalid( + "chunked asset export descriptor is missing its length, hash or cursor.", + )); + }; + // The field names what the fragments are cut from. A PNG carries its + // scale on the field, so the re-export behind each fragment is the + // same bytes that were announced. + let field = match request.format { + AssetFormat::Svg => SVG_EXPORT_FIELD.to_owned(), + AssetFormat::Png => format!("{PNG_EXPORT_FIELD}@{}", request.scale), + _ => { + return Err(invalid( + "only an SVG or PNG export is carried in fragments.", + )); + } + }; + let descriptor = LargeValueDescriptor { + node_id: request.node_id.clone(), + field, + byte_length, + sha256, + cursor, + }; + return Ok(AssetExportOutcome::Chunked(descriptor)); + } if descriptor.status == AssetStatus::Failed { - return Ok(AssetManifestEntry { + return Ok(AssetExportOutcome::Entry(AssetManifestEntry { asset_id: request.asset_id.clone(), node_id: request.node_id.clone(), field: request.field.clone(), @@ -273,23 +588,54 @@ pub fn asset_export_from_result( data_base64: None, output_path: None, error_code: descriptor.error_code, - }); + path: None, + })); } - let data = find_binary(&result.raw, request.format.mime_type()) - .ok_or_else(|| invalid("asset export 응답에 요청한 binary가 없습니다."))?; - let bytes = STANDARD - .decode(data.as_bytes()) - .map_err(|_| invalid("asset export binary의 base64가 올바르지 않습니다."))?; + let payload = find_payload(&result.raw, request.format.mime_type()).ok_or_else(|| { + // Which shapes the response *did* carry. Without this the failure is + // indistinguishable between "no attachment came back", "it came back + // under a different mime type" and "it came back in a field this + // search does not read" — three very different bugs. + DevupError::with_details( + ErrorCode::DevupSnapshotUnsupported, + format!( + "Figma exported the asset but did not return the {} bytes. \ + Upstream returns written files as an attachment only for png; \ + svg is carried inline. Request png or svg instead.", + request.format.extension() + ), + false, + json!({ + "expectedMimeType": request.format.mime_type(), + "observed": observed_payload_shapes(&result.raw), + }), + ) + })?; + let (bytes, data) = match payload { + AssetPayload::Base64(data) => { + let bytes = STANDARD + .decode(data.as_bytes()) + .map_err(|_| invalid("asset export binary base64 is invalid."))?; + (bytes, data) + } + // Re-encoded so every consumer downstream still receives base64, + // regardless of how the upstream happened to carry the payload. + AssetPayload::Text(text) => { + let bytes = text.into_bytes(); + let data = STANDARD.encode(&bytes); + (bytes, data) + } + }; if bytes.is_empty() || bytes.len() > MAX_ASSET_BYTES || descriptor.byte_length != Some(bytes.len()) || descriptor.sha256.as_deref() != Some(sha256_hex(&bytes).as_str()) { return Err(invalid( - "asset export binary의 길이 또는 hash가 일치하지 않습니다.", + "asset export binary length or hash does not match.", )); } - Ok(AssetManifestEntry { + Ok(AssetExportOutcome::Entry(AssetManifestEntry { asset_id: request.asset_id.clone(), node_id: request.node_id.clone(), field: request.field.clone(), @@ -304,6 +650,71 @@ pub fn asset_export_from_result( data_base64: Some(data), output_path: None, error_code: None, + path: None, + })) +} + +/// The virtual field a fragmented SVG export is read under: the large-value +/// script re-exports the node as SVG text and slices that, where for any +/// other field it slices the field's JSON. +/// What an export is cut from, from what the request already says: an +/// image fill, a node that is a picture, or a node that is line work. The +/// entry an export produces replaces the one discovery listed, so the two +/// have to agree; deriving both from the same two fields is how they do. +pub fn source_kind_of(request: &AssetRequest) -> &'static str { + if request.field.starts_with("fills/") { + "image-fill" + } else if request.image_hash.is_some() { + "image-node" + } else { + "vector-node" + } +} + +pub const SVG_EXPORT_FIELD: &str = "$export:svg"; + +/// The virtual field a PNG too large for one attachment is read back +/// through, with its scale appended: `$export:png@2`. Figma's remote MCP +/// returns a written PNG as an attachment only up to about a megabyte once +/// base64-encoded; a larger one is written, reported exported, and never +/// arrives, so it is carried in fragments instead. +pub const PNG_EXPORT_FIELD: &str = "$export:png"; + +/// What an asset export call answered with: the asset, exported or failed, +/// or the announcement of an SVG too large for one answer, to be read back +/// in fragments. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssetExportOutcome { + Entry(AssetManifestEntry), + Chunked(LargeValueDescriptor), +} + +/// The entry for an export that arrived in fragments, from the bytes the +/// fragments assembled to. The length and hash were checked against the +/// announcement by the assembler. +pub fn exported_asset_from_bytes( + request: &AssetRequest, + bytes: &[u8], +) -> Result { + if bytes.is_empty() || bytes.len() > MAX_ASSET_BYTES { + return Err(invalid("asset export binary length is out of range.")); + } + Ok(AssetManifestEntry { + asset_id: request.asset_id.clone(), + node_id: request.node_id.clone(), + field: request.field.clone(), + source_kind: source_kind_of(request).to_owned(), + image_hash: request.image_hash.clone(), + format: Some(request.format), + scale: Some(request.scale), + status: AssetStatus::Exported, + byte_length: Some(bytes.len()), + sha256: Some(sha256_hex(bytes)), + mime_type: Some(request.format.mime_type().to_owned()), + data_base64: Some(STANDARD.encode(bytes)), + output_path: None, + error_code: None, + path: None, }) } @@ -323,6 +734,8 @@ struct AssetExportDescriptor { byte_length: Option, sha256: Option, error_code: Option, + #[serde(default)] + cursor: Option, } fn find_descriptor(value: &Value) -> Option { @@ -341,29 +754,99 @@ fn find_descriptor(value: &Value) -> Option { } } -fn find_binary(value: &Value, mime_type: &str) -> Option { +/// How an upstream carried the exported asset. +enum AssetPayload { + /// An image content block or a blob resource, which are base64. + Base64(String), + /// A text resource. MCP models a text-based document — SVG being the one + /// devup-mcp exports — as `text` holding the document itself rather than + /// base64 of it, so an SVG export used to be invisible to a search that + /// only looked for `data`/`blob` and every request failed with "asset + /// export response does not contain the requested binary". + Text(String), +} + +fn find_payload(value: &Value, mime_type: &str) -> Option { match value { Value::Object(object) => { - let observed_mime = object.get("mimeType").and_then(Value::as_str); - if observed_mime == Some(mime_type) - && let Some(data) = object + if object.get("mimeType").and_then(Value::as_str) == Some(mime_type) { + // Base64 first: when a payload offers both, the binary form is + // the exact bytes, while `text` may be a lossy preview. + if let Some(data) = object .get("data") .or_else(|| object.get("blob")) .and_then(Value::as_str) - { - return Some(data.to_owned()); + { + return Some(AssetPayload::Base64(data.to_owned())); + } + if let Some(text) = object.get("text").and_then(Value::as_str) { + return Some(AssetPayload::Text(text.to_owned())); + } } object .values() - .find_map(|value| find_binary(value, mime_type)) + .find_map(|value| find_payload(value, mime_type)) } Value::Array(values) => values .iter() - .find_map(|value| find_binary(value, mime_type)), + .find_map(|value| find_payload(value, mime_type)), + // The descriptor — and, for SVG, the payload inlined beside it — + // arrives as JSON inside a text content block, so the search has to + // step through that encoding exactly as `find_descriptor` does. + Value::String(text) => serde_json::from_str::(text) + .ok() + .and_then(|value| find_payload(&value, mime_type)), _ => None, } } +/// Describes every payload-carrying object in a response by its `type` and +/// `mimeType` and which of `data`/`blob`/`text` it holds, without ever +/// including the payload itself. Bounded so a large response cannot turn a +/// diagnostic into another problem. +fn observed_payload_shapes(value: &Value) -> Vec { + fn walk(value: &Value, found: &mut Vec) { + if found.len() >= 12 { + return; + } + match value { + Value::Object(object) => { + let carriers: Vec<&str> = ["data", "blob", "text", "uri"] + .into_iter() + .filter(|key| object.contains_key(*key)) + .collect(); + if !carriers.is_empty() { + let kind = object + .get("type") + .and_then(Value::as_str) + .unwrap_or(""); + let mime = object + .get("mimeType") + .and_then(Value::as_str) + .unwrap_or(""); + found.push(format!( + "type={kind} mimeType={mime} carries=[{}]", + carriers.join(",") + )); + } + for child in object.values() { + walk(child, found); + } + } + Value::Array(values) => { + for child in values { + walk(child, found); + } + } + _ => {} + } + } + + let mut found = Vec::new(); + walk(value, &mut found); + found +} + fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes) .iter() diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index d7962d61..34035bf0 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -14,15 +14,16 @@ use crate::large_values::{ LargeValueResult, descriptors_in_chunk, large_value_from_result, replace_descriptor, }; use crate::{ - AssetManifestEntry, AssetRequest, AssetSelection, AssetStatus, BatchLimits, BuiltinScript, - DevupError, ErrorCode, ExploreReadOptions, FigmaTarget, LargeValueAssembler, + AssetExportOutcome, AssetManifestEntry, AssetRequest, AssetSelection, AssetStatus, BatchLimits, + BuiltinScript, DevupError, ErrorCode, ExploreReadOptions, FigmaTarget, LargeValueAssembler, LargeValueReadOptions, RawNode, ReadToolCall, ResourceBatch, ResourceScope, ResourceStyleRef, - SearchReadOptions, SectionIndex, SnapshotChunk, SnapshotReadOptions, UnresolvedResource, - UpstreamResult, UsedResourceRefs, asset_export_from_result, build_section_index, - collect_used_resource_refs, decode_fast_multi_snapshot, decode_fast_snapshot, - decode_fast_theme, merge_chunks, + SNAPSHOT_CURSOR_ID, SearchReadOptions, SectionIndex, SnapshotChunk, SnapshotCursor, + SnapshotReadOptions, UnresolvedResource, UpstreamResult, UsedResourceRefs, + asset_export_from_result, build_section_index, collect_used_resource_refs, + decode_fast_multi_snapshot, decode_fast_snapshot, decode_fast_theme, exported_asset_from_bytes, + merge_chunks, metadata::{MetadataResult, metadata_from_result_for_target}, - plan_batches, resolve_asset_selections, snapshot_chunk_from_result, + plan_batches, read_snapshot_cursor, resolve_asset_selections, snapshot_chunk_from_result, variables::{ VariableBatchResult, VariableCatalog, batch_from_result, catalog_from_result, merge_used_resource_results, merge_variable_results, @@ -33,15 +34,22 @@ const LARGE_SUBTREE_THRESHOLD: usize = 200; const MAX_PENDING_CALLS: usize = 4; const VARIABLE_BATCH_SIZE: usize = 8; const STYLE_BATCH_SIZE: usize = 8; -const USED_RESOURCE_BATCH_ITEMS: usize = 64; +// The byte budget below measures the *request* — the IDs — and the ceiling +// that matters is on the *response*: the official MCP cuts a text result at +// 20,480 UTF-8 bytes (measured; see `scripts/fast_snapshot.js`) and appends +// "// truncated to 20kb", which the batch decoder then cannot parse. A variable serialised with its valuesByMode and +// codeSyntax is five hundred to a thousand bytes on the way back, so the +// sixty-four this allowed could not fit; thirty-eight were measured filling +// the cap exactly. Twelve leaves the response near a third of it. +const USED_RESOURCE_BATCH_ITEMS: usize = 12; const USED_RESOURCE_BATCH_BYTES: usize = 12_000; // Consumer relations can be huge. Compact, bounded fragments are expanded // back to the exhaustive shape in Rust without dropping any relation. const STYLE_CONSUMER_BATCH_SIZE: usize = 320; -const SNAPSHOT_CURSOR_ID: &str = "__DEVUP_SNAPSHOT_CURSOR__"; + const MAX_REFERENCE_PNG_BYTES: usize = 16 * 1024 * 1024; const MAX_REFERENCE_PNG_BASE64_BYTES: usize = MAX_REFERENCE_PNG_BYTES.div_ceil(3) * 4; -const MAX_REFERENCE_PNG_DIMENSION: u32 = 8_192; +pub(crate) const MAX_REFERENCE_PNG_DIMENSION: u32 = 8_192; const MAX_REFERENCE_PNG_DECODED_BYTES: usize = 64 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -116,6 +124,16 @@ pub struct CollectedParts { pub stats: CollectionStats, pub assets: Vec, pub reference_png: Option, + pub failures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScreenFailure { + pub node_id: String, + pub error_code: ErrorCode, + pub message: String, + pub retryable: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -147,7 +165,10 @@ impl Default for CollectionStats { fn default() -> Self { Self { figma_tool_calls: 0, - transport: "legacy-cursor".to_owned(), + // Text (optionally paginated) is the default, primary path now; + // "legacy-cursor" only ever appears once a fast call actually + // falls back (see `restart_legacy`). + transport: "text".to_owned(), fallback_used: false, fallback_reason: None, node_count: 0, @@ -209,6 +230,12 @@ pub struct CollectorSession { variable_batches: BTreeMap, variables: Option, large_values: BTreeMap<(String, String), LargeValueAssembler>, + /// An SVG or PNG export announced as too large for one answer, by the + /// key its fragments arrive under, until the last of them has. + chunked_assets: BTreeMap<(String, String), AssetRequest>, + /// The pages of a paginated theme so far, merged, until the last one. + fast_theme_pages: Option, + fast_theme_page_count: usize, asset_results: Vec, assets_scheduled: bool, reference_png: Option, @@ -219,7 +246,15 @@ pub struct CollectorSession { section_selected_roots: Vec, fast_multi_resources: Option, fast_multi_has_large_values: bool, + /// Resources merged across rounds of the paginated single-root fast + /// snapshot (`accept_fast_snapshot`). Distinct from `fast_multi_resources`, + /// which is scoped to Section multi-root batching; the two paths are + /// mutually exclusive (`fast_path_eligible` requires `section.is_none()`). + fast_snapshot_resources: Option, + fast_snapshot_has_large_values: bool, + fast_snapshot_rounds: usize, section_fallback_roots: BTreeSet, + screen_failures: Vec, next_id: usize, completed: bool, } @@ -244,6 +279,9 @@ impl CollectorSession { variable_batches: BTreeMap::new(), variables: None, large_values: BTreeMap::new(), + chunked_assets: BTreeMap::new(), + fast_theme_pages: None, + fast_theme_page_count: 0, asset_results: Vec::new(), assets_scheduled, reference_png: None, @@ -254,7 +292,11 @@ impl CollectorSession { section_selected_roots: Vec::new(), fast_multi_resources: None, fast_multi_has_large_values: false, + fast_snapshot_resources: None, + fast_snapshot_has_large_values: false, + fast_snapshot_rounds: 0, section_fallback_roots: BTreeSet::new(), + screen_failures: Vec::new(), next_id: 0, completed: false, } @@ -266,7 +308,9 @@ impl CollectorSession { pub fn advance(&mut self) -> Result { if self.completed { - return Err(invalid_call("완료된 Figma 수집 session입니다.")); + return Err(invalid_call( + "This Figma collection session is already complete.", + )); } if self.section_index.is_none() && let Some(index) = self.request.cached_section_index.take() @@ -276,10 +320,12 @@ impl CollectorSession { } if self.metadata.is_none() && self.pending.is_empty() && self.queued.is_empty() { if self.request.section.is_some() && self.section_index.is_none() { - let node_id = - self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma Section index에는 node ID가 필요합니다.") - })?; + let node_id = self + .request + .target + .node_id + .clone() + .ok_or_else(|| invalid_call("Figma Section index requires a node ID."))?; self.enqueue( ReadToolCall::section_index(&self.request.target.file_key, &node_id), Some(node_id), @@ -298,7 +344,7 @@ impl CollectorSession { } if let Some(options) = self.request.explore.clone() { let node_id = self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma 주변 화면 탐색에는 node ID가 필요합니다.") + invalid_call("Figma nearby-screen exploration requires a node ID.") })?; self.enqueue( ReadToolCall::explore_snapshot( @@ -320,10 +366,12 @@ impl CollectorSession { return self.advance(); } if self.fast_path_eligible() && !self.fast_attempted { - let node_id = - self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma fast snapshot에는 node ID가 필요합니다.") - })?; + let node_id = self + .request + .target + .node_id + .clone() + .ok_or_else(|| invalid_call("Figma fast snapshot requires a node ID."))?; self.fast_attempted = true; self.enqueue( ReadToolCall::fast_snapshot(&self.request.target.file_key, &node_id), @@ -397,7 +445,7 @@ impl CollectorSession { && self.queued.is_empty() { let node_id = self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma reference PNG 수집에는 node ID가 필요합니다.") + invalid_call("Figma reference PNG collection requires a node ID.") })?; self.reference_png_scheduled = true; self.enqueue( @@ -413,10 +461,9 @@ impl CollectorSession { && self.variables.is_none() && self.queued.is_empty() { - let node_id = self - .root_node_id - .clone() - .ok_or_else(|| invalid_call("Figma 변수 수집에 사용할 root node ID가 없습니다."))?; + let node_id = self.root_node_id.clone().ok_or_else(|| { + invalid_call("No root node ID available for Figma variable collection.") + })?; self.enqueue( ReadToolCall::snapshot( &self.request.target.file_key, @@ -437,7 +484,7 @@ impl CollectorSession { let catalog = self .variable_catalog .take() - .ok_or_else(|| invalid_call("Figma 변수 catalog가 없습니다."))?; + .ok_or_else(|| invalid_call("Figma variable catalog is missing."))?; self.variables = Some(merge_variable_results( catalog, std::mem::take(&mut self.variable_batches).into_values(), @@ -463,7 +510,7 @@ impl CollectorSession { let refs = self .used_resource_refs .take() - .ok_or_else(|| invalid_call("사용된 Figma 리소스 참조가 없습니다."))?; + .ok_or_else(|| invalid_call("Used Figma resource references are missing."))?; let merged = merge_used_resource_results( &refs, std::mem::take(&mut self.variable_batches).into_values(), @@ -479,7 +526,7 @@ impl CollectorSession { &mut combined, result .take() - .ok_or_else(|| invalid_call("fallback resource 결과가 없습니다."))?, + .ok_or_else(|| invalid_call("fallback resource result is missing."))?, )?; result = combined; } @@ -515,6 +562,7 @@ impl CollectorSession { stats: self.stats.clone(), assets: std::mem::take(&mut self.asset_results), reference_png: self.reference_png.take(), + failures: std::mem::take(&mut self.screen_failures), }))); } Ok(CollectorStep::AwaitingResults) @@ -524,7 +572,7 @@ impl CollectorSession { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("알 수 없거나 이미 처리한 Figma call ID입니다."))?; + .ok_or_else(|| invalid_call("Unknown or already-handled Figma call ID."))?; self.consumed.insert(call_id.to_owned()); match pending.kind { CallKind::FastSnapshot => { @@ -561,18 +609,37 @@ impl CollectorSession { pub fn reject(&mut self, call_id: &str, error: &DevupError) -> Result { let Some(pending) = self.pending.get(call_id) else { - return Err(invalid_call( - "알 수 없거나 이미 처리한 Figma call ID입니다.", - )); + return Err(invalid_call("Unknown or already-handled Figma call ID.")); }; + if pending.kind == CallKind::FastSnapshot && is_section_target_error(error) { + let pending = self + .pending + .remove(call_id) + .ok_or_else(|| invalid_call("fast Section probe call is missing."))?; + self.consumed.insert(call_id.to_owned()); + let node_id = pending + .planned + .expected_node_id + .ok_or_else(|| invalid_call("fast Section probe node ID is missing."))?; + self.request.section = Some(SectionReadOptions { + frame_ids: Vec::new(), + all_screens: false, + }); + self.enqueue( + ReadToolCall::section_index(&self.request.target.file_key, &node_id), + Some(node_id), + CallKind::SectionIndex, + ); + return Ok(true); + } if pending.kind == CallKind::Asset { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("asset call이 없습니다."))?; + .ok_or_else(|| invalid_call("asset call is missing."))?; self.consumed.insert(call_id.to_owned()); let ReadToolCall::AssetExport { request, .. } = pending.planned.call else { - return Err(invalid_call("asset call 형식이 올바르지 않습니다.")); + return Err(invalid_call("asset call format is invalid.")); }; self.record_asset_failure(*request, "DEVUP_ASSET_EXPORT_FAILED"); return Ok(true); @@ -581,14 +648,39 @@ impl CollectorSession { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("large value call이 없습니다."))?; + .ok_or_else(|| invalid_call("large value call is missing."))?; self.consumed.insert(call_id.to_owned()); let ReadToolCall::LargeValue { options, .. } = pending.planned.call else { - return Err(invalid_call("large value call 형식이 올바르지 않습니다.")); + return Err(invalid_call("large value call format is invalid.")); }; self.record_large_value_unsupported(&options, "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM")?; return Ok(true); } + if pending.kind == CallKind::Snapshot + && pending + .planned + .expected_node_id + .as_ref() + .is_some_and(|node_id| self.section_fallback_roots.contains(node_id)) + { + let pending = self + .pending + .remove(call_id) + .ok_or_else(|| invalid_call("Section legacy call is missing."))?; + self.consumed.insert(call_id.to_owned()); + let node_id = pending + .planned + .expected_node_id + .ok_or_else(|| invalid_call("Section legacy call node ID is missing."))?; + self.section_fallback_roots.remove(&node_id); + self.screen_failures.push(ScreenFailure { + node_id, + error_code: error.code, + message: error.message.clone(), + retryable: error.retryable, + }); + return Ok(true); + } if !matches!( pending.kind, CallKind::FastSnapshot | CallKind::FastTheme | CallKind::FastMultiRoot @@ -601,7 +693,7 @@ impl CollectorSession { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("fast call이 없습니다."))?; + .ok_or_else(|| invalid_call("fast call is missing."))?; self.consumed.insert(call_id.to_owned()); if pending.kind == CallKind::FastMultiRoot { self.fallback_multi_root_batch(&pending.planned, fallback_category(error))?; @@ -642,21 +734,69 @@ impl CollectorSession { return Ok(()); } }; + self.fast_theme_page_count += 1; + self.stats.raw_bytes = self.stats.raw_bytes.saturating_add(payload.stats.raw_bytes); + self.stats.wire_bytes = self + .stats + .wire_bytes + .saturating_add(payload.stats.wire_bytes); + // A page is merged onto the pages before it: the resources are + // concatenated in the one order the script lists them, and the scan + // every page repeats - which ids are used, what could not be + // resolved - is taken from the page at hand. + let merged = match self.fast_theme_pages.take() { + None => payload.resources.raw, + Some(mut merged) => { + merge_theme_page(&mut merged, payload.resources.raw)?; + merged + } + }; + if let Some(page) = payload.page + && !page.complete + { + if page.next_offset <= page.offset { + return Err(invalid_call("Figma theme page cursor did not advance.")); + } + self.fast_theme_pages = Some(merged); + self.enqueue( + ReadToolCall::fast_theme_page(&self.request.target.file_key, page.next_offset), + None, + CallKind::FastTheme, + ); + return Ok(()); + } + // The remote variables among those collected, which a page does not + // list twice. + let mut merged = merged; + let remote = merged["variables"] + .as_array() + .map(|variables| { + variables + .iter() + .filter(|variable| { + variable.get("remote").and_then(Value::as_bool) == Some(true) + }) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + if merged["usedRemoteVariables"] + .as_array() + .is_none_or(Vec::is_empty) + { + merged["usedRemoteVariables"] = Value::Array(remote); + } self.metadata = Some(json!({ - "transport": "png-theme-envelope-v1", - "collectionCount": payload.resources.raw["collections"] - .as_array().map_or(0, Vec::len), - "variableCount": payload.resources.raw["variables"] - .as_array().map_or(0, Vec::len), - "styleCount": payload.resources.raw["styles"] - .as_array().map_or(0, Vec::len) + "transport": payload.stats.transport, + "pageCount": self.fast_theme_page_count, + "collectionCount": merged["collections"].as_array().map_or(0, Vec::len), + "variableCount": merged["variables"].as_array().map_or(0, Vec::len), + "styleCount": merged["styles"].as_array().map_or(0, Vec::len) })); self.source_version = payload.source_version; - self.stats.transport = "png-theme-envelope-v1".to_owned(); - self.stats.raw_bytes = payload.stats.raw_bytes; - self.stats.wire_bytes = payload.stats.wire_bytes; - self.stats.envelope_chunks = payload.stats.chunk_count; - self.variables = Some(payload.resources); + self.stats.transport = payload.stats.transport.to_owned(); + self.stats.envelope_chunks = self.fast_theme_page_count; + self.variables = Some(UpstreamResult { raw: merged }); Ok(()) } @@ -693,22 +833,71 @@ impl CollectorSession { .target .node_id .clone() - .ok_or_else(|| invalid_call("Figma fast snapshot에는 node ID가 필요합니다."))?; - self.metadata = Some(json!({ - "transport": "png-envelope-v1", - "rootId": root_id, - "nodeCount": payload.snapshot.nodes.len() - })); - self.root_node_id = Some(root_id); + .ok_or_else(|| invalid_call("Figma fast snapshot requires a node ID."))?; + self.root_node_id = Some(root_id.clone()); self.source_version = payload.snapshot.version.clone(); self.metadata_root_ids = payload.snapshot.root_ids.clone(); - self.stats.transport = "png-envelope-v1".to_owned(); - self.stats.raw_bytes = payload.stats.raw_bytes; - self.stats.wire_bytes = payload.stats.wire_bytes; - self.stats.envelope_chunks = payload.stats.chunk_count; + self.fast_snapshot_rounds = self.fast_snapshot_rounds.saturating_add(1); + self.stats.raw_bytes = self.stats.raw_bytes.saturating_add(payload.stats.raw_bytes); + self.stats.wire_bytes = self + .stats + .wire_bytes + .saturating_add(payload.stats.wire_bytes); let has_large_values = !descriptors_in_chunk(&payload.snapshot)?.is_empty(); - self.variables = (!has_large_values).then_some(payload.resources); - self.record_snapshot_chunk(order, payload.snapshot)?; + if has_large_values { + self.fast_snapshot_has_large_values = true; + } else { + merge_fast_resources(&mut self.fast_snapshot_resources, payload.resources)?; + } + + let mut chunk = payload.snapshot; + // The script always appends a `__DEVUP_SNAPSHOT_CURSOR__` marker node + // (same convention as the legacy cursor snapshot) reporting whether + // more pages remain; `take_snapshot_cursor` strips it and returns + // that state. A missing marker (only possible for hand-built, + // pre-pagination-shaped payloads) is treated as a single complete + // page. `record_snapshot_chunk` then stores this page's real nodes + // and enqueues any large-value follow-ups they declared. + let total_nodes = chunk.nodes.len(); + let cursor = take_snapshot_cursor(&mut chunk)?.unwrap_or(SnapshotCursor { + offset: 0, + next_offset: total_nodes, + complete: true, + total_nodes, + }); + self.record_snapshot_chunk(order, chunk)?; + + if cursor.complete { + self.stats.transport = if self.fast_snapshot_rounds > 1 { + "text-paginated" + } else { + "text" + } + .to_owned(); + self.stats.envelope_chunks = 0; + self.variables = (!self.fast_snapshot_has_large_values) + .then(|| self.fast_snapshot_resources.take()) + .flatten(); + self.metadata = Some(json!({ + "transport": &self.stats.transport, + "rootId": root_id, + "nodeCount": cursor.total_nodes, + "pageCount": self.fast_snapshot_rounds + })); + } else { + self.enqueue( + ReadToolCall::fast_snapshot_page( + &self.request.target.file_key, + &root_id, + SnapshotReadOptions { + offset: cursor.next_offset, + ..SnapshotReadOptions::default() + }, + ), + Some(root_id), + CallKind::FastSnapshot, + ); + } Ok(()) } @@ -726,7 +915,11 @@ impl CollectorSession { self.variable_batches.clear(); self.variables = None; self.large_values.clear(); + self.fast_snapshot_resources = None; + self.fast_snapshot_has_large_values = false; + self.fast_snapshot_rounds = 0; self.section_fallback_roots.clear(); + self.screen_failures.clear(); self.asset_results.clear(); self.assets_scheduled = self.request.asset_selections.is_empty(); self.reference_png = None; @@ -762,13 +955,13 @@ impl CollectorSession { let catalog = snapshot_chunk_from_result(&result)?; if catalog.file_key != planned.expected_file_key { return Err(invalid_call( - "Figma page catalog의 file key가 요청과 다릅니다.", + "Figma page catalog file key does not match the request.", )); } if catalog.root_ids.is_empty() { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma page catalog가 비어 있습니다.", + "Figma page catalog is empty.", false, )); } @@ -776,7 +969,7 @@ impl CollectorSession { .request .search .clone() - .ok_or_else(|| invalid_call("검색 설정 없이 page catalog를 수집했습니다."))?; + .ok_or_else(|| invalid_call("Collected a page catalog without search options."))?; self.metadata = Some(result.raw); self.root_node_id = catalog.root_ids.first().cloned(); self.source_version = catalog.version.clone(); @@ -807,17 +1000,16 @@ impl CollectorSession { let chunk = snapshot_chunk_from_result(&result)?; if chunk.file_key != planned.expected_file_key { return Err(invalid_call( - "Figma 탐색 projection의 file key가 요청과 다릅니다.", + "Figma exploration projection file key does not match the request.", )); } - let expected_node_id = planned - .expected_node_id - .as_deref() - .ok_or_else(|| invalid_call("Figma 탐색 projection의 expected node ID가 없습니다."))?; + let expected_node_id = planned.expected_node_id.as_deref().ok_or_else(|| { + invalid_call("Figma exploration projection expected node ID is missing.") + })?; if !chunk.nodes.iter().any(|node| node.id == expected_node_id) { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma 탐색 projection에서 anchor node를 찾지 못했습니다.", + "anchor node not found in the Figma exploration projection.", false, )); } @@ -838,18 +1030,18 @@ impl CollectorSession { let chunk = snapshot_chunk_from_result(&result)?; if chunk.file_key != planned.expected_file_key { return Err(invalid_call( - "Figma Section index의 file key가 요청과 다릅니다.", + "Figma Section index file key does not match the request.", )); } let section_id = planned .expected_node_id .as_deref() - .ok_or_else(|| invalid_call("Figma Section index의 node ID가 없습니다."))?; + .ok_or_else(|| invalid_call("Figma Section index node ID is missing."))?; if chunk.root_ids.as_slice() != [section_id] || !chunk.nodes.iter().any(|node| node.id == section_id) { return Err(invalid_call( - "Figma Section index가 요청한 Section과 일치하지 않습니다.", + "Figma Section index does not match the requested Section.", )); } let snapshot = merge_chunks(vec![chunk.clone()])?; @@ -858,7 +1050,7 @@ impl CollectorSession { .request .section .clone() - .ok_or_else(|| invalid_call("Section read options가 없습니다."))?; + .ok_or_else(|| invalid_call("Section read options are missing."))?; self.source_version = index.source_version.clone(); self.root_node_id = Some(section_id.to_owned()); self.metadata_root_ids = chunk.root_ids.clone(); @@ -893,15 +1085,17 @@ impl CollectorSession { self.enqueue_section_legacy_root(root_id); } } else { - self.enqueue( - ReadToolCall::multi_root_snapshot( - &self.request.target.file_key, - section_id, - batch.root_ids, - ), - Some(section_id.to_owned()), - CallKind::FastMultiRoot, - ); + for root_id in batch.root_ids { + self.enqueue( + ReadToolCall::multi_root_snapshot( + &self.request.target.file_key, + section_id, + vec![root_id], + ), + Some(section_id.to_owned()), + CallKind::FastMultiRoot, + ); + } } } Ok(()) @@ -912,14 +1106,14 @@ impl CollectorSession { || self.request.target.node_id.as_deref() != Some(index.section.node_id.as_str()) { return Err(invalid_call( - "cached Section index가 요청한 Section과 일치하지 않습니다.", + "cached Section index does not match the requested Section.", )); } let options = self .request .section .clone() - .ok_or_else(|| invalid_call("cached Section index에 선택 설정이 없습니다."))?; + .ok_or_else(|| invalid_call("cached Section index has no selection options."))?; let selected = index.select(&options.frame_ids, options.all_screens)?; let batches = plan_batches(&index, &selected, BatchLimits::default())?; self.source_version = index.source_version.clone(); @@ -939,7 +1133,7 @@ impl CollectorSession { .target .node_id .clone() - .ok_or_else(|| invalid_call("cached Section index의 Section ID가 없습니다."))?; + .ok_or_else(|| invalid_call("cached Section index Section ID is missing."))?; for batch in batches { if batch.oversized { self.mark_section_legacy("oversized-section-root".to_owned()); @@ -947,15 +1141,17 @@ impl CollectorSession { self.enqueue_section_legacy_root(root_id); } } else { - self.enqueue( - ReadToolCall::multi_root_snapshot( - &self.request.target.file_key, - §ion_id, - batch.root_ids, - ), - Some(section_id.clone()), - CallKind::FastMultiRoot, - ); + for root_id in batch.root_ids { + self.enqueue( + ReadToolCall::multi_root_snapshot( + &self.request.target.file_key, + §ion_id, + vec![root_id], + ), + Some(section_id.clone()), + CallKind::FastMultiRoot, + ); + } } } Ok(()) @@ -973,9 +1169,7 @@ impl CollectorSession { .. } = &planned.call else { - return Err(invalid_call( - "multi-root snapshot call 형식이 올바르지 않습니다.", - )); + return Err(invalid_call("multi-root snapshot call format is invalid.")); }; let payload = match decode_fast_multi_snapshot(&result, &self.request.target, root_ids) { Ok(payload) => payload, @@ -990,7 +1184,7 @@ impl CollectorSession { { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "multi-root 수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during multi-root collection.", true, )); } @@ -1000,7 +1194,7 @@ 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() { - "png-multi-root-envelope-v1" + payload.stats.transport } else { "hybrid-multi-root-cursor" } @@ -1029,14 +1223,10 @@ impl CollectorSession { .. } = &planned.call else { - return Err(invalid_call( - "multi-root fallback call 형식이 올바르지 않습니다.", - )); + return Err(invalid_call("multi-root fallback call format is invalid.")); }; if root_ids.is_empty() { - return Err(invalid_call( - "multi-root fallback에 선택된 root가 없습니다.", - )); + return Err(invalid_call("multi-root fallback has no selected roots.")); } self.mark_section_legacy(reason); for root_id in root_ids { @@ -1080,19 +1270,29 @@ impl CollectorSession { } let snapshot = merge_chunks(chunks)?; let observed = snapshot.roots.iter().collect::>(); + let failed = self + .screen_failures + .iter() + .map(|failure| failure.node_id.as_str()) + .collect::>(); if self .section_selected_roots .iter() - .any(|root_id| !observed.contains(root_id)) + .any(|root_id| !observed.contains(root_id) && !failed.contains(root_id.as_str())) { return Err(invalid_call( - "Section snapshot에 선택된 root가 모두 포함되지 않았습니다.", + "Section snapshot does not include all selected roots.", )); } Ok(vec![SnapshotChunk { file_key: snapshot.file_key, version: snapshot.version, - root_ids: self.section_selected_roots.clone(), + root_ids: self + .section_selected_roots + .iter() + .filter(|root_id| observed.contains(*root_id)) + .cloned() + .collect(), nodes: snapshot.nodes.into_values().collect(), diagnostics: snapshot.diagnostics, }]) @@ -1110,7 +1310,7 @@ impl CollectorSession { let key = (descriptor.node_id.clone(), descriptor.field.clone()); if self.large_values.contains_key(&key) { return Err(invalid_call( - "동일한 Figma large value descriptor가 중복되었습니다.", + "Duplicate Figma large value descriptor for the same field.", )); } let options = LargeValueReadOptions::from_descriptor( @@ -1141,7 +1341,7 @@ impl CollectorSession { result: UpstreamResult, ) -> Result<(), DevupError> { let ReadToolCall::LargeValue { options, .. } = &planned.call else { - return Err(invalid_call("large value call 형식이 올바르지 않습니다.")); + return Err(invalid_call("large value call format is invalid.")); }; let result = large_value_from_result(&result)?; if let LargeValueResult::Unsupported(unsupported) = result { @@ -1154,7 +1354,7 @@ impl CollectorSession { || unsupported.error_code != "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM" { return Err(invalid_call( - "large value unsupported 응답이 요청과 일치하지 않습니다.", + "large value unsupported response does not match the request.", )); } return self.record_large_value_unsupported(options, &unsupported.error_code); @@ -1164,7 +1364,7 @@ impl CollectorSession { }; if fragment.offset != options.offset { return Err(invalid_call( - "large value fragment offset이 요청과 일치하지 않습니다.", + "large value fragment offset does not match the request.", )); } let key = (options.node_id.clone(), options.field.clone()); @@ -1173,19 +1373,27 @@ impl CollectorSession { let assembler = self .large_values .get_mut(&key) - .ok_or_else(|| invalid_call("large value assembler가 없습니다."))?; + .ok_or_else(|| invalid_call("large value assembler is missing."))?; assembler.push(fragment)?; if complete { let assembler = self .large_values .remove(&key) - .ok_or_else(|| invalid_call("large value assembler가 없습니다."))?; + .ok_or_else(|| invalid_call("large value assembler is missing."))?; + if let Some(request) = self.chunked_assets.remove(&key) { + // The fragments were an SVG or PNG export, not a field: the + // bytes are the asset, and go where a one-answer export would. + let bytes = assembler.finish_bytes()?; + self.asset_results + .push(exported_asset_from_bytes(&request, &bytes)?); + return Ok(()); + } let descriptor = assembler.descriptor().clone(); let value = assembler.finish()?; replace_descriptor(&mut self.snapshot_chunks, &descriptor, value)?; } else { if next_offset <= options.offset { - return Err(invalid_call("large value cursor가 진행되지 않았습니다.")); + return Err(invalid_call("large value cursor did not advance.")); } let descriptor = assembler.descriptor().clone(); let next = LargeValueReadOptions::from_descriptor( @@ -1211,7 +1419,11 @@ impl CollectorSession { let assembler = self .large_values .remove(&key) - .ok_or_else(|| invalid_call("large value assembler가 없습니다."))?; + .ok_or_else(|| invalid_call("large value assembler is missing."))?; + if let Some(request) = self.chunked_assets.remove(&key) { + self.record_asset_failure(request, "DEVUP_ASSET_EXPORT_FAILED"); + return Ok(()); + } let descriptor = assembler.descriptor().clone(); replace_descriptor( &mut self.snapshot_chunks, @@ -1235,7 +1447,7 @@ impl CollectorSession { chunk.diagnostics.push(crate::Diagnostic { code: error_code.to_owned(), message: - "Figma upstream에서 큰 필드를 다시 읽을 수 없어 명시적 marker를 유지했습니다." + "Figma upstream could not re-read the large field, so an explicit marker was kept." .to_owned(), node_id: Some(descriptor.node_id), severity: Some(crate::DiagnosticSeverity::Warning), @@ -1257,14 +1469,44 @@ impl CollectorSession { version, request, .. } = &planned.call else { - return Err(invalid_call("asset call 형식이 올바르지 않습니다.")); + return Err(invalid_call("asset call format is invalid.")); }; - let exported = asset_export_from_result( + let exported = match asset_export_from_result( &result, &planned.expected_file_key, version.as_deref(), request, - )?; + )? { + AssetExportOutcome::Entry(entry) => entry, + AssetExportOutcome::Chunked(descriptor) => { + let key = (descriptor.node_id.clone(), descriptor.field.clone()); + if self.large_values.contains_key(&key) { + return Err(invalid_call( + "Duplicate Figma large value descriptor for the same field.", + )); + } + let options = LargeValueReadOptions::from_descriptor( + &descriptor, + version.clone(), + descriptor.cursor.next_offset, + ); + self.large_values.insert( + key.clone(), + LargeValueAssembler::new( + self.request.target.file_key.clone(), + version.clone(), + descriptor, + )?, + ); + self.chunked_assets.insert(key, request.as_ref().clone()); + self.enqueue( + ReadToolCall::large_value(&self.request.target.file_key, options.clone()), + Some(options.node_id), + CallKind::LargeValue, + ); + return Ok(()); + } + }; if exported.status == AssetStatus::Failed { let error_code = exported .error_code @@ -1282,29 +1524,29 @@ impl CollectorSession { result: UpstreamResult, ) -> Result<(), DevupError> { let ReadToolCall::Screenshot { file_key, node_id } = &planned.call else { - return Err(invalid_call("reference PNG call 형식이 올바르지 않습니다.")); + return Err(invalid_call("reference PNG call format is invalid.")); }; if file_key != &planned.expected_file_key || planned.expected_node_id.as_deref() != Some(node_id.as_str()) { return Err(invalid_call( - "reference PNG call의 Figma 대상이 요청과 다릅니다.", + "reference PNG call Figma target does not match the request.", )); } let data_base64 = take_single_png_data(result.raw)?; if data_base64.len() > MAX_REFERENCE_PNG_BASE64_BYTES { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG가 허용 크기를 초과했습니다.", + "Figma reference PNG exceeds the allowed size.", false, )); } let bytes = STANDARD .decode(data_base64.as_bytes()) - .map_err(|_| invalid_call("Figma reference PNG의 base64가 올바르지 않습니다."))?; + .map_err(|_| invalid_call("Figma reference PNG base64 is invalid."))?; if bytes.is_empty() || bytes.len() > MAX_REFERENCE_PNG_BYTES { return Err(invalid_call( - "Figma reference PNG의 형식 또는 크기가 올바르지 않습니다.", + "Figma reference PNG format or size is invalid.", )); } validate_reference_png(&bytes)?; @@ -1322,15 +1564,12 @@ impl CollectorSession { fn record_asset_failure(&mut self, request: AssetRequest, error_code: &str) { self.record_asset_diagnostic(&request, error_code); + let source_kind = crate::assets::source_kind_of(&request).to_owned(); self.asset_results.push(AssetManifestEntry { asset_id: request.asset_id, node_id: request.node_id, field: request.field, - source_kind: if request.image_hash.is_some() { - "image-fill".to_owned() - } else { - "vector-node".to_owned() - }, + source_kind, image_hash: request.image_hash, format: Some(request.format), scale: Some(request.scale), @@ -1341,6 +1580,7 @@ impl CollectorSession { data_base64: None, output_path: None, error_code: Some(error_code.to_owned()), + path: None, }); } @@ -1352,7 +1592,7 @@ impl CollectorSession { { chunk.diagnostics.push(crate::Diagnostic { code: error_code.to_owned(), - message: "요청한 Figma asset을 export하지 못해 layout 출력은 유지했습니다." + message: "Failed to export the requested Figma asset; layout output was kept." .to_owned(), node_id: Some(request.node_id.clone()), severity: Some(crate::DiagnosticSeverity::Warning), @@ -1379,7 +1619,7 @@ impl CollectorSession { if let MetadataResult::TopLevelPages(pages) = metadata { if planned.expected_node_id.is_some() { return Err(invalid_call( - "page metadata 요청에 top-level page 목록이 반환되었습니다.", + "A page metadata request returned the top-level page list.", )); } self.record_metadata(result.raw); @@ -1420,14 +1660,16 @@ impl CollectorSession { unreachable!("top-level page metadata is handled above") }; if document.file_key != self.request.target.file_key { - return Err(invalid_call("Figma metadata의 file key가 요청과 다릅니다.")); + return Err(invalid_call( + "Figma metadata file key does not match the request.", + )); } if let (Some(existing), Some(incoming)) = (&self.source_version, &document.version) && existing != incoming { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "metadata 수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during metadata collection.", true, )); } @@ -1447,7 +1689,7 @@ impl CollectorSession { let root = document.root().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma metadata에서 대상 node를 찾지 못했습니다.", + "Target node not found in the Figma metadata.", false, ) })?; @@ -1537,12 +1779,14 @@ impl CollectorSession { ) -> Result<(), DevupError> { let mut chunk = snapshot_chunk_from_result(&result)?; if chunk.file_key != planned.expected_file_key { - return Err(invalid_call("Figma snapshot의 file key가 요청과 다릅니다.")); + return Err(invalid_call( + "Figma snapshot file key does not match the request.", + )); } if chunk.version != self.source_version { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during collection.", true, )); } @@ -1560,23 +1804,23 @@ impl CollectorSession { let expected_next = options .offset .checked_add(chunk.nodes.len()) - .ok_or_else(|| invalid_call("Figma snapshot cursor offset이 넘쳤습니다."))?; + .ok_or_else(|| invalid_call("Figma snapshot cursor offset overflowed."))?; if cursor.next_offset != expected_next || cursor.next_offset > cursor.total_nodes { return Err(invalid_call( - "Figma snapshot cursor가 수집한 node 범위와 일치하지 않습니다.", + "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의 완료 상태가 node 수와 일치하지 않습니다.", + "Figma snapshot cursor completion state does not match the node count.", )); } if !cursor.complete { if chunk.nodes.is_empty() { - return Err(invalid_call("Figma snapshot cursor가 진행되지 않았습니다.")); + return Err(invalid_call("Figma snapshot cursor did not advance.")); } let node_id = planned.expected_node_id.clone().ok_or_else(|| { - invalid_call("Figma snapshot cursor의 root node ID가 없습니다.") + invalid_call("Figma snapshot cursor root node ID is missing.") })?; self.enqueue( ReadToolCall::snapshot_chunk( @@ -1598,10 +1842,9 @@ impl CollectorSession { fn accept_variable_catalog(&mut self, result: UpstreamResult) -> Result<(), DevupError> { let catalog = catalog_from_result(&result)?; - let node_id = self - .root_node_id - .clone() - .ok_or_else(|| invalid_call("Figma 변수 batch에 사용할 root node ID가 없습니다."))?; + let node_id = self.root_node_id.clone().ok_or_else(|| { + invalid_call("No root node ID available for the Figma variable batch.") + })?; for variable_ids in catalog.variable_ids.chunks(VARIABLE_BATCH_SIZE) { self.enqueue( ReadToolCall::resource_batch( @@ -1651,7 +1894,7 @@ impl CollectorSession { .collect::>(); let refs = collect_used_resource_refs(&chunks); let node_id = self.root_node_id.clone().ok_or_else(|| { - invalid_call("사용된 Figma 리소스 batch에 사용할 root node ID가 없습니다.") + invalid_call("No root node ID available for the used Figma resource batch.") })?; for batch in used_resource_batches(&refs)? { self.enqueue( @@ -1680,7 +1923,7 @@ impl CollectorSession { let diagnostic = crate::Diagnostic { code: "DEVUP_RESOURCE_UNRESOLVED".to_owned(), message: format!( - "Figma 리소스를 확인할 수 없어 raw 값으로 대체했습니다: field={}, resourceId={}", + "Could not resolve the Figma resource; substituted the raw value: field={}, resourceId={}", occurrence.field, occurrence.resource_id ), node_id: Some(occurrence.node_id.clone()), @@ -1713,11 +1956,11 @@ impl CollectorSession { batch: &VariableBatchResult, ) -> Result<(), DevupError> { let node_id = self.root_node_id.clone().ok_or_else(|| { - invalid_call("Figma style consumer 수집에 사용할 root node ID가 없습니다.") + invalid_call("No root node ID available for Figma style consumer collection.") })?; for style in &batch.styles { let Some(object) = style.as_object() else { - return Err(invalid_call("Figma style batch 형식이 올바르지 않습니다.")); + return Err(invalid_call("Figma style batch format is invalid.")); }; let Some(consumer_count) = object.get("$consumerCount").and_then(Value::as_u64) else { continue; @@ -1725,11 +1968,11 @@ impl CollectorSession { let id = object .get("id") .and_then(Value::as_str) - .ok_or_else(|| invalid_call("Figma style ID가 없습니다."))?; + .ok_or_else(|| invalid_call("Figma style ID is missing."))?; let style_type = object .get("styleType") .and_then(Value::as_str) - .ok_or_else(|| invalid_call("Figma style type이 없습니다."))?; + .ok_or_else(|| invalid_call("Figma style type is missing."))?; for start in (0..consumer_count as usize).step_by(STYLE_CONSUMER_BATCH_SIZE) { let end = (start + STYLE_CONSUMER_BATCH_SIZE).min(consumer_count as usize); self.enqueue( @@ -1771,27 +2014,36 @@ impl CollectorSession { } } +/// The one image the screenshot answered with. +/// +/// The official `get_screenshot` writes the PNG's URL and how to fetch it as +/// text blocks, and inlines the PNG itself as an image block beside them when +/// asked to (`enableBase64Response`). The text is not read; the image must be +/// exactly one, and a PNG. A response with no image, or two, or one hidden in +/// text or JSON, is refused as before. fn take_single_png_data(value: Value) -> Result { let result = serde_json::from_value::(value) - .map_err(|_| invalid_call("Figma screenshot 응답 형식이 올바르지 않습니다."))?; - if result.is_error == Some(true) || result.content.len() != 1 { + .map_err(|_| invalid_call("Figma screenshot response format is invalid."))?; + if result.is_error == Some(true) { return Err(invalid_call( - "Figma screenshot 응답에는 image/png content가 정확히 하나 있어야 합니다.", + "Figma screenshot response must contain exactly one image/png content block.", )); } - let content = result + let mut images = result .content .into_iter() - .next() - .ok_or_else(|| invalid_call("Figma screenshot 응답에 image/png content가 없습니다."))?; - let ContentBlock::Image(image) = content else { + .filter_map(|content| match content { + ContentBlock::Image(image) => Some(image), + _ => None, + }); + let (Some(image), None) = (images.next(), images.next()) else { return Err(invalid_call( - "Figma screenshot 응답에는 image/png content가 정확히 하나 있어야 합니다.", + "Figma screenshot response must contain exactly one image/png content block.", )); }; if image.mime_type != "image/png" { return Err(invalid_call( - "Figma screenshot 응답의 MIME 형식이 image/png가 아닙니다.", + "Figma screenshot response MIME type is not image/png.", )); } Ok(image.data) @@ -1808,7 +2060,7 @@ fn validate_reference_png(bytes: &[u8]) -> Result<(), DevupError> { let decoded_bytes = usize::try_from(decoder.total_bytes()).map_err(|_| { DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG의 decoded 크기가 허용 범위를 초과했습니다.", + "Figma reference PNG decoded size exceeds the allowed range.", false, ) })?; @@ -1820,7 +2072,7 @@ fn validate_reference_png(bytes: &[u8]) -> Result<(), DevupError> { { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG의 dimensions 또는 decoded 크기가 허용 범위를 초과했습니다.", + "Figma reference PNG dimensions or decoded size exceed the allowed range.", false, )); } @@ -1834,67 +2086,73 @@ fn reference_png_decode_error(error: ImageError) -> DevupError { if matches!(error, ImageError::Limits(_)) { DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG의 dimensions 또는 decoded 크기가 허용 범위를 초과했습니다.", + "Figma reference PNG dimensions or decoded size exceed the allowed range.", false, ) } else { - invalid_call("Figma reference PNG 데이터가 손상되었습니다.") + invalid_call("Figma reference PNG data is corrupted.") } } -#[derive(Debug, Clone, Copy)] -struct SnapshotCursor { - next_offset: usize, - complete: bool, - total_nodes: usize, -} - fn take_snapshot_cursor(chunk: &mut SnapshotChunk) -> Result, DevupError> { - let positions = chunk - .nodes - .iter() - .enumerate() - .filter_map(|(index, node)| (node.id == SNAPSHOT_CURSOR_ID).then_some(index)) - .collect::>(); - let Some(&position) = positions.first() else { + let Some(cursor) = read_snapshot_cursor(&chunk.nodes) + .map_err(|message| invalid_call(message.korean_message()))? + else { return Ok(None); }; - if positions.len() != 1 { - return Err(invalid_call( - "Figma snapshot 응답에 cursor가 중복되었습니다.", - )); - } - let cursor = chunk.nodes.remove(position); - if cursor.node_type != "DEVUP_INTERNAL" { - return Err(invalid_call( - "Figma snapshot cursor 형식이 올바르지 않습니다.", - )); - } - let cursor = cursor.typed_view(); - let next_offset = cursor - .value("nextOffset") - .and_then(Value::as_u64) - .and_then(|value| usize::try_from(value).ok()) - .ok_or_else(|| invalid_call("Figma snapshot cursor의 nextOffset이 없습니다."))?; - let complete = cursor - .bool("complete") - .ok_or_else(|| invalid_call("Figma snapshot cursor의 complete가 없습니다."))?; - let total_nodes = cursor - .value("totalNodes") - .and_then(Value::as_u64) - .and_then(|value| usize::try_from(value).ok()) - .ok_or_else(|| invalid_call("Figma snapshot cursor의 totalNodes가 없습니다."))?; - Ok(Some(SnapshotCursor { - next_offset, - complete, - total_nodes, - })) + chunk.nodes.retain(|node| node.id != SNAPSHOT_CURSOR_ID); + Ok(Some(cursor)) } fn invalid_call(message: &str) -> DevupError { DevupError::new(ErrorCode::DevupFigmaHandoffInvalid, message, false) } +/// The next page of a theme onto the pages before it. Every list - the +/// resources and the scan alike - is appended in order, with an entry the +/// pages both carry kept once; the scalars are the page's. +fn merge_theme_page(merged: &mut Value, page: Value) -> Result<(), DevupError> { + let Some(page) = page.as_object() else { + return Err(invalid_call("Figma theme page format is invalid.")); + }; + for list in [ + "collections", + "variables", + "styles", + "usedRemoteVariables", + "usedVariableIds", + "usedStyleIds", + "unresolved", + ] { + let incoming = page + .get(list) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let target = merged + .get_mut(list) + .and_then(Value::as_array_mut) + .ok_or_else(|| invalid_call("Figma theme page format is invalid."))?; + for item in incoming { + // A resource is one by its id, an id in the scan by itself. + let same = |existing: &Value| match item.get("id") { + Some(id) => existing.get("id") == Some(id), + None => *existing == item, + }; + if target.iter().any(same) { + continue; + } + target.push(item); + } + } + for field in ["localComplete", "usedRemoteComplete"] { + if let Some(value) = page.get(field) { + merged[field] = value.clone(); + } + } + Ok(()) +} + fn fallback_category(error: &DevupError) -> String { error .details @@ -1904,6 +2162,14 @@ fn fallback_category(error: &DevupError) -> String { .unwrap_or_else(|| format!("{:?}", error.code)) } +fn is_section_target_error(error: &DevupError) -> bool { + error.message.contains("DEVUP_TARGET_IS_SECTION") + || error + .details + .to_string() + .contains("DEVUP_TARGET_IS_SECTION") +} + fn fast_call_fallback_allowed(error: &DevupError) -> bool { matches!( error.code, @@ -1949,11 +2215,11 @@ fn merge_fast_resources( let current = current .raw .as_object_mut() - .ok_or_else(|| invalid_call("기존 multi-root resource 형식이 올바르지 않습니다."))?; + .ok_or_else(|| invalid_call("Existing multi-root resource format is invalid."))?; let incoming = incoming .raw .as_object() - .ok_or_else(|| invalid_call("multi-root resource 형식이 올바르지 않습니다."))?; + .ok_or_else(|| invalid_call("multi-root resource format is invalid."))?; for field in ["collections", "variables", "styles", "usedRemoteVariables"] { let mut values = BTreeMap::::new(); for value in current @@ -1972,13 +2238,13 @@ fn merge_fast_resources( let id = value .get("id") .and_then(Value::as_str) - .ok_or_else(|| invalid_call("multi-root resource ID가 없습니다."))?; + .ok_or_else(|| invalid_call("multi-root resource ID is missing."))?; if let Some(previous) = values.get(id) && previous != value { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "multi-root resource 내용이 batch 사이에서 달라졌습니다.", + "multi-root resource contents differ between batches.", true, )); } @@ -2006,7 +2272,7 @@ fn merge_fast_resources( value .as_str() .map(str::to_owned) - .ok_or_else(|| invalid_call("multi-root resource ID 형식이 올바르지 않습니다.")) + .ok_or_else(|| invalid_call("multi-root resource ID format is invalid.")) }) .collect::, _>>()?; current.insert( @@ -2028,7 +2294,7 @@ fn merge_fast_resources( ) .map(|value| serde_json::to_string(value).map(|key| (key, value.clone()))) .collect::, _>>() - .map_err(|_| invalid_call("multi-root unresolved resource를 직렬화할 수 없습니다."))?; + .map_err(|_| invalid_call("Could not serialize the multi-root unresolved resource."))?; current.insert( "unresolved".to_owned(), Value::Array(unresolved.into_values().collect()), @@ -2073,7 +2339,7 @@ fn used_resource_batches(refs: &UsedResourceRefs) -> Result, if current.variable_ids.is_empty() && current.styles.is_empty() { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "단일 Figma 리소스 ID가 안전한 batch 크기를 초과했습니다.", + "A single Figma resource ID exceeds the safe batch size.", false, )); } @@ -2086,7 +2352,7 @@ fn used_resource_batches(refs: &UsedResourceRefs) -> Result, if !used_resource_batch_fits(¤t) { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "단일 Figma 리소스 ID가 안전한 batch 크기를 초과했습니다.", + "A single Figma resource ID exceeds the safe batch size.", false, )); } diff --git a/crates/devup-mcp-figma/src/credentials.rs b/crates/devup-mcp-figma/src/credentials.rs index 826b45ce..b3619012 100644 --- a/crates/devup-mcp-figma/src/credentials.rs +++ b/crates/devup-mcp-figma/src/credentials.rs @@ -4,12 +4,25 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -use super::{DevupError, ErrorCode}; +use super::{DevupError, ErrorCode, SecretString}; #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StoredAuthorization { pub client_id: String, + /// The secret issued alongside `client_id` by Dynamic Client + /// Registration, when the authorization server issues one. + /// + /// Figma's does: its metadata advertises only `client_secret_basic` and + /// `client_secret_post`, so a DCR-registered client is confidential and + /// every token/refresh request must carry the secret. It is kept next to + /// the `client_id` it belongs to rather than in the user-facing client + /// credential store, which holds credentials the operator supplied. + /// + /// `#[serde(default)]` keeps authorizations written before this field + /// existed readable from the keyring. + #[serde(default)] + pub client_secret: Option, pub access_token: String, pub refresh_token: Option, pub expires_at: Option, @@ -85,7 +98,7 @@ impl CredentialStore for KeyringCredentialStore { Ok(json) => serde_json::from_str(&json).map(Some).map_err(|_| { DevupError::new( ErrorCode::DevupAuthRequired, - "저장된 Figma 인증 정보를 읽을 수 없습니다. 다시 로그인하세요.", + "Cannot read the stored Figma credentials. Log in again.", false, ) }), @@ -118,7 +131,7 @@ impl CredentialStore for KeyringCredentialStore { fn keyring_error(_error: keyring::Error) -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "운영체제 보안 저장소에 Figma 인증 정보를 저장할 수 없습니다.", + "Cannot store Figma credentials in the OS secure store.", false, ) } @@ -126,7 +139,114 @@ fn keyring_error(_error: keyring::Error) -> DevupError { fn credential_task_error() -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "Figma 인증 저장소 작업을 완료하지 못했습니다.", + "Failed to complete the Figma credential store operation.", true, ) } + +/// A user-supplied, pre-registered Figma Remote MCP OAuth client (see +/// `README.md`'s "Figma 연결 설정" section for why devup-mcp cannot +/// register its own client). devup-mcp never invents this value: it is +/// only ever accepted from `--figma-client-id`/`--figma-client-secret`, +/// `DEVUP_FIGMA_CLIENT_ID`/`DEVUP_FIGMA_CLIENT_SECRET`, or the +/// `devup_figma_auth {"action":"configure"}` tool. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCredentials { + pub client_id: String, + pub client_secret: Option, +} + +impl std::fmt::Debug for ClientCredentials { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ClientCredentials") + .field("client_id", &self.client_id) + .field( + "client_secret", + &self.client_secret.as_ref().map(|_| "[REDACTED]"), + ) + .finish() + } +} + +/// Persists a user-supplied [`ClientCredentials`] so it survives process +/// restarts, independent of the OAuth token stored in [`CredentialStore`]. +#[async_trait] +pub trait ClientCredentialStore: Send + Sync + 'static { + async fn load(&self) -> Result, DevupError>; + async fn save(&self, value: &ClientCredentials) -> Result<(), DevupError>; + async fn clear(&self) -> Result<(), DevupError>; +} + +#[derive(Clone, Default)] +pub struct MemoryClientCredentialStore { + value: Arc>>, +} + +#[async_trait] +impl ClientCredentialStore for MemoryClientCredentialStore { + async fn load(&self) -> Result, DevupError> { + Ok(self.value.read().await.clone()) + } + + async fn save(&self, value: &ClientCredentials) -> Result<(), DevupError> { + *self.value.write().await = Some(value.clone()); + Ok(()) + } + + async fn clear(&self) -> Result<(), DevupError> { + *self.value.write().await = None; + Ok(()) + } +} + +/// OS credential store backend for [`ClientCredentialStore`]. Uses a +/// distinct keyring entry from [`KeyringCredentialStore`] (which holds the +/// OAuth token) so configuring a client credential never touches the +/// stored access/refresh token, and vice versa. +#[derive(Debug, Clone, Copy, Default)] +pub struct KeyringClientCredentialStore; + +impl KeyringClientCredentialStore { + fn entry() -> Result { + keyring::Entry::new("devup-mcp", "figma-client-credentials").map_err(keyring_error) + } +} + +#[async_trait] +impl ClientCredentialStore for KeyringClientCredentialStore { + async fn load(&self) -> Result, DevupError> { + tokio::task::spawn_blocking(|| match Self::entry()?.get_password() { + Ok(json) => serde_json::from_str(&json).map(Some).map_err(|_| { + DevupError::new( + ErrorCode::DevupAuthRequired, + "Cannot read the stored Figma client credentials. Run configure again.", + false, + ) + }), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(keyring_error(error)), + }) + .await + .map_err(|_| credential_task_error())? + } + + async fn save(&self, value: &ClientCredentials) -> Result<(), DevupError> { + let json = serde_json::to_string(value).map_err(|_| credential_task_error())?; + tokio::task::spawn_blocking(move || { + Self::entry()?.set_password(&json).map_err(keyring_error) + }) + .await + .map_err(|_| credential_task_error())? + } + + async fn clear(&self) -> Result<(), DevupError> { + tokio::task::spawn_blocking(|| match Self::entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(keyring_error(error)), + }) + .await + .map_err(|_| credential_task_error())? + } +} diff --git a/crates/devup-mcp-figma/src/envelope.rs b/crates/devup-mcp-figma/src/envelope.rs index 144da20e..28ba5f44 100644 --- a/crates/devup-mcp-figma/src/envelope.rs +++ b/crates/devup-mcp-figma/src/envelope.rs @@ -1,27 +1,25 @@ use std::{borrow::Cow, collections::BTreeSet}; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use serde::Deserialize; +use serde::{Deserialize, de::DeserializeOwned}; use serde_json::{Value, json}; use crate::{ DevupError, ErrorCode, FigmaTarget, ResourceKind, SnapshotChunk, UpstreamResult, - collect_used_resource_refs, + collect_used_resource_refs, read_snapshot_cursor, }; -const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; -const ENVELOPE_CHUNK_TYPE: &[u8; 4] = b"duVp"; -const EXPECTED_IHDR: &[u8; 13] = &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]; -const MAX_PNG_BYTES: usize = 11 * 1024 * 1024; -const MAX_BASE64_PNG_BYTES: usize = MAX_PNG_BYTES.div_ceil(3) * 4; -const MAX_ENVELOPE_BYTES: usize = 8 * 1024 * 1024; -const MAX_ENVELOPE_CHUNKS: usize = 32; +/// Decoder-side ceiling on a single text envelope. Deliberately larger than +/// the 19 KiB the producing script budgets itself to: a relay that +/// re-serializes the JSON (pretty-printing, different escaping) inflates the +/// payload without changing its content, and rejecting that as `too_large` +/// would fail a perfectly valid envelope. Still bounded, so a hostile or +/// runaway response cannot be buffered without limit. +const MAX_TEXT_ENVELOPE_BYTES: usize = 64 * 1024; const MAX_STRINGIFIED_RESULT_BYTES: usize = 16 * 1024 * 1024; -type EnvelopeChunk<'a> = (u32, u32, &'a [u8]); - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FastTransportStats { + pub transport: &'static str, pub raw_bytes: usize, pub wire_bytes: usize, pub chunk_count: usize, @@ -39,11 +37,28 @@ pub struct FastThemePayload { pub resources: UpstreamResult, pub source_version: Option, pub stats: FastTransportStats, + /// Which page of the file's resources this is. `None` for an envelope + /// that carries them all at once, the shape every fast theme had before + /// pagination. + pub page: Option, +} + +/// One page of a paginated theme: the run of resources it carries and where +/// the next page starts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemePage { + pub offset: usize, + pub next_offset: usize, + pub complete: bool, + pub total_items: usize, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Envelope { + #[serde(default)] + kind: Option, schema_version: u32, source: EnvelopeSource, snapshot: SnapshotChunk, @@ -58,35 +73,30 @@ struct EnvelopeSource { root_id: String, } +/// The producer also emits `utf8Bytes` here. It is deliberately absent: it is +/// the producer's measurement of its own serialized form, so comparing it +/// against what arrived only rejected relays that re-serialize the JSON. +/// Corruption is caught by the counts below plus `validate_resources`, which +/// read the content itself. Serde ignores the extra key on the wire. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnvelopeIntegrity { node_count: usize, variable_ref_count: usize, style_ref_count: usize, - utf8_bytes: usize, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EnvelopeDescriptor { - kind: String, - schema_version: u32, - root_id: String, - node_count: usize, - variable_ref_count: usize, - style_ref_count: usize, - utf8_bytes: usize, - chunk_count: usize, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ThemeEnvelope { + #[serde(default)] + kind: Option, schema_version: u32, source: ThemeEnvelopeSource, resources: Value, integrity: ThemeEnvelopeIntegrity, + #[serde(default)] + page: Option, } #[derive(Debug, Deserialize)] @@ -96,6 +106,7 @@ struct ThemeEnvelopeSource { version: Option, } +/// `utf8Bytes` is omitted for the same reason as [`EnvelopeIntegrity`]. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ThemeEnvelopeIntegrity { @@ -103,20 +114,6 @@ struct ThemeEnvelopeIntegrity { variable_count: usize, style_count: usize, unresolved_count: usize, - utf8_bytes: usize, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ThemeEnvelopeDescriptor { - kind: String, - schema_version: u32, - collection_count: usize, - variable_count: usize, - style_count: usize, - unresolved_count: usize, - utf8_bytes: usize, - chunk_count: usize, } pub fn decode_fast_snapshot( @@ -143,84 +140,36 @@ pub fn decode_fast_multi_snapshot( decode_fast_snapshot_for_roots(result, target, expected_root_ids) } +/// Fast node snapshots are always delivered as text now (no PNG-chunked +/// binary transport exists any more — real-world hosts silently discarded +/// those image attachments, so it never actually worked). A single round may +/// legitimately cover only *part* of the target subtree; `peek_page_cursor` +/// reports whether this is the case so `validate_envelope` can relax the +/// root-containment and dangling-child checks that only hold for a complete, +/// self-contained envelope. fn decode_fast_snapshot_for_roots( result: &UpstreamResult, target: &FigmaTarget, expected_root_ids: &[String], ) -> Result { let raw = normalize_upstream_result(&result.raw)?; - let descriptor = find_descriptor(&raw)?; - if descriptor.chunk_count == 0 { - return Err(invalid("descriptorChunkCount")); - } - if descriptor.chunk_count > MAX_ENVELOPE_CHUNKS { - return Err(too_large("chunkCount")); - } - - let images = find_images(&raw)?; - if images.len() > descriptor.chunk_count { - return Err(invalid("imageMultiplicity")); - } - let mut encoded_bytes = 0_usize; - let mut wire_bytes = 0_usize; - let mut pngs = Vec::with_capacity(images.len()); - for (encoded, mime_type) in images { - if mime_type != "image/png" { - return Err(invalid("imageMime")); - } - encoded_bytes = encoded_bytes - .checked_add(encoded.len()) - .ok_or_else(|| too_large("png"))?; - let maximum_encoded_bytes = MAX_BASE64_PNG_BYTES - .checked_add(MAX_ENVELOPE_CHUNKS * 3) - .ok_or_else(|| too_large("png"))?; - if encoded_bytes > maximum_encoded_bytes { - return Err(too_large("png")); - } - let png = STANDARD - .decode(encoded) - .map_err(|_| invalid("imageBase64"))?; - wire_bytes = wire_bytes - .checked_add(png.len()) - .ok_or_else(|| too_large("png"))?; - if wire_bytes > MAX_PNG_BYTES { - return Err(too_large("png")); - } - pngs.push(png); - } - - let mut chunks = Vec::with_capacity(descriptor.chunk_count); - for png in &pngs { - chunks.extend(decode_png_envelope(png)?); - } - if chunks.len() != descriptor.chunk_count { - return Err(invalid("descriptorChunkCount")); - } - let envelope_bytes = join_envelope_chunks(chunks)?; - if envelope_bytes.len() > MAX_ENVELOPE_BYTES { - return Err(too_large("envelope")); - } - let envelope_text = - std::str::from_utf8(&envelope_bytes).map_err(|_| invalid("envelopeUtf8"))?; - let envelope: Envelope = - serde_json::from_str(envelope_text).map_err(|_| invalid("envelopeJson"))?; - validate_envelope( - &envelope, - &descriptor, - target, - expected_root_ids, - envelope_bytes.len(), - )?; - + let Some((envelope, utf8_bytes)) = + find_tagged_text::(&raw, "devupFastSnapshotEnvelope")? + else { + return Err(invalid("textEnvelopeMissing")); + }; + let page = peek_page_cursor(&envelope.snapshot)?; + validate_envelope(&envelope, target, expected_root_ids, page)?; Ok(FastSnapshotPayload { snapshot: envelope.snapshot, resources: UpstreamResult { raw: envelope.resources, }, stats: FastTransportStats { - raw_bytes: envelope_bytes.len(), - wire_bytes, - chunk_count: descriptor.chunk_count, + transport: "text", + raw_bytes: utf8_bytes, + wire_bytes: utf8_bytes, + chunk_count: 0, }, }) } @@ -230,75 +179,60 @@ pub fn decode_fast_theme( expected_file_key: &str, ) -> Result { let raw = normalize_upstream_result(&result.raw)?; - let descriptor = find_theme_descriptor(&raw)?; - if descriptor.chunk_count == 0 { - return Err(invalid("descriptorChunkCount")); - } - if descriptor.chunk_count > MAX_ENVELOPE_CHUNKS { - return Err(too_large("chunkCount")); - } - let images = find_images(&raw)?; - if images.len() > descriptor.chunk_count { - return Err(invalid("imageMultiplicity")); - } - let mut encoded_bytes = 0_usize; - let mut wire_bytes = 0_usize; - let mut pngs = Vec::with_capacity(images.len()); - for (encoded, mime_type) in images { - if mime_type != "image/png" { - return Err(invalid("imageMime")); - } - encoded_bytes = encoded_bytes - .checked_add(encoded.len()) - .ok_or_else(|| too_large("png"))?; - if encoded_bytes > MAX_BASE64_PNG_BYTES + MAX_ENVELOPE_CHUNKS * 3 { - return Err(too_large("png")); - } - let png = STANDARD - .decode(encoded) - .map_err(|_| invalid("imageBase64"))?; - wire_bytes = wire_bytes - .checked_add(png.len()) - .ok_or_else(|| too_large("png"))?; - if wire_bytes > MAX_PNG_BYTES { - return Err(too_large("png")); - } - pngs.push(png); - } - let mut chunks = Vec::with_capacity(descriptor.chunk_count); - for png in &pngs { - chunks.extend(decode_png_envelope(png)?); - } - if chunks.len() != descriptor.chunk_count { - return Err(invalid("descriptorChunkCount")); - } - let envelope_bytes = join_envelope_chunks(chunks)?; - if envelope_bytes.len() > MAX_ENVELOPE_BYTES { - return Err(too_large("envelope")); + let Some((envelope, utf8_bytes)) = + find_tagged_text::(&raw, "devupFastThemeEnvelope")? + else { + return Err(invalid("textEnvelopeMissing")); + }; + validate_theme_envelope(&envelope, expected_file_key)?; + if let Some(page) = &envelope.page + && (page.next_offset < page.offset + || page.next_offset > page.total_items + || page.complete != (page.next_offset >= page.total_items)) + { + return Err(invalid("themePageCursor")); } - let envelope_text = - std::str::from_utf8(&envelope_bytes).map_err(|_| invalid("envelopeUtf8"))?; - let envelope: ThemeEnvelope = - serde_json::from_str(envelope_text).map_err(|_| invalid("envelopeJson"))?; - validate_theme_envelope( - &envelope, - &descriptor, - expected_file_key, - envelope_bytes.len(), - )?; Ok(FastThemePayload { resources: UpstreamResult { raw: envelope.resources, }, source_version: envelope.source.version, stats: FastTransportStats { - raw_bytes: envelope_bytes.len(), - wire_bytes, - chunk_count: descriptor.chunk_count, + transport: "text", + raw_bytes: utf8_bytes, + wire_bytes: utf8_bytes, + chunk_count: 0, }, + page: envelope.page, }) } +/// Whether an envelope's node list is a partial page of a larger, paginated +/// fetch. Derived from the shared `__DEVUP_SNAPSHOT_CURSOR__` reader so the +/// marker is only ever parsed against one field list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PageCursor { + is_first_page: bool, + is_final_page: bool, +} + +fn peek_page_cursor(chunk: &SnapshotChunk) -> Result { + match read_snapshot_cursor(&chunk.nodes).map_err(|error| invalid(error.category()))? { + Some(cursor) => Ok(PageCursor { + is_first_page: cursor.offset == 0, + is_final_page: cursor.complete, + }), + // No cursor marker at all: treat as a single, complete, self-contained + // envelope (the shape every fast snapshot had before pagination). + // Real script output always includes the marker; this only matters for + // hand-built payloads (tests, older fixtures). + None => Ok(PageCursor { + is_first_page: true, + is_final_page: true, + }), + } +} + fn normalize_upstream_result(value: &Value) -> Result, DevupError> { match value { Value::String(text) => { @@ -313,227 +247,89 @@ fn normalize_upstream_result(value: &Value) -> Result, DevupError } } -fn find_images(value: &Value) -> Result, DevupError> { - fn collect<'a>(value: &'a Value, found: &mut Vec<(&'a str, &'a str)>) { +fn find_tagged_text( + value: &Value, + expected_kind: &str, +) -> Result, DevupError> { + fn collect<'a>(value: &'a Value, expected_kind: &str, found: &mut Vec<&'a str>) { match value { Value::Object(object) => { - if object.get("type").and_then(Value::as_str) == Some("image") - && let Some(data) = object.get("data").and_then(Value::as_str) - && let Some(mime) = object - .get("mimeType") - .or_else(|| object.get("mime_type")) - .and_then(Value::as_str) + if let Some(text) = object.get("text").and_then(Value::as_str) + && serde_json::from_str::(text) + .ok() + .and_then(|value| { + value.get("kind").and_then(Value::as_str).map(str::to_owned) + }) + .as_deref() + == Some(expected_kind) { - found.push((data, mime)); + found.push(text); } for child in object.values() { - collect(child, found); + collect(child, expected_kind, found); } } Value::Array(values) => { for child in values { - collect(child, found); + collect(child, expected_kind, found); } } - _ => {} + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} } } - let mut images = Vec::new(); - collect(value, &mut images); - if images.is_empty() { - return Err(invalid("imageMissing")); - } - if images.len() > MAX_ENVELOPE_CHUNKS { - return Err(too_large("imageCount")); - } - Ok(images) -} - -fn find_descriptor(value: &Value) -> Result { - fn collect(value: &Value, found: &mut Vec) { - match value { - Value::Object(object) => { - if let Some(Value::String(text)) = object.get("text") - && let Ok(descriptor) = serde_json::from_str::(text) - && descriptor.kind == "devupFastSnapshotDescriptor" - { - found.push(descriptor); - } - for child in object.values() { - collect(child, found); - } + let mut found = Vec::new(); + collect(value, expected_kind, &mut found); + match found.as_slice() { + [] => Ok(None), + [text] => { + if text.len() > MAX_TEXT_ENVELOPE_BYTES { + return Err(too_large("textEnvelope")); } - Value::Array(values) => { - for child in values { - collect(child, found); - } - } - _ => {} - } - } - - let mut descriptors = Vec::new(); - collect(value, &mut descriptors); - match descriptors.len() { - 1 => Ok(descriptors.remove(0)), - 0 => Err(invalid("descriptorMissing")), - _ => Err(invalid("descriptorMultiplicity")), - } -} - -fn find_theme_descriptor(value: &Value) -> Result { - fn collect(value: &Value, found: &mut Vec) { - match value { - Value::Object(object) => { - if let Some(Value::String(text)) = object.get("text") - && let Ok(descriptor) = serde_json::from_str::(text) - && descriptor.kind == "devupFastThemeDescriptor" - { - found.push(descriptor); - } - for child in object.values() { - collect(child, found); - } - } - Value::Array(values) => { - for child in values { - collect(child, found); - } - } - _ => {} - } - } - - let mut descriptors = Vec::new(); - collect(value, &mut descriptors); - match descriptors.len() { - 1 => Ok(descriptors.remove(0)), - 0 => Err(invalid("descriptorMissing")), - _ => Err(invalid("descriptorMultiplicity")), - } -} - -fn decode_png_envelope(png: &[u8]) -> Result>, DevupError> { - if !png.starts_with(PNG_SIGNATURE) { - return Err(invalid("pngSignature")); - } - - let mut offset = PNG_SIGNATURE.len(); - let mut first = true; - let mut saw_idat = false; - let mut saw_iend = false; - let mut envelope_chunks = Vec::new(); - while offset < png.len() { - let header_end = offset.checked_add(8).ok_or_else(|| invalid("pngLength"))?; - if header_end > png.len() { - return Err(invalid("pngLength")); - } - let length = u32::from_be_bytes( - png[offset..offset + 4] - .try_into() - .map_err(|_| invalid("pngLength"))?, - ) as usize; - let chunk_type: &[u8; 4] = png[offset + 4..header_end] - .try_into() - .map_err(|_| invalid("pngChunkType"))?; - let data_start = header_end; - let data_end = data_start - .checked_add(length) - .ok_or_else(|| invalid("pngLength"))?; - let crc_end = data_end - .checked_add(4) - .ok_or_else(|| invalid("pngLength"))?; - if crc_end > png.len() { - return Err(invalid("pngLength")); - } - - if first { - if chunk_type != b"IHDR" || &png[data_start..data_end] != EXPECTED_IHDR { - return Err(invalid("pngIhdr")); - } - } else if chunk_type == b"IHDR" { - return Err(invalid("pngIhdr")); - } - first = false; - let expected_crc = u32::from_be_bytes( - png[data_end..crc_end] - .try_into() - .map_err(|_| invalid("pngCrc"))?, - ); - if crc32(&png[offset + 4..data_end]) != expected_crc { - return Err(invalid("pngCrc")); - } - if chunk_type == ENVELOPE_CHUNK_TYPE { - if length < 8 { - return Err(invalid("envelopeChunkHeader")); - } - let sequence = u32::from_be_bytes( - png[data_start..data_start + 4] - .try_into() - .map_err(|_| invalid("envelopeChunkHeader"))?, - ); - let total = u32::from_be_bytes( - png[data_start + 4..data_start + 8] - .try_into() - .map_err(|_| invalid("envelopeChunkHeader"))?, - ); - envelope_chunks.push((sequence, total, &png[data_start + 8..data_end])); - } - if chunk_type == b"IDAT" { - saw_idat = true; - } - if chunk_type == b"IEND" { - if length != 0 || crc_end != png.len() { - return Err(invalid("pngIend")); - } - saw_iend = true; - break; + serde_json::from_str(text) + .map(|envelope| Some((envelope, text.len()))) + .map_err(|_| invalid("envelopeJson")) } - offset = crc_end; - } - - if !saw_iend { - return Err(invalid("pngIend")); - } - if !saw_idat { - return Err(invalid("pngIdat")); + _ => Err(invalid("textEnvelopeMultiplicity")), } - if envelope_chunks.is_empty() { - return Err(invalid("envelopeChunkMissing")); - } - Ok(envelope_chunks) } -fn join_envelope_chunks(chunks: Vec>) -> Result, DevupError> { - let total = u32::try_from(chunks.len()).map_err(|_| too_large("chunkCount"))?; - let mut byte_count = 0_usize; - for (expected_sequence, (sequence, declared_total, bytes)) in chunks.iter().enumerate() { - if declared_total != &total || sequence != &(expected_sequence as u32) { - return Err(invalid("envelopeChunkSequence")); - } - byte_count = byte_count - .checked_add(bytes.len()) - .ok_or_else(|| too_large("envelope"))?; - if byte_count > MAX_ENVELOPE_BYTES { - return Err(too_large("envelope")); - } - } - let mut output = Vec::with_capacity(byte_count); - for (_, _, bytes) in chunks { - output.extend_from_slice(bytes); - } - Ok(output) +/// Whether the roots an envelope carries are an answer to the roots asked for. +/// +/// They usually are the same list. The one licensed difference is the family +/// the snapshot script gathers on its own: asked for one frame that is named +/// for a breakpoint and sits in a Section, it brings that frame's similarly +/// named siblings along, so a request for `mobile` comes back rooted at +/// `mobile`, `tablet` and `desktop`. That is still the target, drawn at its +/// other widths. The script learned to do this and the decoder did not: it +/// read three roots against the one it had asked for, called the very first +/// page a mismatch, and threw the fast path away for a legacy walk of the +/// same three frames at five times the cost — 282 calls for a screen the +/// fast path pages through in a handful. A family is one root asked for, +/// present in the list, with nothing listed twice; anything else is a +/// different target. +fn roots_answer_for(root_ids: &[String], expected_root_ids: &[String]) -> bool { + if root_ids == expected_root_ids { + return true; + } + let [expected] = expected_root_ids else { + return false; + }; + root_ids.contains(expected) && root_ids.iter().collect::>().len() == root_ids.len() } fn validate_envelope( envelope: &Envelope, - descriptor: &EnvelopeDescriptor, target: &FigmaTarget, expected_root_ids: &[String], - utf8_bytes: usize, + page: PageCursor, ) -> Result<(), DevupError> { - if envelope.schema_version != 1 || descriptor.schema_version != 1 { + if envelope.schema_version != 1 + || envelope + .kind + .as_deref() + .is_some_and(|kind| kind != "devupFastSnapshotEnvelope") + { return Err(invalid("schemaVersion")); } let target_root = target @@ -543,42 +339,44 @@ fn validate_envelope( if envelope.source.file_key != target.file_key || envelope.snapshot.file_key != target.file_key || envelope.source.root_id != target_root - || descriptor.root_id != target_root - || envelope.snapshot.root_ids != expected_root_ids + || !roots_answer_for(&envelope.snapshot.root_ids, expected_root_ids) { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes || descriptor.utf8_bytes != utf8_bytes { - return Err(invalid("utf8Bytes")); - } - let mut node_ids = BTreeSet::new(); for node in &envelope.snapshot.nodes { if !node_ids.insert(node.id.as_str()) { return Err(invalid("duplicateNode")); } } + // The root is only guaranteed present on the first page of a paginated + // fetch (BFS traversal always visits it at index 0); later pages cover + // only a later slice of the same subtree. if envelope.integrity.node_count != node_ids.len() - || descriptor.node_count != node_ids.len() - || !expected_root_ids - .iter() - .all(|root_id| node_ids.contains(root_id.as_str())) + || (page.is_first_page + && !expected_root_ids + .iter() + .all(|root_id| node_ids.contains(root_id.as_str()))) { return Err(invalid("nodeCount")); } - for node in &envelope.snapshot.nodes { - for child_id in node.typed_view().child_ids() { - if !node_ids.contains(child_id) { - return Err(invalid("danglingChild")); + // A child referenced by a node in this page may legitimately live in a + // later page while pagination is still in progress. Once the fetch is + // complete (this is the final page), every remaining node has already + // been sent, so full containment is enforced again. + if page.is_final_page { + for node in &envelope.snapshot.nodes { + for child_id in node.typed_view().child_ids() { + if !node_ids.contains(child_id) { + return Err(invalid("danglingChild")); + } } } } let refs = collect_used_resource_refs(std::slice::from_ref(&envelope.snapshot)); if envelope.integrity.variable_ref_count != refs.variable_ids.len() - || descriptor.variable_ref_count != refs.variable_ids.len() || envelope.integrity.style_ref_count != refs.styles.len() - || descriptor.style_ref_count != refs.styles.len() { return Err(invalid("resourceRefCount")); } @@ -588,19 +386,19 @@ fn validate_envelope( fn validate_theme_envelope( envelope: &ThemeEnvelope, - descriptor: &ThemeEnvelopeDescriptor, expected_file_key: &str, - utf8_bytes: usize, ) -> Result<(), DevupError> { - if envelope.schema_version != 1 || descriptor.schema_version != 1 { + if envelope.schema_version != 1 + || envelope + .kind + .as_deref() + .is_some_and(|kind| kind != "devupFastThemeEnvelope") + { return Err(invalid("schemaVersion")); } if envelope.source.file_key != expected_file_key { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes || descriptor.utf8_bytes != utf8_bytes { - return Err(invalid("utf8Bytes")); - } let resources = envelope .resources .as_object() @@ -614,25 +412,17 @@ fn validate_theme_envelope( .ok_or_else(|| invalid("unresolvedShape"))?; validate_theme_count( envelope.integrity.collection_count, - descriptor.collection_count, collections.len(), "collectionCount", )?; validate_theme_count( envelope.integrity.variable_count, - descriptor.variable_count, variables.len(), "variableCount", )?; - validate_theme_count( - envelope.integrity.style_count, - descriptor.style_count, - styles.len(), - "styleCount", - )?; + validate_theme_count(envelope.integrity.style_count, styles.len(), "styleCount")?; validate_theme_count( envelope.integrity.unresolved_count, - descriptor.unresolved_count, unresolved.len(), "unresolvedCount", )?; @@ -657,11 +447,10 @@ fn validate_theme_envelope( fn validate_theme_count( envelope_count: usize, - descriptor_count: usize, observed_count: usize, category: &'static str, ) -> Result<(), DevupError> { - if envelope_count != observed_count || descriptor_count != observed_count { + if envelope_count != observed_count { Err(invalid(category)) } else { Ok(()) @@ -734,21 +523,10 @@ fn resource_ids<'a>( .collect() } -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} - fn invalid(category: &'static str) -> DevupError { DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, - "Figma fast snapshot envelope 검증에 실패했습니다.", + "Figma fast snapshot envelope validation failed.", false, json!({"category": category}), ) @@ -757,7 +535,7 @@ fn invalid(category: &'static str) -> DevupError { fn too_large(category: &'static str) -> DevupError { DevupError::with_details( ErrorCode::DevupFigmaResponseTooLarge, - "Figma fast snapshot envelope가 안전한 크기 제한을 초과했습니다.", + "Figma fast snapshot envelope exceeded the safe size limit.", false, json!({"category": category}), ) diff --git a/crates/devup-mcp-figma/src/errors.rs b/crates/devup-mcp-figma/src/errors.rs index a681a603..62807058 100644 --- a/crates/devup-mcp-figma/src/errors.rs +++ b/crates/devup-mcp-figma/src/errors.rs @@ -6,6 +6,7 @@ pub enum ErrorCode { DevupAuthRequired, DevupAuthCallbackTimeout, DevupAuthStateMismatch, + DevupFigmaCallbackPortInUse, DevupFigmaPermissionDenied, DevupFigmaRateLimited, DevupFigmaDirectUnavailable, @@ -21,6 +22,8 @@ pub enum ErrorCode { DevupCodegenFailed, DevupThemeConflict, DevupCompatCorpusDrift, + DevupInvalidInput, + DevupProjectRootNotFound, } #[derive(Clone, Serialize, Deserialize)] diff --git a/crates/devup-mcp-figma/src/explore.rs b/crates/devup-mcp-figma/src/explore.rs index 4ebb136f..7b8b1f54 100644 --- a/crates/devup-mcp-figma/src/explore.rs +++ b/crates/devup-mcp-figma/src/explore.rs @@ -108,7 +108,7 @@ impl TryFrom<&RawNode> for ExploreNode { .ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "Figma 탐색 projection에 유효한 node bounds가 없습니다.", + "Figma exploration projection has no valid node bounds.", false, ) })?; @@ -260,21 +260,21 @@ pub fn explore_snapshot( if options.limit == 0 || options.limit > 100 { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "탐색 limit은 1 이상 100 이하여야 합니다.", + "Exploration limit must be between 1 and 100.", false, )); } let anchor_id = target.node_id.as_deref().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma 주변 화면 탐색에는 node-id가 필요합니다.", + "Figma nearby-screen exploration requires a node-id.", false, ) })?; let raw_anchor = snapshot.nodes.get(anchor_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma 탐색 projection에서 anchor node를 찾지 못했습니다.", + "anchor node not found in the Figma exploration projection.", false, ) })?; @@ -463,14 +463,14 @@ pub fn collect_section_notes(snapshot: &Snapshot, section_id: &str) -> Result self.descriptor.cursor.max_chunk_bytes || bytes.len() > MAX_LARGE_VALUE_CHUNK_BYTES @@ -145,14 +145,12 @@ impl LargeValueAssembler { || fragment.next_offset > self.descriptor.byte_length || fragment.complete != (fragment.next_offset == self.descriptor.byte_length) { - return Err(invalid( - "large value fragment의 byte 범위가 올바르지 않습니다.", - )); + return Err(invalid("large value fragment byte range is invalid.")); } if let Some(existing) = self.fragments.get(&fragment.offset) { if existing != &bytes { return Err(invalid( - "large value fragment가 같은 offset에서 충돌합니다.", + "large value fragments conflict at the same offset.", )); } return Ok(()); @@ -163,14 +161,25 @@ impl LargeValueAssembler { } pub fn finish(self) -> Result { + let output = self.finish_bytes()?; + serde_json::from_slice(&output) + .map_err(|_| invalid("large value fragment cannot be restored as a JSON value.")) + } + + /// The bytes the fragments assemble to, checked against the announced + /// length and hash but not parsed: an SVG export is text and a PNG + /// export is binary, neither JSON. + pub fn finish_bytes(self) -> Result, DevupError> { if !self.saw_complete { - return Err(invalid("large value fragment의 마지막 범위가 없습니다.")); + return Err(invalid( + "large value fragment for the final range is missing.", + )); } let mut output = Vec::with_capacity(self.descriptor.byte_length); for (offset, bytes) in self.fragments { if offset != output.len() { return Err(invalid( - "large value fragment 범위가 누락되었거나 겹칩니다.", + "large value fragment ranges are missing or overlapping.", )); } output.extend_from_slice(&bytes); @@ -179,11 +188,10 @@ impl LargeValueAssembler { || sha256_hex(&output) != self.descriptor.sha256 { return Err(invalid( - "large value fragment의 길이 또는 hash가 일치하지 않습니다.", + "large value fragment length or hash does not match.", )); } - serde_json::from_slice(&output) - .map_err(|_| invalid("large value fragment를 JSON 값으로 복원할 수 없습니다.")) + Ok(output) } } @@ -196,14 +204,12 @@ pub(crate) fn descriptors_in_chunk( let Some(raw) = value.get("$largeValue") else { continue; }; - let descriptor: LargeValueDescriptor = - serde_json::from_value(raw.clone()).map_err(|_| { - invalid("snapshot의 large value descriptor 형식이 올바르지 않습니다.") - })?; + let descriptor: LargeValueDescriptor = serde_json::from_value(raw.clone()) + .map_err(|_| invalid("snapshot large value descriptor format is invalid."))?; validate_descriptor(&descriptor)?; if descriptor.node_id != node.id || descriptor.field != *field { return Err(invalid( - "snapshot의 large value descriptor 대상이 필드와 다릅니다.", + "snapshot large value descriptor target does not match its field.", )); } descriptors.push(descriptor); @@ -222,7 +228,7 @@ pub(crate) fn large_value_from_result( result: &UpstreamResult, ) -> Result { find_large_value_result(&result.raw) - .ok_or_else(|| invalid("Figma MCP 응답에서 large value fragment를 찾지 못했습니다.")) + .ok_or_else(|| invalid("large value fragment not found in the Figma MCP response.")) } pub(crate) fn replace_descriptor( @@ -240,22 +246,24 @@ pub(crate) fn replace_descriptor( .fields .get_mut(&descriptor.field) .or_else(|| node.extra.get_mut(&descriptor.field)) - .ok_or_else(|| invalid("large value descriptor가 가리키는 필드가 없습니다."))?; + .ok_or_else(|| { + invalid("field referenced by the large value descriptor is missing.") + })?; let observed: LargeValueDescriptor = serde_json::from_value( slot.get("$largeValue") .cloned() - .ok_or_else(|| invalid("large value descriptor marker가 없습니다."))?, + .ok_or_else(|| invalid("large value descriptor marker is missing."))?, ) - .map_err(|_| invalid("large value descriptor marker가 올바르지 않습니다."))?; + .map_err(|_| invalid("large value descriptor marker is invalid."))?; if observed != *descriptor { - return Err(invalid("large value descriptor가 수집 중 변경되었습니다.")); + return Err(invalid("large value descriptor changed during collection.")); } *slot = value; node.field_errors.remove(&descriptor.field); return Ok(()); } } - Err(invalid("large value descriptor의 node를 찾지 못했습니다.")) + Err(invalid("node for the large value descriptor not found.")) } fn validate_descriptor(descriptor: &LargeValueDescriptor) -> Result<(), DevupError> { @@ -272,9 +280,7 @@ fn validate_descriptor(descriptor: &LargeValueDescriptor) -> Result<(), DevupErr || descriptor.cursor.max_chunk_bytes == 0 || descriptor.cursor.max_chunk_bytes > MAX_LARGE_VALUE_CHUNK_BYTES { - return Err(invalid( - "large value descriptor의 범위 또는 hash가 올바르지 않습니다.", - )); + return Err(invalid("large value descriptor range or hash is invalid.")); } Ok(()) } diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 3c0ab8f0..d736795f 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -18,15 +18,17 @@ mod variables; pub use collector::{ CollectedParts, CollectionRequest, CollectionScope, CollectionStats, CollectorSession, - CollectorStep, PlannedCall, ReferencePng, SectionReadOptions, + CollectorStep, PlannedCall, ReferencePng, ScreenFailure, SectionReadOptions, }; pub use credentials::{ - CredentialStore, KeyringCredentialStore, MemoryCredentialStore, StoredAuthorization, + ClientCredentialStore, ClientCredentials, CredentialStore, KeyringClientCredentialStore, + KeyringCredentialStore, MemoryClientCredentialStore, MemoryCredentialStore, + StoredAuthorization, }; pub use envelope::{ - FastSnapshotPayload, FastThemePayload, FastTransportStats, decode_fast_multi_snapshot, - decode_fast_snapshot, decode_fast_theme, + FastSnapshotPayload, FastThemePayload, FastTransportStats, ThemePage, + decode_fast_multi_snapshot, decode_fast_snapshot, decode_fast_theme, }; pub use errors::{DevupError, ErrorCode}; pub use explore::{ @@ -39,7 +41,10 @@ pub use large_values::{ LargeValueReadOptions, LargeValueUnsupported, MAX_LARGE_VALUE_BYTES, MAX_LARGE_VALUE_CHUNK_BYTES, }; -pub use oauth::{AuthStatus, BrowserOpener, OAuthManager, SecretString, SystemBrowser}; +pub use oauth::{ + AuthStatus, BrowserOpener, ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, + OAuthManager, SecretString, SystemBrowser, TokenState, +}; pub use payload::{ CollectedPayload, PayloadCompleteness, PayloadCompletenessReport, PayloadStructure, ResourceAudit, validate_payload_context, @@ -54,12 +59,12 @@ pub use section::{ }; pub use snapshot::{ ChildCountMismatch, CompletenessState, Diagnostic, DiagnosticSeverity, FidelityImpact, - FieldLocation, MissingChild, ParentMismatch, RawNode, Snapshot, SnapshotAudit, SnapshotChunk, - TypedNode, merge_chunks, snapshot_chunk_from_result, + FieldLocation, MissingChild, ParentMismatch, RawNode, SNAPSHOT_CURSOR_ID, Snapshot, + SnapshotAudit, SnapshotChunk, SnapshotCursor, SnapshotCursorError, TypedNode, merge_chunks, + read_snapshot_cursor, snapshot_chunk_from_result, }; pub use source::{ - SelectedSource, SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, - classify_upstream_failure, fallback_allowed, fallback_allowed_for_error, + SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, classify_upstream_failure, upstream_failure_error, }; pub use upstream::{ @@ -70,7 +75,8 @@ pub use url::FigmaTarget; pub use variables::{ResourceBatch, ResourceStyleRef, UnresolvedResource}; mod metadata; pub use assets::{ - AssetFormat, AssetManifest, AssetManifestEntry, AssetRequest, AssetSelection, AssetStatus, - MAX_ASSET_BYTES, asset_export_from_result, discover_asset_manifest, resolve_asset_selections, - validate_asset_requests, + AssetExportOutcome, AssetFormat, AssetManifest, AssetManifestEntry, AssetRequest, + AssetSelection, AssetStatus, MAX_ASSET_BYTES, PNG_EXPORT_FIELD, SVG_EXPORT_FIELD, + asset_export_from_result, discover_asset_manifest, exported_asset_from_bytes, + resolve_asset_selections, source_kind_of, validate_asset_requests, }; diff --git a/crates/devup-mcp-figma/src/metadata.rs b/crates/devup-mcp-figma/src/metadata.rs index 5072ffb2..3a21be67 100644 --- a/crates/devup-mcp-figma/src/metadata.rs +++ b/crates/devup-mcp-figma/src/metadata.rs @@ -1,6 +1,6 @@ use quick_xml::{Reader, XmlVersion, events::Event}; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Value, json}; use crate::{DevupError, ErrorCode, UpstreamResult}; @@ -54,14 +54,52 @@ pub fn metadata_from_result_for_target( }) .or_else(|| find_top_level_pages(&result.raw).map(MetadataResult::TopLevelPages)) .ok_or_else(|| { - DevupError::new( + DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, - "Figma MCP 응답에서 metadata를 찾지 못했습니다.", + "metadata not found in the Figma MCP response.", false, + observed_response_shape(&result.raw), ) }) } +/// Summarises what actually arrived when metadata could not be parsed. +/// +/// This failure is intermittent, and reporting only that metadata was "not +/// found" gave no way to tell an empty response from a relayed error string or +/// an envelope shape the parser does not yet recognise — so every occurrence +/// had to be reproduced live to learn anything. Carrying the observed shape +/// with the error makes a single occurrence diagnosable. +fn observed_response_shape(value: &Value) -> Value { + fn previews(value: &Value, found: &mut Vec) { + if found.len() >= 4 { + return; + } + match value { + Value::Object(object) => object.values().for_each(|child| previews(child, found)), + Value::Array(values) => values.iter().for_each(|child| previews(child, found)), + Value::String(text) if !text.is_empty() => { + let mut preview: String = text.chars().take(200).collect(); + if text.chars().count() > 200 { + preview.push('…'); + } + found.push(preview); + } + _ => {} + } + } + + let mut texts = Vec::new(); + previews(value, &mut texts); + json!({ + "topLevelKeys": match value { + Value::Object(object) => object.keys().cloned().collect::>(), + _ => Vec::new(), + }, + "textPreviews": texts, + }) +} + fn find_top_level_pages(value: &Value) -> Option> { match value { Value::Object(object) => object.values().find_map(find_top_level_pages), @@ -122,13 +160,33 @@ fn find_xml_metadata( Value::Array(values) => values .iter() .find_map(|value| find_xml_metadata(value, expected_file_key, expected_root_id)), - Value::String(text) if text.trim_start().starts_with('<') => { - parse_xml_metadata(text, expected_file_key, expected_root_id) - } + Value::String(text) => xml_slice(text) + .and_then(|xml| parse_xml_metadata(xml, expected_file_key, expected_root_id)), _ => None, } } +/// Extracts the XML region from a `get_metadata` text response. +/// +/// Figma no longer returns bare XML. When the user has the queried node +/// selected in the desktop app, the response is *prepended* with a +/// `Currently selected nodes:` block, and every response is *appended* with +/// an `IMPORTANT: After you call this tool...` instruction footer. Requiring +/// the text to start with `<` therefore made devup-mcp fail with +/// `metadata not found in the Figma MCP response.` for the very common case +/// of "the user is looking at the node they asked about". +/// +/// Slicing between the first `<` and the last `>` keeps the pre-existing +/// bare-XML input working unchanged, tolerates prose on either side, and +/// still yields `None` for text that carries no element at all. Text that +/// merely *contains* angle brackets is not a risk: `parse_xml_metadata` +/// returns `None` unless it finds at least one element with an `id`. +fn xml_slice(text: &str) -> Option<&str> { + let start = text.find('<')?; + let end = text.rfind('>')?; + (end > start).then(|| &text[start..=end]) +} + fn parse_xml_metadata( text: &str, expected_file_key: &str, @@ -260,3 +318,63 @@ fn find_metadata(value: &Value) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + const XML: &str = "\n \ + \n"; + + fn parse(text: &str) -> Option { + find_xml_metadata( + &Value::String(text.to_owned()), + "85CgSws3o5XsLv7aAwWJyS", + Some("3997:48764"), + ) + } + + #[test] + fn bare_xml_still_parses() { + let document = parse(XML).expect("bare XML"); + assert_eq!(document.root_id, "3997:48764"); + assert_eq!(document.nodes.len(), 2); + } + + /// The regression this fix exists for: with the node selected in the + /// Figma desktop app, `get_metadata` prepends a selection block, which + /// used to make the whole legacy metadata path fail. + #[test] + fn a_selected_nodes_preamble_is_tolerated() { + let text = format!("Currently selected nodes:\n- 3997:48764: A : STORY-INTRO\n\n\n\n{XML}"); + let document = parse(&text).expect("preamble must not break parsing"); + assert_eq!(document.root_id, "3997:48764"); + assert_eq!(document.nodes.len(), 2); + } + + #[test] + fn an_instruction_footer_is_tolerated() { + let text = format!( + "{XML}\n\nIMPORTANT: After you call this tool, you MUST call get_design_context \ + if trying to implement the design." + ); + assert_eq!(parse(&text).expect("footer").root_id, "3997:48764"); + } + + #[test] + fn a_preamble_and_a_footer_together_are_tolerated() { + let text = + format!("Currently selected nodes:\n- 3997:48764: A\n\n{XML}\n\nIMPORTANT: do X."); + let document = parse(&text).expect("preamble and footer"); + assert_eq!(document.root_id, "3997:48764"); + assert_eq!(document.nodes.len(), 2); + } + + #[test] + fn prose_without_any_element_is_still_rejected() { + assert!(parse("Currently selected nodes:\n- 3997:48764: A : STORY-INTRO").is_none()); + assert!(parse("no angle brackets here at all").is_none()); + // Angle brackets but no element carrying an `id`. + assert!(parse("a < b and c > d").is_none()); + } +} diff --git a/crates/devup-mcp-figma/src/oauth.rs b/crates/devup-mcp-figma/src/oauth.rs index 171f33ab..2a678c89 100644 --- a/crates/devup-mcp-figma/src/oauth.rs +++ b/crates/devup-mcp-figma/src/oauth.rs @@ -1,4 +1,7 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use rand::Rng; @@ -11,7 +14,11 @@ use tokio::{ }; use url::Url; -use super::{CredentialStore, DevupError, ErrorCode, StoredAuthorization}; +use super::{ + ClientCredentialStore, ClientCredentials, CredentialStore, DevupError, ErrorCode, + MemoryClientCredentialStore, StoredAuthorization, UpstreamFailureContext, + upstream_failure_error, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -20,6 +27,72 @@ pub enum AuthStatus { Disconnected, } +/// Where a resolved [`ClientCredentials`] came from, reported by +/// `devup_figma_auth {"action":"doctor"}` so an agent (or human) can tell +/// *why* a particular client is in play without ever seeing the secret +/// itself. See `README.md`'s "Figma 연결 설정" for the three supported +/// injection paths. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ClientCredentialSource { + CliArg, + Env, + CredentialStore, + #[default] + None, +} + +/// Freshness of the OAuth token in the [`CredentialStore`], independent of +/// whether a [`ClientCredentials`] is configured. `Expired` still means a +/// refresh is possible if a `refresh_token` was stored; it does not by +/// itself make `direct` unavailable (see `AuthStatus`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TokenState { + Valid, + Expired, + Absent, +} + +/// Everything `doctor` needs to describe the `direct` connection path +/// without ever including the client secret or access/refresh tokens +/// themselves — only their provenance and state. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectPathSnapshot { + pub credential_source: ClientCredentialSource, + pub token_state: TokenState, + pub callback_port: Option, + pub callback_port_free: Option, + /// The `client_name` Dynamic Client Registration would send right + /// now. Reported because Figma gates `/register` on this exact + /// string, so a 403 is otherwise indistinguishable from a network + /// fault. Never a secret — see [`OAuthManager::with_client_name`]. + pub client_name: String, +} + +/// The `client_name` devup-mcp sends to Dynamic Client Registration +/// unless the operator overrides it. +/// +/// Figma admits `POST /v1/oauth/mcp/register` only for `client_name` +/// values on its catalog allowlist and rejects everything else with a +/// plain-text `403 Forbidden`. devup-mcp itself is not on that +/// allowlist, so the literal name `devup-mcp` makes the `direct` path +/// unreachable. This default is therefore `Codex` — the host devup-mcp +/// is distributed to be installed into — so a Codex install can complete +/// `login` without extra flags. +/// +/// Two consequences to be aware of, neither of which devup-mcp can +/// resolve on its own: the value is sent verbatim as this client's +/// identity, so Figma attributes the registration and the resulting +/// traffic to Codex rather than to devup-mcp; and the allowlist is +/// Figma's access control, so this default routes around it. The +/// sanctioned path is admission through +/// , after which +/// [`OAuthManager::with_client_name`] should carry your own registered +/// name instead. +pub const DEFAULT_CLIENT_NAME: &str = "Codex"; + pub trait BrowserOpener: Send + Sync { fn open(&self, authorization_url: &str) -> Result<(), DevupError>; } @@ -32,7 +105,7 @@ impl BrowserOpener for SystemBrowser { webbrowser::open(authorization_url).map_err(|_| { DevupError::new( ErrorCode::DevupAuthRequired, - "브라우저를 열지 못했습니다. Figma 인증을 다시 시도하세요.", + "Could not open the browser. Retry Figma authentication.", true, ) })?; @@ -40,10 +113,14 @@ impl BrowserOpener for SystemBrowser { } } -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub struct SecretString(String); impl SecretString { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + pub fn expose(&self) -> &str { &self.0 } @@ -61,6 +138,16 @@ pub struct OAuthManager { store: S, client: reqwest::Client, callback_timeout: Duration, + callback_port: Option, + /// A cli-arg/env-supplied override. Always wins over + /// `client_credential_store` when present; its `ClientCredentialSource` + /// is always `CliArg` or `Env`. + static_client_credentials: Option<(ClientCredentials, ClientCredentialSource)>, + client_credential_store: Arc, + /// The `client_name` sent to Dynamic Client Registration. Defaults to + /// [`DEFAULT_CLIENT_NAME`]; overridable per process because Figma + /// admits `/register` only for allowlisted names. + client_name: String, } impl OAuthManager { @@ -76,6 +163,10 @@ impl OAuthManager { store, client, callback_timeout: Duration::from_secs(180), + callback_port: None, + static_client_credentials: None, + client_credential_store: Arc::new(MemoryClientCredentialStore::default()), + client_name: DEFAULT_CLIENT_NAME.to_owned(), } } @@ -84,6 +175,60 @@ impl OAuthManager { self } + /// Fixes the local OAuth callback listener to a specific port instead + /// of letting the OS assign a free one. Required when a pre-registered + /// client's `redirect_uri` was registered with an exact port. `None` + /// (the default) preserves the pre-existing OS-assigned-port behavior. + pub fn with_callback_port(mut self, port: Option) -> Self { + self.callback_port = port; + self + } + + /// Overrides the `client_name` sent to Dynamic Client Registration + /// (default [`DEFAULT_CLIENT_NAME`], i.e. `Codex`). + /// + /// Set this to the name your own client was admitted under through + /// ; doing so stops attributing + /// this client's registration and traffic to Codex, and is the only + /// configuration that does not depend on Figma's allowlist gate + /// staying permissive for a name that is not yours. + /// + /// The value is transmitted verbatim to the upstream authorization + /// server as this client's identity, so whichever name is active is + /// the identity Figma records. [`Self::direct_path_snapshot`] always + /// reports the value in play, and never a secret. + /// + /// An empty or whitespace-only name is ignored, keeping the default. + pub fn with_client_name(mut self, client_name: impl Into) -> Self { + let client_name = client_name.into(); + if !client_name.trim().is_empty() { + self.client_name = client_name; + } + self + } + + /// Installs a cli-arg/env-supplied client credential override. This + /// always takes priority over anything in `client_credential_store`, + /// and causes `login` to skip Dynamic Client Registration entirely. + pub fn with_static_client_credentials( + mut self, + credentials: ClientCredentials, + source: ClientCredentialSource, + ) -> Self { + self.static_client_credentials = Some((credentials, source)); + self + } + + /// Installs the backend used to persist client credentials configured + /// via [`Self::configure_client_credentials`]. Defaults to an + /// in-process-only store so `configure` still works without explicit + /// wiring in tests; production code should pass a + /// `KeyringClientCredentialStore`. + pub fn with_client_credential_store(mut self, store: Arc) -> Self { + self.client_credential_store = store; + self + } + pub async fn status(&self) -> Result { Ok(if self.store.load().await?.is_some() { AuthStatus::Connected @@ -96,39 +241,128 @@ impl OAuthManager { self.store.clear().await } + /// Persists a user-supplied client credential (from the + /// `devup_figma_auth {"action":"configure"}` tool) so subsequent + /// `login` calls skip Dynamic Client Registration, even across process + /// restarts, without requiring `--figma-client-id`/`DEVUP_FIGMA_CLIENT_ID` + /// on every launch. + pub async fn configure_client_credentials( + &self, + client_id: String, + client_secret: Option, + ) -> Result<(), DevupError> { + let credentials = ClientCredentials { + client_id, + client_secret: client_secret.map(SecretString), + }; + self.client_credential_store.save(&credentials).await + } + + /// Resolves the client credential that `login`/`refresh` should use, + /// in priority order: cli-arg/env override, then the persisted + /// client-credential store, then `None` (Dynamic Client Registration). + async fn resolve_client_credentials( + &self, + ) -> Result, DevupError> { + if let Some((credentials, source)) = &self.static_client_credentials { + return Ok(Some((credentials.clone(), *source))); + } + if let Some(credentials) = self.client_credential_store.load().await? { + return Ok(Some((credentials, ClientCredentialSource::CredentialStore))); + } + Ok(None) + } + + async fn token_state(&self) -> Result { + Ok(match self.store.load().await? { + None => TokenState::Absent, + Some(authorization) => match authorization.expires_at { + Some(expires_at) if expires_at <= now() => TokenState::Expired, + _ => TokenState::Valid, + }, + }) + } + + /// Builds the `paths.direct` snapshot for `devup_figma_auth + /// {"action":"doctor"}`: which credential is in play (never the secret + /// itself), whether the stored token is still fresh, and — when a + /// fixed callback port is configured — whether it is actually free + /// right now (measured, not assumed). + pub async fn direct_path_snapshot(&self) -> Result { + let credential_source = self + .resolve_client_credentials() + .await? + .map(|(_, source)| source) + .unwrap_or_default(); + let token_state = self.token_state().await?; + let callback_port_free = match self.callback_port { + Some(port) => Some(probe_callback_port_free(port).await), + None => None, + }; + Ok(DirectPathSnapshot { + credential_source, + token_state, + callback_port: self.callback_port, + callback_port_free, + client_name: self.client_name.clone(), + }) + } + pub async fn login( &self, opener: &dyn BrowserOpener, ) -> Result { let metadata = self.discover().await?; - let listener = TcpListener::bind("127.0.0.1:0") - .await - .map_err(callback_error)?; + let listener = bind_callback_listener(self.callback_port).await?; let redirect_uri = format!( "http://127.0.0.1:{}/callback", listener.local_addr().map_err(callback_error)?.port() ); - let registration: RegistrationResponse = self - .client - .post(&metadata.registration_endpoint) - .json(&serde_json::json!({ - "client_name": "devup-mcp", - "redirect_uris": [redirect_uri], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - "application_type": "native", - "scope": "mcp:connect" - })) - .send() - .await - .map_err(auth_network_error)? - .error_for_status() - .map_err(auth_network_error)? - .json() - .await - .map_err(auth_network_error)?; + // A resolved client credential (cli-arg/env override or a + // previously `configure`d value) always skips Dynamic Client + // Registration. Otherwise devup-mcp registers under + // `self.client_name` — `DEFAULT_CLIENT_NAME` (`Codex`) unless + // `--figma-client-name`/`DEVUP_FIGMA_CLIENT_NAME` supplied the + // name this deployment was actually admitted under — and Figma's + // allowlist decides the outcome. See `DEFAULT_CLIENT_NAME` for + // what that default does and does not license. + let resolved = self.resolve_client_credentials().await?; + let (client_id, client_secret) = match resolved { + Some((credentials, _source)) => (credentials.client_id, credentials.client_secret), + None => { + let response = self + .client + .post(&metadata.registration_endpoint) + .json(&serde_json::json!({ + "client_name": self.client_name.as_str(), + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "native", + "scope": "mcp:connect" + })) + .send() + .await + .map_err(auth_network_error)?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(upstream_failure_error( + UpstreamFailureContext::RegisterClient, + Some(status.as_u16()), + &body, + )); + } + let registration: RegistrationResponse = + response.json().await.map_err(auth_network_error)?; + ( + registration.client_id, + registration.client_secret.map(SecretString), + ) + } + }; let state = random_urlsafe(32); let verifier = random_urlsafe(64); @@ -138,7 +372,7 @@ impl OAuthManager { authorization_url .query_pairs_mut() .append_pair("response_type", "code") - .append_pair("client_id", ®istration.client_id) + .append_pair("client_id", &client_id) .append_pair("redirect_uri", &redirect_uri) .append_pair("scope", "mcp:connect") .append_pair("state", &state) @@ -148,17 +382,21 @@ impl OAuthManager { opener.open(authorization_url.as_str())?; let callback = receive_callback(listener, &state, self.callback_timeout).await?; + let mut form: Vec<(&str, &str)> = vec![ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", callback.code.as_str()), + ("redirect_uri", redirect_uri.as_str()), + ("code_verifier", verifier.as_str()), + ("resource", metadata.resource.as_str()), + ]; + if let Some(secret) = client_secret.as_ref() { + form.push(("client_secret", secret.expose())); + } let token: TokenResponse = self .client .post(&metadata.token_endpoint) - .form(&[ - ("grant_type", "authorization_code"), - ("client_id", registration.client_id.as_str()), - ("code", callback.code.as_str()), - ("redirect_uri", redirect_uri.as_str()), - ("code_verifier", verifier.as_str()), - ("resource", metadata.resource.as_str()), - ]) + .form(&form) .send() .await .map_err(auth_network_error)? @@ -169,7 +407,8 @@ impl OAuthManager { .map_err(auth_network_error)?; let authorization = StoredAuthorization { - client_id: registration.client_id, + client_id, + client_secret, access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token @@ -202,15 +441,28 @@ impl OAuthManager { .refresh_token .clone() .ok_or_else(auth_required)?; + let resolved = self.resolve_client_credentials().await?; + let mut form: Vec<(&str, &str)> = vec![ + ("grant_type", "refresh_token"), + ("client_id", authorization.client_id.as_str()), + ("refresh_token", refresh_token.as_str()), + ("resource", authorization.resource.as_str()), + ]; + // The secret that belongs to *this* authorization wins: when the + // client was registered through DCR the operator has no configured + // credential at all, and dropping it here would fail the refresh with + // the same bare 400 the initial exchange used to. + if let Some(secret) = authorization.client_secret.as_ref().or_else(|| { + resolved + .as_ref() + .and_then(|(credentials, _source)| credentials.client_secret.as_ref()) + }) { + form.push(("client_secret", secret.expose())); + } let response: TokenResponse = self .client .post(&authorization.token_endpoint) - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", authorization.client_id.as_str()), - ("refresh_token", refresh_token.as_str()), - ("resource", authorization.resource.as_str()), - ]) + .form(&form) .send() .await .map_err(auth_network_error)? @@ -312,6 +564,16 @@ struct OAuthMetadata { #[derive(Debug, Deserialize)] struct RegistrationResponse { client_id: String, + /// Figma's authorization server advertises only `client_secret_basic` + /// and `client_secret_post`, so its Dynamic Client Registration response + /// issues a secret and every subsequent token/refresh request must send + /// it. Discarding this field made the authorization-code exchange fail + /// with a bare `400` from `/v1/oauth/token` after an otherwise fully + /// successful registration and browser consent. `Option` because an + /// authorization server that genuinely supports public clients + /// (`token_endpoint_auth_method: none`) omits it. + #[serde(default)] + client_secret: Option, } #[derive(Debug, Deserialize)] @@ -336,7 +598,7 @@ async fn receive_callback( .map_err(|_| { DevupError::new( ErrorCode::DevupAuthCallbackTimeout, - "Figma 인증 응답 시간이 초과되었습니다.", + "Figma authentication response timed out.", true, ) })? @@ -364,7 +626,7 @@ async fn receive_callback( let _ = write_callback_response(&mut stream, false).await; return Err(DevupError::new( ErrorCode::DevupAuthStateMismatch, - "Figma 인증 state 검증에 실패했습니다.", + "Figma authentication state validation failed.", false, )); } @@ -378,9 +640,9 @@ async fn write_callback_response( success: bool, ) -> Result<(), DevupError> { let body = if success { - "Figma 인증이 완료되었습니다. 이 창을 닫아도 됩니다." + "Figma authentication is complete. You can close this window." } else { - "Figma 인증을 확인할 수 없습니다. 다시 시도하세요." + "Figma authentication could not be verified. Try again." }; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", @@ -393,6 +655,46 @@ async fn write_callback_response( .map_err(callback_error) } +/// Binds the local OAuth callback listener. When `port` is `None`, keeps +/// the pre-existing behavior of letting the OS assign a free ephemeral +/// port (`0`). When `port` is `Some`, the bind attempt itself is the +/// availability check: a fixed port that is already in use fails +/// immediately with [`callback_port_in_use_error`] instead of silently +/// waiting — binding is not retried and no listener that never receives a +/// connection is created. +async fn bind_callback_listener(port: Option) -> Result { + let requested_port = port.unwrap_or(0); + TcpListener::bind(("127.0.0.1", requested_port)) + .await + .map_err(|error| match port { + Some(configured_port) => callback_port_in_use_error(configured_port, error), + None => callback_error(error), + }) +} + +/// Best-effort probe for `doctor`: attempts to bind `port` and immediately +/// releases it. `true` means the port was free at the moment of the probe +/// (not a guarantee it stays free); `false` means something is already +/// listening there. Never blocks waiting for a connection. +pub async fn probe_callback_port_free(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).await.is_ok() +} + +fn callback_port_in_use_error(port: u16, _error: std::io::Error) -> DevupError { + DevupError::with_details( + ErrorCode::DevupFigmaCallbackPortInUse, + format!( + "The configured Figma auth callback port {port} is already in use by another \ + process. If the OS or security software holds this port, the browser looks like \ + the redirect succeeded, but the request is delivered to that other process instead \ + of devup-mcp, so authentication never completes. Stop the process holding the port, \ + or pick a different port with --figma-callback-port." + ), + false, + serde_json::json!({ "port": port }), + ) +} + fn protected_resource_url(endpoint: &Url) -> Url { let mut url = endpoint.clone(); url.set_query(None); @@ -438,7 +740,7 @@ fn now() -> u64 { fn auth_required() -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "Figma 인증이 필요합니다.", + "Figma authentication is required.", false, ) } @@ -446,23 +748,63 @@ fn auth_required() -> DevupError { fn invalid_metadata() -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "Figma OAuth 서버 정보를 검증할 수 없습니다.", + "Cannot validate the Figma OAuth server metadata.", false, ) } -fn auth_network_error(_error: reqwest::Error) -> DevupError { - DevupError::new( +/// Classifies a transport failure against the Figma OAuth server. +/// +/// The cause used to be discarded outright, which made every failure — DNS, +/// a TLS trust failure behind a corporate proxy, a timeout, a malformed +/// metadata document — surface as the same opaque sentence with +/// `details: null`, leaving no way to tell them apart. The details below are +/// derived from the error itself and its source chain; the URL is reduced to +/// scheme/host/path so a query string can never carry an authorization code +/// or token into a log. +fn auth_network_error(error: reqwest::Error) -> DevupError { + let kind = if error.is_connect() { + "connect" + } else if error.is_timeout() { + "timeout" + } else if error.is_decode() { + "decode" + } else if error.is_status() { + "status" + } else if error.is_body() { + "body" + } else if error.is_redirect() { + "redirect" + } else if error.is_request() { + "request" + } else { + "unknown" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(&error); + while let Some(current) = source { + causes.push(current.to_string()); + source = current.source(); + } + DevupError::with_details( ErrorCode::DevupAuthRequired, - "Figma OAuth 서버와 통신하지 못했습니다.", + "Failed to communicate with the Figma OAuth server.", true, + serde_json::json!({ + "kind": kind, + "status": error.status().map(|status| status.as_u16()), + "url": error.url().map(|url| { + format!("{}://{}{}", url.scheme(), url.host_str().unwrap_or(""), url.path()) + }), + "causes": causes, + }), ) } fn callback_error(_error: std::io::Error) -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "로컬 Figma 인증 callback을 처리하지 못했습니다.", + "Failed to handle the local Figma authentication callback.", true, ) } diff --git a/crates/devup-mcp-figma/src/payload.rs b/crates/devup-mcp-figma/src/payload.rs index f7ba2a49..85208d6f 100644 --- a/crates/devup-mcp-figma/src/payload.rs +++ b/crates/devup-mcp-figma/src/payload.rs @@ -34,6 +34,8 @@ pub struct CollectedPayload { pub assets: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub reference_png: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failures: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -77,6 +79,7 @@ impl CollectedPayload { CompletenessState::Failed } else if snapshot.state == CompletenessState::Partial || resources.state == CompletenessState::Partial + || !self.failures.is_empty() { CompletenessState::Partial } else { @@ -128,6 +131,7 @@ impl TryFrom for CollectedPayload { stats: parts.stats, assets: parts.assets, reference_png: parts.reference_png, + failures: parts.failures, }) } } @@ -145,7 +149,7 @@ pub fn validate_payload_context( { return Err(DevupError::new( crate::ErrorCode::DevupFigmaHandoffInvalid, - "Figma payload가 요청한 파일 또는 node와 일치하지 않습니다.", + "Figma payload does not match the requested file or node.", false, )); } diff --git a/crates/devup-mcp-figma/src/plugin_api_manifest.json b/crates/devup-mcp-figma/src/plugin_api_manifest.json index 0bbbd621..0f61ced9 100644 --- a/crates/devup-mcp-figma/src/plugin_api_manifest.json +++ b/crates/devup-mcp-figma/src/plugin_api_manifest.json @@ -1,25 +1,16 @@ [ - "absoluteBoundingBox", "absoluteRenderBounds", "annotations", "arcData", "attachedConnectors", - "authorVisible", "backgrounds", "backgroundStyleId", "blendMode", "bottomLeftRadius", - "bottomRightRadius", "boundVariables", "characters", "clipsContent", "componentDescription", - "componentProperties", "componentPropertyDefinitions", "componentPropertyReferences", "componentSetId", "componentSetProperties", - "constraints", "cornerRadius", "cornerSmoothing", "counterAxisAlignContent", "counterAxisAlignItems", - "counterAxisSizingMode", "dashPattern", "description", "detachedInfo", "devStatus", "documentationLinks", "effects", - "effectStyleId", "expanded", "explicitVariableModes", "exportSettings", "exposedInstances", "fills", "fillStyleId", - "fontName", "fontSize", "gridColumnAnchorIndex", "gridColumnCount", "gridColumnGap", "gridColumnSpan", - "gridRowAnchorIndex", "gridRowCount", "gridRowGap", "gridRowSpan", "gridStyleId", "guides", "height", - "hyperlink", "inferredAutoLayout", "isAsset", "isExposedInstance", "isMask", "isMaskOutline", "itemReverseZIndex", - "itemSpacing", "layoutAlign", "layoutGrids", "layoutGrow", "layoutMode", "layoutPositioning", - "layoutSizingHorizontal", "layoutSizingVertical", "layoutWrap", "letterSpacing", "lineHeight", - "locked", "mainAxisAlignItems", "mainAxisSizingMode", "maskType", "maxHeight", "maxWidth", "measurements", - "minHeight", "minWidth", "name", "numberOfFixedChildren", "opacity", "overlayBackground", - "overlayBackgroundInteraction", "overlayPositionType", "overflowDirection", "paddingBottom", "paddingLeft", "paddingRight", - "paddingTop", "paragraphIndent", "paragraphSpacing", "paragraphSpacingMode", "pluginData", "primaryAxisAlignItems", "reactions", - "relativeTransform", "remote", "removed", "resizeHandlePlacement", "resolvedVariableModes", "rotation", - "scrollBehavior", "sharedPluginData", "strokes", "strokeAlign", "strokeBottomWeight", "strokeCap", - "strokeJoin", "strokeLeftWeight", "strokeMiterLimit", "strokeRightWeight", "strokeStyleId", - "strokeTopWeight", "strokeWeight", "stuckNodes", "targetAspectRatio", "textAlignHorizontal", - "textAlignVertical", "textAutoResize", "textCase", "textDecoration", "textStyleId", "topLeftRadius", - "topRightRadius", "triggeredInteractions", "truncation", "variantProperties", "vectorNetwork", "visible", - "width", "x", "y" + "absoluteBoundingBox", "absoluteRenderBounds", "arcData", "backgroundStyleId", "blendMode", "bottomLeftRadius", + "bottomRightRadius", "boundVariables", "characters", "clipsContent", "componentProperties", "componentPropertyDefinitions", + "componentPropertyReferences", "constraints", "cornerRadius", "counterAxisAlignItems", "dashPattern", "defaultVariant", + "effectStyleId", "effects", "fillStyleId", "fills", "fontName", "fontSize", + "gridColumnAnchorIndex", "gridColumnCount", "gridColumnGap", "gridColumnSizes", "gridColumnSpan", "gridRowAnchorIndex", + "gridRowCount", "gridRowGap", "gridRowSizes", "gridRowSpan", "gridStyleId", "height", + "inferredAutoLayout", "isAsset", "isMask", "itemSpacing", "layoutGrow", "layoutMode", + "layoutPositioning", "layoutSizingHorizontal", "layoutSizingVertical", "letterSpacing", "lineHeight", "maxHeight", + "maxLines", "maxWidth", "minHeight", "minWidth", "name", "opacity", + "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "primaryAxisAlignItems", "reactions", + "rotation", "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokeStyleId", + "strokeTopWeight", "strokeWeight", "strokes", "targetAspectRatio", "textAlignHorizontal", "textAlignVertical", + "textAutoResize", "textCase", "textDecoration", "textStyleId", "textTruncation", "topLeftRadius", + "topRightRadius", "variantProperties", "visible", "width", "x", "y" ] diff --git a/crates/devup-mcp-figma/src/scripts/assets.js b/crates/devup-mcp-figma/src/scripts/assets.js index 11ef7302..12757f8e 100644 --- a/crates/devup-mcp-figma/src/scripts/assets.js +++ b/crates/devup-mcp-figma/src/scripts/assets.js @@ -41,16 +41,59 @@ try { return failed("DEVUP_ASSET_FORMAT_UNSUPPORTED"); } const scale = Math.min(4, Math.max(1, Math.floor(Number(options.scale) || 1))); - const settings = { format }; + // SVG is exported as a string and carried back inline. Figma's remote MCP + // does not return a written `.svg` as an attachment the way it does a PNG, + // so writing the file alone left the caller holding a descriptor and no + // bytes at all, and every SVG request failed. SVG is text and small, so an + // inline copy is bounded well under the text-response limit; anything + // larger is reported rather than silently truncated. + const inlineSvg = format === "SVG"; + const settings = { format: inlineSvg ? "SVG_STRING" : format }; if (format === "PNG" || format === "JPG") { settings.constraint = { type: "SCALE", value: scale }; } const exported = await node.exportAsync(settings); - const bytes = exported instanceof Uint8Array ? exported : new Uint8Array(exported); + const svgText = inlineSvg && typeof exported === "string" ? exported : null; + const bytes = + svgText === null + ? exported instanceof Uint8Array + ? exported + : new Uint8Array(exported) + : devupUtf8Encode(svgText); if (bytes.length === 0 || bytes.length > 8 * 1024 * 1024) { return failed("DEVUP_ASSET_RESPONSE_TOO_LARGE"); } const sha256 = devupSha256(bytes); + // A PNG past what one attachment carries is not written here either. + // Figma's remote MCP returns a written PNG as an attachment only up to + // about a megabyte once base64-encoded: a 665 KB photograph came back, a + // 950 KB one was written, reported exported, and never arrived - the + // devup-ui landing page's hero. Past 768 KiB, which is exactly one MiB + // encoded, it is announced and read back in fragments like a large SVG. + const pngTooLargeToAttach = format === "PNG" && bytes.length > 768 * 1024; + if ((svgText !== null && bytes.length > 12 * 1024) || pngTooLargeToAttach) { + // An SVG past what one text response holds is not written here at all: + // it is announced with its length and hash, and read back in fragments + // through the large-value script, which re-exports it and slices — the + // same transport a large node field takes. An illustration of fifty + // vectors is 125 KB; before this it simply failed. + return { + kind: "devupAssetExport", + fileKey: figma.fileKey || "", + version: options.version, + assetId: options.assetId, + nodeId: options.nodeId, + field: options.field, + imageHash: options.imageHash, + format: options.format, + scale, + status: "chunked", + byteLength: bytes.length, + sha256, + cursor: { nextOffset: 0, maxChunkBytes: DEVUP_LARGE_VALUE_CHUNK_BYTES }, + errorCode: null, + }; + } figma.io.write(`devup-asset-${options.assetId.replace(/[^A-Za-z0-9_-]/g, "_")}.${String(options.format).toLowerCase()}`, bytes); return { kind: "devupAssetExport", @@ -65,6 +108,10 @@ try { status: "exported", byteLength: bytes.length, sha256, + // Present only for SVG. `mimeType` is what lets the Rust side recognise + // this as the payload rather than as ordinary descriptor prose. + mimeType: svgText === null ? null : "image/svg+xml", + text: svgText, errorCode: null, }; } catch (_) { diff --git a/crates/devup-mcp-figma/src/scripts/explore.js b/crates/devup-mcp-figma/src/scripts/explore.js index fb14312e..23c2d8b1 100644 --- a/crates/devup-mcp-figma/src/scripts/explore.js +++ b/crates/devup-mcp-figma/src/scripts/explore.js @@ -194,7 +194,10 @@ const compact = [...included.values()] childCount: "children" in node ? node.children.length : 0, textPreview: textPreview(node), pageChildIndex: pageChildIndex >= 0 ? pageChildIndex : null, - visible: node.visible !== false, + // A page or the document itself has no `visible`, and Figma throws on + // reading a property a node does not have rather than returning + // undefined — so exploring from a page id failed outright. + visible: !("visible" in node) || node.visible !== false, breadcrumb: breadcrumb(node), }, extra: {}, diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 23f87520..6ad79335 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -1,36 +1,199 @@ -"__DEVUP_SECTION_INDEX_PROBE__"; - const requestedRootIds = "__DEVUP_ROOT_IDS__"; if (!Array.isArray(requestedRootIds) || requestedRootIds.length === 0) { throw new Error("DEVUP_ROOTS_INVALID"); } const roots = await Promise.all(requestedRootIds.map((id) => figma.getNodeByIdAsync(id))); if (roots.some((root) => !root)) throw new Error("DEVUP_NODE_NOT_FOUND"); +if (roots.length === 1 && roots[0].type === "SECTION") { + throw new Error("DEVUP_TARGET_IS_SECTION"); +} + +// A screen drawn at three widths is three sibling frames in a Section, named +// for the width they are. Converting one of them alone can only describe that +// width, and the caller wanted the screen — so when the target is one of those +// frames, its siblings come along and the conversion can say how the screen +// changes rather than how it looks at one size. +// +// Narrow on purpose: the target must itself be named for a breakpoint, and only +// siblings that are. A Section is also how a file of unrelated cases is grouped, +// and pulling every neighbour in there would collect a catalogue to convert one +// square. +// +// The family keeps the Section's own order. It is not decoration: where one +// width does not draw a node, the plugin gives that width a hidden copy of the +// node from the first width that does, first in this order, and every value of +// the copy shows in the responsive array. Sorting the family by name here put +// tablet's values where the reference has desktop's. +const BREAKPOINT_NAMES = ["mobile", "tablet", "desktop"]; +const breakpointRank = (node) => + BREAKPOINT_NAMES.indexOf(String(node.name || "").trim().toLowerCase()); +if (roots.length === 1 && breakpointRank(roots[0]) >= 0) { + const parent = roots[0].parent; + if (parent && parent.type === "SECTION" && "children" in parent) { + const family = parent.children + .filter((child) => child.id === roots[0].id || breakpointRank(child) >= 0) + .filter((child) => child.visible !== false); + if (family.length > 1) { + roots.length = 0; + roots.push(...family); + } + } +} + +// A timed Smart Animate is a chain of frames: the target, after a timeout, +// becomes its destination, which after its own timeout becomes the next, and +// so on, often back to the start. The plugin reads those frames as it goes, +// with `getNodeByIdAsync`, and turns what changes between them into CSS +// keyframes. Those frames are top-level siblings the target's subtree does +// not contain, so they are gathered here as extra roots — with the same +// narrowness as the family above: only for a single requested root, so a +// multi-root request keeps its exact root list. Every node in the subtree is +// looked at, not only the root, because the animated thing is usually inside +// the screen; and every chain member's own chain is followed, until a frame +// repeats. +if (requestedRootIds.length === 1) { + const chainRootIds = []; + const seen = new Set(roots.map((root) => root.id)); + const pending = []; + const collectDestinations = (node) => { + if (!("reactions" in node) || !Array.isArray(node.reactions)) return; + for (const reaction of node.reactions) { + if (!reaction || !reaction.trigger || reaction.trigger.type !== "AFTER_TIMEOUT") continue; + for (const action of reaction.actions || []) { + if ( + action && + action.type === "NODE" && + action.transition && + action.transition.type === "SMART_ANIMATE" && + typeof action.destinationId === "string" && + !seen.has(action.destinationId) + ) { + seen.add(action.destinationId); + pending.push(action.destinationId); + } + } + } + if ("children" in node) for (const child of node.children) collectDestinations(child); + }; + for (const root of roots) collectDestinations(root); + while (pending.length > 0) { + const id = pending.shift(); + const node = await figma.getNodeByIdAsync(id); + if (!node || node.type === "DOCUMENT" || node.type === "PAGE") continue; + chainRootIds.push(node); + collectDestinations(node); + } + roots.push(...chainRootIds); +} const envelopeRootId = "__DEVUP_NODE_ID__"; const manifest = "__DEVUP_PLUGIN_API_MANIFEST__"; -const manifestSet = new Set(manifest); const textSegmentManifest = "__DEVUP_TEXT_SEGMENT_MANIFEST__"; -const skipped = new Set(["id", "type", "parent", "children"]); -const MAX_ENVELOPE_BYTES = 8 * 1024 * 1024; -const MAX_ENVELOPE_CHUNK_BYTES = 512 * 1024; +const pageOptions = "__DEVUP_SNAPSHOT__"; +const offset = Math.max(0, Math.floor(Number(pageOptions.offset) || 0)); +// Upper bound for one round's node payload, before the resources block and +// the envelope around it. The ceiling that matters is on the whole text +// result: the Figma MCP cuts one at 20,480 UTF-8 bytes and appends +// "// truncated to 20kb". Measured on the official server, 2026-09-07: a +// 20,480-character ASCII string came back whole and a 20,703-character one +// was cut at 20,480; a Hangul-heavy value of 12,003 characters and 24,003 +// bytes was cut at byte 20,480, mid-character, so the count is bytes and not +// characters; and 5,816 quote characters in a 20,357-character JSON array all +// survived, so JSON escaping is not counted either. The script measures +// exactly what is cut - `utf8ByteLength(JSON.stringify(envelope))` - and +// `integrity.utf8Bytes` matched the text that arrived on every one of 488 +// banked pages, so a 1 KiB margin under the cut is enough. There is no PNG +// fallback any more; a page has to survive as text. +const maxPayloadBytes = Math.min( + 18000, + Math.max(4096, Math.floor(Number(pageOptions.maxPayloadBytes) || 15000)), +); +const MAX_TEXT_ENVELOPE_BYTES = 19 * 1024; -function propertyNames(value) { - const names = new Set(); - let current = value; - while (current && current !== Object.prototype) { - for (const name of Object.getOwnPropertyNames(current)) names.add(name); - current = Object.getPrototypeOf(current); - } - for (const name of manifest) { - try { - if (name in value) names.add(name); - } catch (_) {} - } - return [...names].sort(); +// A field whose value equals its default carries no information the converter +// can't recover from the key being absent, so it is dropped from the envelope. +// Which fields qualify is NOT a judgement call: it is proven for every rule +// below by `devup-mcp-devup-ui/tests/default_omission_golden.rs`, which +// replays this exact omission over ten real screens (1,500+ nodes) and +// requires the generated TSX to stay byte-identical. Keep the two tables in +// sync with that test. + +// Figma reports an unbound style as `""`, and both readers of these fields +// already treat `""` and "absent" the same: `resources.rs::is_resource_id` +// rejects empty IDs, and `codegen/text.rs` looks the ID up in a token map +// where an empty key can never match. +const STYLE_ID_FIELDS = new Set([ + "backgroundStyleId", + "effectStyleId", + "fillStyleId", + "gridStyleId", + "strokeStyleId", + "textStyleId", +]); + +// `codegen/layout.rs` compares `view.value("maxWidth") != Some(&Value::Null)`, +// so for these two a present-null and an absent key take opposite branches. +// Their null must survive. +const NULL_SENSITIVE_FIELDS = new Set(["maxWidth", "maxHeight"]); + +// Deliberately absent from this table, each because the converter branches on +// the field's *presence* rather than its value: `opacity` (hover-variant +// detection), `visible` (component registration snapshot), `layoutPositioning` +// (compared against "AUTO"), and the per-corner radii / per-side stroke +// weights (read as a group by the shorthand builders). +const SCALAR_DEFAULTS = new Map([ + ["rotation", 0], + ["cornerRadius", 0], + ["isAsset", false], + ["isMask", false], + ["clipsContent", false], + ["blendMode", "PASS_THROUGH"], + ["strokeAlign", "INSIDE"], + ["textCase", "ORIGINAL"], + ["textDecoration", "NONE"], + ["textAlignHorizontal", "LEFT"], + ["textAlignVertical", "TOP"], + ["counterAxisAlignItems", "MIN"], + ["primaryAxisAlignItems", "MIN"], + ["gridColumnCount", 0], + ["gridRowCount", 0], + ["gridColumnGap", 0], + ["gridRowGap", 0], + ["gridColumnAnchorIndex", -1], + ["gridRowAnchorIndex", -1], + // A grid child spans one track unless it says otherwise. + ["gridColumnSpan", 1], + ["gridRowSpan", 1], +]); + +// Keys a styled text segment carries that the TEXT node itself does not, so +// they must survive even when the node has a single segment. +const SEGMENT_ONLY_KEYS = new Set([ + "start", + "end", + "characters", + "fontWeight", + "textStyleId", + "fillStyleId", + "listOptions", + "indentation", + "hyperlink", +]); + +function isOmittableDefault(value, name) { + if (value === null) return !NULL_SENSITIVE_FIELDS.has(name); + if (Array.isArray(value)) return value.length === 0; + if (typeof value === "object") return Object.keys(value).length === 0; + if (value === "" && STYLE_ID_FIELDS.has(name)) return true; + return SCALAR_DEFAULTS.has(name) && SCALAR_DEFAULTS.get(name) === value; } -function serialize(value, seen = new WeakSet(), depth = 0) { +// One serializer for both node fields and variable/style resources. Resources +// need the prototype chain walked (their data lives on accessors, not own +// keys) and a few structural keys skipped; node fields never do, because the +// manifest already names every property worth reading. +const RESOURCE_SKIPPED_KEYS = new Set(["parent", "children", "consumers"]); +function serialize(value, resource = false, seen = new WeakSet(), depth = 0) { if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value; if (typeof value === "undefined") return { $undefined: true }; if (typeof value === "bigint") return { $bigint: value.toString() }; @@ -44,20 +207,36 @@ function serialize(value, seen = new WeakSet(), depth = 0) { ) { return { $nodeId: value.id, $nodeType: value.type }; } - if (Array.isArray(value)) return value.map((item) => serialize(item, seen, depth + 1)); + if (Array.isArray(value)) return value.map((item) => serialize(item, resource, seen, depth + 1)); if (ArrayBuffer.isView(value)) { return { $binary: value.constructor.name, byteLength: value.byteLength }; } if (value instanceof ArrayBuffer) return { $binary: "ArrayBuffer", byteLength: value.byteLength }; if (seen.has(value)) return { $circular: true }; seen.add(value); + + let keys; + if (resource) { + const names = new Set(Object.keys(value)); + let current = value; + while (current && current !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(current)) names.add(name); + current = Object.getPrototypeOf(current); + } + keys = [...names].sort().filter((name) => !name.startsWith("_") && !RESOURCE_SKIPPED_KEYS.has(name)); + } else { + keys = Object.keys(value).sort(); + } + const result = {}; - for (const key of Object.keys(value).sort()) { + for (const key of keys) { try { - const serialized = serialize(value[key], seen, depth + 1); + const serialized = serialize(value[key], resource, seen, depth + 1); if (!(serialized && serialized.$unsupported === "function")) result[key] = serialized; } catch (error) { - result[key] = { $error: String(error && error.message ? error.message : error) }; + result[key] = resource + ? { $error: "unavailable" } + : { $error: String(error && error.message ? error.message : error) }; } } seen.delete(value); @@ -66,30 +245,75 @@ function serialize(value, seen = new WeakSet(), depth = 0) { function snapshotNode(node) { const fields = {}; - const extra = {}; const fieldErrors = {}; - fields.parentId = node.parent ? node.parent.id : null; - fields.childrenIds = "children" in node ? node.children.map((child) => child.id) : []; + if (node.parent) fields.parentId = node.parent.id; + // Only a root needs this. Its parent lies outside the collected subtree, so + // the id alone says nothing, and the parent's type is what decides whether + // the root's width is a real constraint or merely the canvas the design was + // drawn on. Every other node's parent is collected and can be read directly, + // so recording it there would be repetition — and repeated across a whole + // screen it was enough to push the payload into chunked delivery. + // Only a frame sitting directly on a page, section or component set needs + // this: its parent is outside the collected subtree, so the id alone says + // nothing, and the type is what decides whether its width is a real + // constraint or the canvas it was drawn on. Keyed on the parent's type + // rather than on being a requested root, because a multi-root collection is + // split into batches with different root sets — the same node would then + // carry the field in one batch and not another, and merging rejects a node + // that arrives two different ways. + if ( + node.parent && + (node.parent.type === "PAGE" || + node.parent.type === "SECTION" || + node.parent.type === "COMPONENT_SET") + ) { + fields.parentType = node.parent.type; + // A screen's Section names the page component the plugin writes for it, + // `AboutPage` for a Section called `about`, and the Section itself is + // outside the collected subtree. + if (node.parent.type === "SECTION") fields.parentName = node.parent.name; + } + const childrenIds = "children" in node ? node.children.map((child) => child.id) : []; + if (childrenIds.length > 0) fields.childrenIds = childrenIds; - for (const name of propertyNames(node)) { - if (skipped.has(name) || name.startsWith("_")) continue; + // Only ever look at the checked-in manifest. No prototype-chain walk, no + // "extra" bucket: an unlisted Figma Plugin API property is never collected. + for (const name of manifest) { + let value; try { - const value = node[name]; + if (!(name in node)) continue; + value = node[name]; if (typeof value === "function") continue; const serialized = serialize(value); - (manifestSet.has(name) ? fields : extra)[name] = serialized; + if (!isOmittableDefault(serialized, name)) fields[name] = serialized; } catch (error) { fieldErrors[name] = String(error && error.message ? error.message : error); } } if (node.type === "TEXT" && typeof node.getStyledTextSegments === "function") { try { - fields.styledTextSegments = serialize(node.getStyledTextSegments(textSegmentManifest)); + const segments = serialize(node.getStyledTextSegments(textSegmentManifest)); + // A single segment restates typography the node already carries at the + // top level, and `codegen/text.rs` reads the node field first and only + // falls back to the segment. Keep just the keys that exist nowhere else. + // Proven over 269 real single-segment text nodes by + // `devup-mcp-devup-ui/tests/default_omission_golden.rs`. + if (segments.length === 1) { + const only = segments[0]; + for (const key of Object.keys(only)) { + if (!SEGMENT_ONLY_KEYS.has(key)) delete only[key]; + } + } + if (segments.length > 0) fields.styledTextSegments = segments; } catch (error) { fieldErrors.styledTextSegments = String(error && error.message ? error.message : error); } } - return { id: node.id, type: node.type, fields, extra, fieldErrors }; + // `extra` and `fieldErrors` are `#[serde(default)]` on the Rust `RawNode`, + // so an empty one is the same as an absent one on the wire. + const snapshotted = { id: node.id, type: node.type, fields }; + if (Object.keys(fieldErrors).length > 0) snapshotted.fieldErrors = fieldErrors; + return snapshotted; } const allNodes = []; @@ -102,7 +326,27 @@ for (let index = 0; index < queue.length; index += 1) { allNodes.push(node); if ("children" in node) queue.push(...node.children); } -const nodes = allNodes.map(snapshotNode); +if (offset >= allNodes.length && allNodes.length > 0) { + throw new Error("DEVUP_SNAPSHOT_RANGE_INVALID"); +} + +function utf8ByteLength(value) { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { + bytes += 4; + index += 1; + } else bytes += 3; + } + return bytes; +} + +function jsonByteLength(value) { + return utf8ByteLength(JSON.stringify(value)); +} function styleTypeForField(field) { if (field === "textStyleId") return "TEXT"; @@ -112,11 +356,9 @@ function styleTypeForField(field) { return null; } -const variableIds = new Set(); -const styleTypes = new Map(); -function scanResources(value, fieldName = "") { +function scanResources(value, variableIds, styleTypes) { if (Array.isArray(value)) { - for (const child of value) scanResources(child, fieldName); + for (const child of value) scanResources(child, variableIds, styleTypes); return; } if (!value || typeof value !== "object") return; @@ -140,174 +382,84 @@ function scanResources(value, fieldName = "") { ) { if (!styleTypes.has(child)) styleTypes.set(child, styleType); } - scanResources(child, field || fieldName); + scanResources(child, variableIds, styleTypes); } } -scanResources(nodes); -function resourcePropertyNames(value) { - const names = new Set(Object.keys(value)); - let current = value; - while (current && current !== Object.prototype) { - for (const name of Object.getOwnPropertyNames(current)) names.add(name); - current = Object.getPrototypeOf(current); - } - return [...names].sort(); -} +// Resolves every variable/style the given page of nodes references. Only the +// nodes shipped in THIS page are scanned, so a page's resource block stays +// consistent with its own integrity counters; devup-mcp merges across pages. +async function collectResources(nodes) { + const variableIds = new Set(); + const styleTypes = new Map(); + scanResources(nodes, variableIds, styleTypes); -function serializeResource(value, seen = new WeakSet(), depth = 0) { - if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value; - if (typeof value === "undefined") return { $undefined: true }; - if (typeof value === "bigint") return { $bigint: value.toString() }; - if (["function", "symbol"].includes(typeof value)) return { $unsupported: typeof value }; - if (depth > 12) return { $truncated: "max-depth" }; - if ( - typeof value === "object" && - "parent" in value && - typeof value.id === "string" && - typeof value.type === "string" - ) { - return { $nodeId: value.id, $nodeType: value.type }; - } - if (Array.isArray(value)) { - return value.map((item) => serializeResource(item, seen, depth + 1)); - } - if (ArrayBuffer.isView(value)) { - return { $binary: value.constructor.name, byteLength: value.byteLength }; - } - if (value instanceof ArrayBuffer) return { $binary: "ArrayBuffer", byteLength: value.byteLength }; - if (seen.has(value)) return { $circular: true }; - seen.add(value); - const result = {}; - for (const name of resourcePropertyNames(value)) { - if (name.startsWith("_") || ["parent", "children", "consumers"].includes(name)) continue; - try { - const serialized = serializeResource(value[name], seen, depth + 1); - if (!(serialized && serialized.$unsupported === "function")) result[name] = serialized; - } catch (_) { - result[name] = { $error: "unavailable" }; - } - } - seen.delete(value); - return result; -} + const sortedVariableIds = [...variableIds].sort(); + const sortedStyles = [...styleTypes.entries()] + .map(([id, styleType]) => ({ id, styleType })) + .sort((left, right) => left.id.localeCompare(right.id)); -const sortedVariableIds = [...variableIds].sort(); -const sortedStyles = [...styleTypes.entries()] - .map(([id, styleType]) => ({ id, styleType })) - .sort((left, right) => left.id.localeCompare(right.id)); -const variableJobs = sortedVariableIds.map(async (id) => { - try { - const variable = await figma.variables.getVariableByIdAsync(id); - return variable - ? { - kind: "variable", - value: serializeResource(variable), - collectionId: variable.variableCollectionId, + const results = await Promise.all([ + ...sortedVariableIds.map(async (id) => { + try { + const variable = await figma.variables.getVariableByIdAsync(id); + return variable + ? { + kind: "variable", + value: serialize(variable, true), + collectionId: variable.variableCollectionId, + } + : { kind: "unresolved", value: { id, kind: "variable", reason: "notFoundOrUnavailable" } }; + } catch (_) { + return { kind: "unresolved", value: { id, kind: "variable", reason: "notFoundOrUnavailable" } }; + } + }), + ...sortedStyles.map(async ({ id, styleType }) => { + try { + const style = await figma.getStyleByIdAsync(id); + if (!style) { + return { kind: "unresolved", value: { id, kind: "style", reason: "notFoundOrUnavailable" } }; } - : { kind: "unresolved", value: { id, kind: "variable", reason: "notFoundOrUnavailable" } }; - } catch (_) { - return { kind: "unresolved", value: { id, kind: "variable", reason: "notFoundOrUnavailable" } }; - } -}); -const styleJobs = sortedStyles.map(async ({ id, styleType }) => { - try { - const style = await figma.getStyleByIdAsync(id); - if (!style) { - return { kind: "unresolved", value: { id, kind: "style", reason: "notFoundOrUnavailable" } }; - } - return { - kind: "style", - value: { - ...serializeResource(style), - styleType, - value: serializeResource( - styleType === "PAINT" - ? style.paints - : styleType === "EFFECT" - ? style.effects - : styleType === "GRID" - ? style.layoutGrids - : style, - ), - }, - }; - } catch (_) { - return { kind: "unresolved", value: { id, kind: "style", reason: "notFoundOrUnavailable" } }; - } -}); -const resourceResults = await Promise.all([...variableJobs, ...styleJobs]); -const collectionIds = [...new Set(resourceResults - .filter((result) => result.kind === "variable" && result.collectionId) - .map((result) => result.collectionId))].sort(); -const collectionJobs = collectionIds.map(async (id) => { - try { - const collection = await figma.variables.getVariableCollectionByIdAsync(id); - return collection ? serializeResource(collection) : null; - } catch (_) { - return null; - } -}); -const collections = (await Promise.all(collectionJobs)).filter((collection) => collection !== null); -const variables = resourceResults - .filter((result) => result.kind === "variable") - .map((result) => result.value); -const styles = resourceResults - .filter((result) => result.kind === "style") - .map((result) => result.value); -const unresolved = resourceResults - .filter((result) => result.kind === "unresolved") - .map((result) => result.value); - -function utf8Encode(value) { - const bytes = []; - for (let index = 0; index < value.length; index += 1) { - let codePoint = value.charCodeAt(index); - if (codePoint >= 0xd800 && codePoint <= 0xdbff) { - const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0; - if (next >= 0xdc00 && next <= 0xdfff) { - codePoint = 0x10000 + ((codePoint - 0xd800) << 10) + (next - 0xdc00); - index += 1; - } else { - codePoint = 0xfffd; + return { + kind: "style", + value: { + ...serialize(style, true), + styleType, + value: serialize( + styleType === "PAINT" + ? style.paints + : styleType === "EFFECT" + ? style.effects + : styleType === "GRID" + ? style.layoutGrids + : style, + true, + ), + }, + }; + } catch (_) { + return { kind: "unresolved", value: { id, kind: "style", reason: "notFoundOrUnavailable" } }; } - } else if (codePoint >= 0xdc00 && codePoint <= 0xdfff) { - codePoint = 0xfffd; - } + }), + ]); - if (codePoint < 0x80) { - bytes.push(codePoint); - } else if (codePoint < 0x800) { - bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f)); - } else if (codePoint < 0x10000) { - bytes.push( - 0xe0 | (codePoint >> 12), - 0x80 | ((codePoint >> 6) & 0x3f), - 0x80 | (codePoint & 0x3f), - ); - } else { - bytes.push( - 0xf0 | (codePoint >> 18), - 0x80 | ((codePoint >> 12) & 0x3f), - 0x80 | ((codePoint >> 6) & 0x3f), - 0x80 | (codePoint & 0x3f), - ); + const collectionIds = [...new Set(results + .filter((result) => result.kind === "variable" && result.collectionId) + .map((result) => result.collectionId))].sort(); + const collections = (await Promise.all(collectionIds.map(async (id) => { + try { + const collection = await figma.variables.getVariableCollectionByIdAsync(id); + return collection ? serialize(collection, true) : null; + } catch (_) { + return null; } - } - return new Uint8Array(bytes); -} + }))).filter((collection) => collection !== null); + + const variables = results.filter((result) => result.kind === "variable").map((result) => result.value); + const styles = results.filter((result) => result.kind === "style").map((result) => result.value); + const unresolved = results.filter((result) => result.kind === "unresolved").map((result) => result.value); -const envelope = { - schemaVersion: 1, - source: { fileKey: figma.fileKey || "", rootId: envelopeRootId }, - snapshot: { - fileKey: figma.fileKey || "", - version: null, - rootIds: roots.map((root) => root.id), - nodes, - diagnostics: [], - }, - resources: { + return { collections, variables, styles, @@ -317,96 +469,109 @@ const envelope = { localComplete: false, usedRemoteComplete: unresolved.length === 0, unresolved, - }, - integrity: { - nodeCount: nodes.length, - variableRefCount: sortedVariableIds.length, - styleRefCount: sortedStyles.length, - utf8Bytes: 0, - }, -}; - -let envelopeBytes = new Uint8Array(); -for (let attempt = 0; attempt < 8; attempt += 1) { - envelopeBytes = utf8Encode(JSON.stringify(envelope)); - if (envelope.integrity.utf8Bytes === envelopeBytes.length) break; - envelope.integrity.utf8Bytes = envelopeBytes.length; -} -envelopeBytes = utf8Encode(JSON.stringify(envelope)); -if (envelope.integrity.utf8Bytes !== envelopeBytes.length) { - throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); -} -if (envelopeBytes.length > MAX_ENVELOPE_BYTES) { - throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); + $variableRefCount: sortedVariableIds.length, + $styleRefCount: sortedStyles.length, + }; } -function crc32(bytes) { - let crc = 0xffffffff; - for (const byte of bytes) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) { - crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } +// Packs as many nodes as fit under `budget`, starting at `offset`. Same +// dynamic, byte-budget-driven pagination the legacy cursor snapshot uses. +function packPage(budget) { + const pageNodes = []; + let payloadBytes = 2; + for (let index = offset; index < allNodes.length; index += 1) { + const snapshotted = snapshotNode(allNodes[index]); + const nodeBytes = jsonByteLength(snapshotted) + (pageNodes.length ? 1 : 0); + if (pageNodes.length && payloadBytes + nodeBytes > budget) break; + pageNodes.push(snapshotted); + payloadBytes += nodeBytes; } - return (crc ^ 0xffffffff) >>> 0; + return { pageNodes, packedBytes: payloadBytes }; } -function u32(value) { - return new Uint8Array([ - (value >>> 24) & 0xff, - (value >>> 16) & 0xff, - (value >>> 8) & 0xff, - value & 0xff, - ]); -} - -function ascii(value) { - return new Uint8Array([...value].map((character) => character.charCodeAt(0))); -} - -function concat(parts) { - const length = parts.reduce((sum, part) => sum + part.length, 0); - const output = new Uint8Array(length); - let offset = 0; - for (const part of parts) { - output.set(part, offset); - offset += part.length; +function buildEnvelope(pageNodes, resources) { + const nextOffset = Math.min(allNodes.length, offset + pageNodes.length); + const { $variableRefCount, $styleRefCount, ...resourceBlock } = resources; + const nodes = [ + ...pageNodes, + { + id: "__DEVUP_SNAPSHOT_CURSOR__", + type: "DEVUP_INTERNAL", + // `offset` is what lets the Rust decoder tell a first page from a + // continuation page, which decides whether the root must be present + // here. All four fields are read by the shared `read_snapshot_cursor`. + fields: { + offset, + nextOffset, + complete: nextOffset >= allNodes.length, + totalNodes: allNodes.length, + }, + extra: {}, + fieldErrors: {}, + }, + ]; + const envelope = { + kind: "devupFastSnapshotEnvelope", + schemaVersion: 1, + source: { fileKey: figma.fileKey || "", rootId: envelopeRootId }, + snapshot: { + fileKey: figma.fileKey || "", + version: null, + rootIds: roots.map((root) => root.id), + nodes, + diagnostics: [], + }, + resources: resourceBlock, + // No `pagination` mirror: the __DEVUP_SNAPSHOT_CURSOR__ marker node is the + // single source of truth for page state, and duplicating it is exactly how + // the two copies drifted apart before. + integrity: { + nodeCount: nodes.length, + variableRefCount: $variableRefCount, + styleRefCount: $styleRefCount, + utf8Bytes: 0, + }, + }; + // Writing the byte count into the envelope changes the envelope's own + // length, so iterate to the fixed point. `utf8ByteLength` measures without + // building a throwaway byte array. + let bytes = 0; + for (let attempt = 0; attempt < 8; attempt += 1) { + bytes = utf8ByteLength(JSON.stringify(envelope)); + if (envelope.integrity.utf8Bytes === bytes) break; + envelope.integrity.utf8Bytes = bytes; } - return output; -} - -function pngChunk(type, data) { - const typeBytes = ascii(type); - return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]); + if (envelope.integrity.utf8Bytes !== utf8ByteLength(JSON.stringify(envelope))) { + throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); + } + return { envelope, bytes }; } -const chunkCount = Math.ceil(envelopeBytes.length / MAX_ENVELOPE_CHUNK_BYTES); -for (let sequence = 0; sequence < chunkCount; sequence += 1) { - const start = sequence * MAX_ENVELOPE_CHUNK_BYTES; - const end = Math.min(envelopeBytes.length, start + MAX_ENVELOPE_CHUNK_BYTES); - const envelopeChunk = pngChunk( - "duVp", - concat([u32(sequence), u32(chunkCount), envelopeBytes.slice(start, end)]), - ); - const png = concat([ - new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), - pngChunk("IHDR", new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0])), - envelopeChunk, - pngChunk( - "IDAT", - new Uint8Array([120, 1, 1, 5, 0, 250, 255, 0, 0, 0, 0, 0, 5, 0, 1]), - ), - pngChunk("IEND", new Uint8Array()), - ]); - figma.io.write(`devup-fast-snapshot-${sequence + 1}-of-${chunkCount}.png`, png); +// The node budget alone can't bound the envelope: a page also carries every +// variable/style its nodes reference, and that block is only sized once the +// nodes are chosen. So pack, build, and if the whole envelope overshoots the +// text limit, take the overshoot off the node budget and try again. Fewer +// nodes can only reference fewer resources, so the next envelope is at least +// the overshoot smaller and lands under the limit. Halving the budget +// instead, as this did, gave up half a page over a few hundred bytes: of 340 +// banked pages, 143 ended where the next node would still have fit. +let nodeBudget = maxPayloadBytes - 1024; +let built = null; +for (let attempt = 0; attempt < 5; attempt += 1) { + const { pageNodes, packedBytes } = packPage(nodeBudget); + if (pageNodes.length === 0) throw new Error("DEVUP_SNAPSHOT_RANGE_INVALID"); + const candidate = buildEnvelope(pageNodes, await collectResources(pageNodes)); + if (candidate.bytes <= MAX_TEXT_ENVELOPE_BYTES) { + built = candidate; + break; + } + if (pageNodes.length === 1) { + // A single node whose own resources blow the limit; no smaller page + // exists and there is no binary transport to fall back to. + throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); + } + const overshoot = candidate.bytes - MAX_TEXT_ENVELOPE_BYTES; + nodeBudget = Math.max(1, Math.min(nodeBudget - 1, packedBytes - overshoot - 256)); } -return { - kind: "devupFastSnapshotDescriptor", - schemaVersion: 1, - rootId: envelopeRootId, - nodeCount: nodes.length, - variableRefCount: sortedVariableIds.length, - styleRefCount: sortedStyles.length, - utf8Bytes: envelopeBytes.length, - chunkCount, -}; +if (!built) throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); +return built.envelope; diff --git a/crates/devup-mcp-figma/src/scripts/fast_theme.js b/crates/devup-mcp-figma/src/scripts/fast_theme.js index 68c0d035..31b1265b 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_theme.js +++ b/crates/devup-mcp-figma/src/scripts/fast_theme.js @@ -1,5 +1,18 @@ const MAX_ENVELOPE_BYTES = 8 * 1024 * 1024; -const MAX_ENVELOPE_CHUNK_BYTES = 512 * 1024; +// The Figma MCP cuts a text result at 20,480 UTF-8 bytes; the measurement is +// written up in fast_snapshot.js. +const MAX_TEXT_ENVELOPE_BYTES = 19 * 1024; +// A file's resources rarely fit one answer - forty variables with their +// modes are twice this - so they are paged: everything the theme is made of +// (the resources, and the scan of which ids are used and what could not be +// resolved) is one list in a fixed order, and a page is a run of it from +// `offset` that says where the next one starts. The theme used to be one +// envelope or nothing, and a file whose theme did not fit fell back to a +// path that knows only local styles. The scan is paged like the rest +// because it is not small: a file with three hundred used ids carries +// 20 KB of them, a whole page on its own. +const pageOptions = "__DEVUP_THEME__"; +const pageOffset = Math.max(0, Math.floor(Number(pageOptions.offset) || 0)); function propertyNames(value) { const names = new Set(Object.keys(value)); @@ -235,89 +248,100 @@ function utf8Encode(value) { return new Uint8Array(bytes); } -const envelope = { - schemaVersion: 1, - source: { fileKey: figma.fileKey || "", version: null }, - resources: { - collections, - variables, - styles, - usedRemoteVariables: variables.filter((variable) => variable.remote === true), - usedVariableIds: [...usedVariableIds].sort(), - usedStyleIds: [...usedStyleTypes.keys()].sort(), - localComplete: true, - usedRemoteComplete: unresolved.length === 0, - unresolved, - }, - integrity: { - collectionCount: collections.length, - variableCount: variables.length, - styleCount: styles.length, - unresolvedCount: unresolved.length, - utf8Bytes: 0, - }, -}; +// Everything in one order: collections, variables, styles, then the scan - +// the used variable ids, the used style ids, the unresolved. A page is a run +// of this list. +const items = [ + ...collections.map((value) => ({ kind: "collection", value })), + ...variables.map((value) => ({ kind: "variable", value })), + ...styles.map((value) => ({ kind: "style", value })), + ...[...usedVariableIds].sort().map((value) => ({ kind: "usedVariableId", value })), + ...[...usedStyleTypes.keys()].sort().map((value) => ({ kind: "usedStyleId", value })), + ...unresolved.map((value) => ({ kind: "unresolved", value })), +]; +if (pageOffset > items.length) throw new Error("DEVUP_SNAPSHOT_RANGE_INVALID"); -let envelopeBytes = new Uint8Array(); -for (let attempt = 0; attempt < 8; attempt += 1) { +function buildEnvelope(pageItems, nextOffset) { + const of = (kind) => pageItems.filter((item) => item.kind === kind).map((item) => item.value); + const pageCollections = of("collection"); + const pageVariables = of("variable"); + const pageStyles = of("style"); + const pageUnresolved = of("unresolved"); + const envelope = { + kind: "devupFastThemeEnvelope", + schemaVersion: 1, + source: { fileKey: figma.fileKey || "", version: null }, + resources: { + collections: pageCollections, + variables: pageVariables, + styles: pageStyles, + // Derived by the collector from `variables` once the pages are merged; + // listing a page's remote variables twice halved what a page held. + usedRemoteVariables: [], + usedVariableIds: of("usedVariableId"), + usedStyleIds: of("usedStyleId"), + localComplete: true, + usedRemoteComplete: unresolved.length === 0, + unresolved: pageUnresolved, + }, + // Read by the Rust decoder: a page is one of several when `complete` is + // false, and the next one is asked for from `nextOffset`. + page: { + offset: pageOffset, + nextOffset, + complete: nextOffset >= items.length, + totalItems: items.length, + }, + integrity: { + collectionCount: pageCollections.length, + variableCount: pageVariables.length, + styleCount: pageStyles.length, + unresolvedCount: pageUnresolved.length, + utf8Bytes: 0, + }, + }; + let envelopeBytes = new Uint8Array(); + for (let attempt = 0; attempt < 8; attempt += 1) { + envelopeBytes = utf8Encode(JSON.stringify(envelope)); + if (envelope.integrity.utf8Bytes === envelopeBytes.length) break; + envelope.integrity.utf8Bytes = envelopeBytes.length; + } envelopeBytes = utf8Encode(JSON.stringify(envelope)); - if (envelope.integrity.utf8Bytes === envelopeBytes.length) break; - envelope.integrity.utf8Bytes = envelopeBytes.length; -} -envelopeBytes = utf8Encode(JSON.stringify(envelope)); -if (envelope.integrity.utf8Bytes !== envelopeBytes.length) { - throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); + if (envelope.integrity.utf8Bytes !== envelopeBytes.length) { + throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); + } + return { envelope, bytes: envelopeBytes.length }; } -if (envelopeBytes.length > MAX_ENVELOPE_BYTES) throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); -function crc32(bytes) { - let crc = 0xffffffff; - for (const byte of bytes) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); +// Pack from the offset by the items' own sizes until the budget is spent, +// then build the page; the envelope around the items - and the remote +// variables a page lists twice - is only sized once built, so if the whole +// overshoots, take the overshoot off the budget and pack again. Fewer items +// can only make a smaller envelope, so this converges. One item alone that +// does not fit cannot be split, and is reported rather than cut. +const itemBytes = items.map((item) => utf8Encode(JSON.stringify(item.value)).length + 1); +function packFrom(budget) { + const pageItems = []; + let packed = 0; + for (let index = pageOffset; index < items.length; index += 1) { + if (pageItems.length > 0 && packed + itemBytes[index] > budget) break; + pageItems.push(items[index]); + packed += itemBytes[index]; } - return (crc ^ 0xffffffff) >>> 0; -} -function u32(value) { - return new Uint8Array([(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]); + return { pageItems, packed }; } -function ascii(value) { - return new Uint8Array([...value].map((character) => character.charCodeAt(0))); -} -function concat(parts) { - const output = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); - let offset = 0; - for (const part of parts) { - output.set(part, offset); - offset += part.length; +let budget = MAX_TEXT_ENVELOPE_BYTES - 1024; +let built = null; +for (let attempt = 0; attempt < 6; attempt += 1) { + const { pageItems, packed } = packFrom(budget); + const candidate = buildEnvelope(pageItems, pageOffset + pageItems.length); + if (candidate.bytes <= MAX_TEXT_ENVELOPE_BYTES) { + built = candidate; + break; } - return output; -} -function pngChunk(type, data) { - const typeBytes = ascii(type); - return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]); -} - -const chunkCount = Math.ceil(envelopeBytes.length / MAX_ENVELOPE_CHUNK_BYTES); -for (let sequence = 0; sequence < chunkCount; sequence += 1) { - const start = sequence * MAX_ENVELOPE_CHUNK_BYTES; - const end = Math.min(envelopeBytes.length, start + MAX_ENVELOPE_CHUNK_BYTES); - const png = concat([ - new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), - pngChunk("IHDR", new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0])), - pngChunk("duVp", concat([u32(sequence), u32(chunkCount), envelopeBytes.slice(start, end)])), - pngChunk("IDAT", new Uint8Array([120, 1, 1, 5, 0, 250, 255, 0, 0, 0, 0, 0, 5, 0, 1])), - pngChunk("IEND", new Uint8Array()), - ]); - figma.io.write(`devup-fast-theme-${sequence + 1}-of-${chunkCount}.png`, png); + if (pageItems.length <= 1) break; + budget = Math.max(1, Math.min(budget - 1, packed - (candidate.bytes - MAX_TEXT_ENVELOPE_BYTES) - 256)); } -return { - kind: "devupFastThemeDescriptor", - schemaVersion: 1, - collectionCount: collections.length, - variableCount: variables.length, - styleCount: styles.length, - unresolvedCount: unresolved.length, - utf8Bytes: envelopeBytes.length, - chunkCount, -}; +if (built === null) throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); +if (built.bytes > MAX_ENVELOPE_BYTES) throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); +return built.envelope; diff --git a/crates/devup-mcp-figma/src/scripts/large_value.js b/crates/devup-mcp-figma/src/scripts/large_value.js index 6315b521..a021b762 100644 --- a/crates/devup-mcp-figma/src/scripts/large_value.js +++ b/crates/devup-mcp-figma/src/scripts/large_value.js @@ -147,8 +147,25 @@ function base64(bytes) { } let rawValue; +// The bytes a fragment is cut from. A node field is its JSON; the virtual +// field `$export:svg` is the node's SVG export as text, which the asset +// script announced when it was too large to carry in one answer. +let exportedBytes = null; try { - if ( + if (options.field === "$export:svg") { + if (typeof node.exportAsync !== "function") throw new Error("unsupported"); + const svg = await node.exportAsync({ format: "SVG_STRING" }); + if (typeof svg !== "string") throw new Error("unsupported"); + exportedBytes = utf8Encode(svg); + } else if (typeof options.field === "string" && options.field.startsWith("$export:png")) { + // A PNG too large for one attachment, re-exported a fragment at a time. + // The scale rides on the field - `$export:png@2` - so the re-export is + // the same bytes the asset script announced, hash for hash. + if (typeof node.exportAsync !== "function") throw new Error("unsupported"); + const scale = Math.min(4, Math.max(1, Math.floor(Number(options.field.split("@")[1]) || 1))); + const png = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: scale } }); + exportedBytes = png instanceof Uint8Array ? png : new Uint8Array(png); + } else if ( options.field === "styledTextSegments" && node.type === "TEXT" && typeof node.getStyledTextSegments === "function" @@ -172,7 +189,7 @@ try { }; } -const bytes = utf8Encode(JSON.stringify(serialize(rawValue))); +const bytes = exportedBytes === null ? utf8Encode(JSON.stringify(serialize(rawValue))) : exportedBytes; const observedHash = sha256(bytes); if (bytes.length !== options.byteLength || observedHash !== options.sha256) { throw new Error("DEVUP_LARGE_VALUE_CHANGED"); diff --git a/crates/devup-mcp-figma/src/scripts/large_value_helpers.js b/crates/devup-mcp-figma/src/scripts/large_value_helpers.js index b2b6c8a4..99382be9 100644 --- a/crates/devup-mcp-figma/src/scripts/large_value_helpers.js +++ b/crates/devup-mcp-figma/src/scripts/large_value_helpers.js @@ -1,5 +1,10 @@ const DEVUP_MAX_LARGE_VALUE_BYTES = 16 * 1024 * 1024; -const DEVUP_LARGE_VALUE_CHUNK_BYTES = 8192; +// A fragment's text is the chunk in base64, four thirds of it, inside a +// wrapper of some three hundred bytes, and the Figma MCP cuts a text result +// at 20,480 UTF-8 bytes (measured; see fast_snapshot.js). Twelve KiB comes +// back as 16.7 KB of text, with room to spare; eight KiB was 11.2 KB, and +// took a third more calls for the same value. +const DEVUP_LARGE_VALUE_CHUNK_BYTES = 12288; function devupUtf8Encode(value) { const bytes = []; diff --git a/crates/devup-mcp-figma/src/scripts/section_index.js b/crates/devup-mcp-figma/src/scripts/section_index.js index f81e09eb..e7091991 100644 --- a/crates/devup-mcp-figma/src/scripts/section_index.js +++ b/crates/devup-mcp-figma/src/scripts/section_index.js @@ -24,6 +24,15 @@ function isScreen(node, box) { && aspect >= 0.25 && aspect <= 2.5; } +function contains(ancestor, node) { + let parent = node.parent; + while (parent) { + if (parent.id === ancestor.id) return true; + parent = parent.parent; + } + return false; +} + function breadcrumb(node) { const names = []; let current = node; @@ -89,6 +98,33 @@ for (let index = 0; index < queue.length && traversalCount < MAX_TRAVERSED_NODES } if ("children" in node) queue.push(...node.children); } +// Screen shape is a guess for finding screens on a page that has no grouping. +// A Section is grouping, already explicit, and the guess applied there answers +// with whatever happens to measure like a phone. A Section of small cases +// annotated with tall notes turns it upside down: the notes pass and the cases +// do not, so the index offered the notes and hid every case — an answer that +// looked complete, which is worse than the empty list a Section of cases used +// to give. What the Section holds is what it offers. +if ("children" in section) { + const chosen = new Set(candidateNodes.map(({ node }) => node.id)); + for (const node of section.children) { + if (chosen.has(node.id)) continue; + const box = bounds(node); + if (!box || node.visible === false) continue; + // And a child outranks whatever the guess found inside it. A long page + // never measures like a screen: the three widths of one page here are + // 1920x4757, 992x5619 and 360x7240, each rejected on height alone and two + // of them on aspect as well. So the traversal walks straight past all + // three and offers the frames within them instead, answering a request for + // three screens with twenty pieces of three screens -- while the widths + // themselves, which are the whole of what the Section holds, appear + // nowhere in the index. Taking the page apart is not a way of offering it. + for (let index = candidateNodes.length - 1; index >= 0; index -= 1) { + if (contains(node, candidateNodes[index].node)) candidateNodes.splice(index, 1); + } + candidateNodes.push({ node, box }); + } +} candidateNodes.sort((left, right) => left.box.y - right.box.y || left.box.x - right.box.x diff --git a/crates/devup-mcp-figma/src/scripts/snapshot.js b/crates/devup-mcp-figma/src/scripts/snapshot.js index bd76931d..51e6b634 100644 --- a/crates/devup-mcp-figma/src/scripts/snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/snapshot.js @@ -8,7 +8,7 @@ const snapshotOptions = "__DEVUP_SNAPSHOT__"; const offset = Math.max(0, Math.floor(Number(snapshotOptions.offset) || 0)); const maxPayloadBytes = Math.min( 16000, - Math.max(4096, Math.floor(Number(snapshotOptions.maxPayloadBytes) || 12000)), + Math.max(4096, Math.floor(Number(snapshotOptions.maxPayloadBytes) || 15000)), ); const maxFieldBytes = Math.min( maxPayloadBytes - 1024, @@ -160,6 +160,25 @@ function snapshotNode(node) { const extra = {}; const fieldErrors = {}; fields.parentId = node.parent ? node.parent.id : null; + // Only the root needs this. Its parent lies outside the collected subtree, + // so the id alone says nothing, and the parent's type is what decides + // whether the root's width is a real constraint or merely the canvas the + // design was drawn on. Every other node's parent is collected and can be + // read directly. + // Keyed on the parent's type rather than on being the requested root, so a + // node carries the same fields however it is reached. See fast_snapshot.js. + if ( + node.parent && + (node.parent.type === "PAGE" || + node.parent.type === "SECTION" || + node.parent.type === "COMPONENT_SET") + ) { + fields.parentType = node.parent.type; + // A screen's Section names the page component the plugin writes for it, + // `AboutPage` for a Section called `about`, and the Section itself is + // outside the collected subtree. + if (node.parent.type === "SECTION") fields.parentName = node.parent.name; + } fields.childrenIds = "children" in node ? node.children.map((child) => child.id) : []; for (const name of propertyNames(node)) { @@ -211,7 +230,10 @@ const nextOffset = Math.min(allNodes.length, offset + nodes.length); nodes.push({ id: "__DEVUP_SNAPSHOT_CURSOR__", type: "DEVUP_INTERNAL", + // Same marker shape as the fast snapshot so both paths go through the one + // `read_snapshot_cursor` reader in Rust. fields: { + offset, nextOffset, complete: nextOffset >= allNodes.length, totalNodes: allNodes.length, diff --git a/crates/devup-mcp-figma/src/search.rs b/crates/devup-mcp-figma/src/search.rs index 07d6e490..800365b5 100644 --- a/crates/devup-mcp-figma/src/search.rs +++ b/crates/devup-mcp-figma/src/search.rs @@ -41,14 +41,14 @@ pub fn search_snapshot( if query.is_empty() { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "검색 query는 비어 있을 수 없습니다.", + "Search query cannot be empty.", false, )); } if options.limit == 0 || options.limit > 100 { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "검색 limit은 1 이상 100 이하여야 합니다.", + "Search limit must be between 1 and 100.", false, )); } @@ -58,7 +58,7 @@ pub fn search_snapshot( ) { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "match는 exact, normalized 또는 fuzzy여야 합니다.", + "match must be exact, normalized, or fuzzy.", false, )); } diff --git a/crates/devup-mcp-figma/src/section.rs b/crates/devup-mcp-figma/src/section.rs index e321181e..55d396ff 100644 --- a/crates/devup-mcp-figma/src/section.rs +++ b/crates/devup-mcp-figma/src/section.rs @@ -49,18 +49,18 @@ impl SectionIndex { ) -> Result, DevupError> { if all_screens && !frame_ids.is_empty() { return Err(invalid_selection( - "frameIds와 allScreens는 동시에 사용할 수 없습니다.", + "frameIds and allScreens cannot be used together.", )); } if !all_screens && frame_ids.is_empty() { return Err(invalid_selection( - "Section root 수집에는 frameIds 또는 allScreens가 필요합니다.", + "Section root collection requires frameIds or allScreens.", )); } if self.truncated && all_screens { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "잘린 Section index에서는 allScreens를 사용할 수 없습니다.", + "allScreens cannot be used with a truncated Section index.", false, )); } @@ -69,7 +69,7 @@ impl SectionIndex { .map(String::as_str) .collect::>(); if requested.len() != frame_ids.len() { - return Err(invalid_selection("frameIds에 중복 node가 있습니다.")); + return Err(invalid_selection("frameIds contains duplicate nodes.")); } let candidates = self .candidates @@ -79,7 +79,7 @@ impl SectionIndex { if let Some(foreign) = requested.difference(&candidates).next() { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("Section 내부 screen frame이 아니거나 존재하지 않습니다: {foreign}"), + format!("Not a screen frame inside the Section, or it does not exist: {foreign}"), false, )); } @@ -122,27 +122,25 @@ pub fn build_section_index( ) -> Result { if snapshot.file_key != target.file_key { return Err(invalid_selection( - "Section index의 file key가 요청과 다릅니다.", + "Section index file key does not match the request.", )); } let section_id = target.node_id.as_deref().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Section index에는 node-id가 필요합니다.", + "Section index requires a node-id.", false, ) })?; let section = snapshot.nodes.get(section_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Section index에서 대상 node를 찾지 못했습니다.", + "Target node not found for the Section index.", false, ) })?; if section.node_type != "SECTION" { - return Err(invalid_selection( - "Section index 대상은 SECTION이어야 합니다.", - )); + return Err(invalid_selection("Section index target must be a SECTION.")); } let section_node = ExploreNode::try_from(section)?; let mut screen_nodes = Vec::new(); @@ -161,6 +159,34 @@ pub fn build_section_index( screen_nodes.push(explore); } } + // A Section is answered with the screens inside it, because converting one + // whole is too much. But a Section is an explicit grouping, and screen shape + // is a guess used to find screens on a page that has no grouping: applied + // here it silently drops whatever is not phone or desktop shaped. A section + // of small cases offered nothing at all, and — worse, because it looked + // like an answer — a section mixing tall notes with small cases offered the + // notes and hid every case. What the Section holds is what it offers, so its + // own children stand alongside the screens found within it. + let found_screen_ids = screen_nodes + .iter() + .map(|node| node.node_id.clone()) + .collect::>(); + let children = section + .typed_view() + .child_ids() + .filter_map(|child_id| snapshot.nodes.get(child_id)) + .filter_map(|child| ExploreNode::try_from(child).ok()) + .filter(|child| child.visible) + .filter(|child| !found_screen_ids.contains(&child.node_id)) + // A child holding a screen would offer that screen twice over, once + // whole and once inside itself. + .filter(|child| { + !found_screen_ids + .iter() + .any(|screen| is_descendant(snapshot, screen, &child.node_id)) + }) + .collect::>(); + screen_nodes.extend(children); let screen_ids = screen_nodes .iter() .map(|node| node.node_id.clone()) @@ -247,7 +273,9 @@ pub fn plan_batches( limits: BatchLimits, ) -> Result, DevupError> { if limits.max_estimated_bytes == 0 || limits.max_nodes == 0 { - return Err(invalid_selection("Section batch 상한은 0보다 커야 합니다.")); + return Err(invalid_selection( + "Section batch limits must be greater than 0.", + )); } let selected = index.select(selected_root_ids, false)?; let by_id = index @@ -266,7 +294,7 @@ pub fn plan_batches( let candidate = by_id .get(root_id.as_str()) .copied() - .ok_or_else(|| invalid_selection("Section batch candidate가 없습니다."))?; + .ok_or_else(|| invalid_selection("Section batch candidate is missing."))?; let rank = visual_rank[&root_id]; Ok((rank, root_id, candidate)) }) diff --git a/crates/devup-mcp-figma/src/snapshot.rs b/crates/devup-mcp-figma/src/snapshot.rs index 90445a3d..a6899c59 100644 --- a/crates/devup-mcp-figma/src/snapshot.rs +++ b/crates/devup-mcp-figma/src/snapshot.rs @@ -89,6 +89,76 @@ impl RawNode { } } +/// Sentinel node ID every paginating snapshot script appends to report where +/// the next page starts. +pub const SNAPSHOT_CURSOR_ID: &str = "__DEVUP_SNAPSHOT_CURSOR__"; + +/// Page state carried by the `__DEVUP_SNAPSHOT_CURSOR__` marker node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapshotCursor { + pub offset: usize, + pub next_offset: usize, + pub complete: bool, + pub total_nodes: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapshotCursorError { + Duplicated, + Shape, +} + +impl SnapshotCursorError { + pub fn korean_message(self) -> &'static str { + match self { + Self::Duplicated => "Figma snapshot response contains duplicate cursors.", + Self::Shape => "Figma snapshot cursor format is invalid.", + } + } + + pub fn category(self) -> &'static str { + match self { + Self::Duplicated => "cursorMultiplicity", + Self::Shape => "cursorShape", + } + } +} + +/// Reads the page cursor out of a node list without mutating it. +/// +/// Both the legacy cursor collector and the fast envelope decoder go through +/// here so the marker is parsed against exactly one field list - the two used +/// to keep separate lists, and drifted apart. +pub fn read_snapshot_cursor( + nodes: &[RawNode], +) -> Result, SnapshotCursorError> { + let markers = nodes + .iter() + .filter(|node| node.id == SNAPSHOT_CURSOR_ID) + .collect::>(); + let marker = match markers.as_slice() { + [] => return Ok(None), + [marker] => *marker, + _ => return Err(SnapshotCursorError::Duplicated), + }; + if marker.node_type != "DEVUP_INTERNAL" { + return Err(SnapshotCursorError::Shape); + } + let view = marker.typed_view(); + let index = |field: &str| { + view.value(field) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or(SnapshotCursorError::Shape) + }; + Ok(Some(SnapshotCursor { + offset: index("offset")?, + next_offset: index("nextOffset")?, + complete: view.bool("complete").ok_or(SnapshotCursorError::Shape)?, + total_nodes: index("totalNodes")?, + })) +} + #[derive(Debug, Clone, Copy)] pub struct TypedNode<'a> { node: &'a RawNode, @@ -346,7 +416,7 @@ pub fn merge_chunks(chunks: Vec) -> Result let first = chunks.first().ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "병합할 Figma snapshot이 없습니다.", + "No Figma snapshot to merge.", false, ) })?; @@ -354,14 +424,14 @@ pub fn merge_chunks(chunks: Vec) -> Result let version = first.version.clone(); let mut roots = Vec::new(); let mut root_set = BTreeSet::new(); - let mut nodes = BTreeMap::new(); + let mut nodes: BTreeMap = BTreeMap::new(); let mut diagnostics = Vec::new(); for chunk in chunks { if chunk.file_key != file_key || chunk.version != version { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma 파일 버전이 변경되었습니다. 다시 시도하세요.", + "The Figma file version changed during collection. Try again.", true, )); } @@ -371,12 +441,41 @@ pub fn merge_chunks(chunks: Vec) -> Result } } for node in chunk.nodes { + // The cursor is each chunk's own pagination state, not a node of + // the design. Comparing it as one meant any collection arriving in + // more than one chunk — every multi-root Section export — was + // rejected for the cursors disagreeing, which is the one thing they + // are certain to do. + if node.id == SNAPSHOT_CURSOR_ID { + continue; + } if let Some(existing) = nodes.get(&node.id) { if existing != &node { - return Err(DevupError::new( + // Which node, and which fields disagree. A collection split + // across batches can reach the same node two ways, and + // without naming the difference there is nothing to act on. + let differing = existing + .fields + .keys() + .chain(node.fields.keys()) + .collect::>() + .into_iter() + .filter(|field| { + existing.fields.get(field.as_str()) != node.fields.get(field.as_str()) + }) + .take(12) + .cloned() + .collect::>(); + return Err(DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, - "동일한 Figma node에 서로 다른 snapshot 데이터가 반환되었습니다.", + "Different snapshot data was returned for the same Figma node.", true, + serde_json::json!({ + "nodeId": node.id, + "nodeType": node.node_type, + "typeChanged": existing.node_type != node.node_type, + "differingFields": differing, + }), )); } } else { @@ -399,7 +498,7 @@ pub fn snapshot_chunk_from_result(result: &UpstreamResult) -> Result bool { - policy == SourcePolicy::Auto - && matches!( - kind, - UpstreamFailureKind::CatalogRejected - | UpstreamFailureKind::AuthUnavailable - | UpstreamFailureKind::CapabilityUnavailable - | UpstreamFailureKind::PermissionDenied - ) -} - -pub fn fallback_allowed_for_error(policy: SourcePolicy, error: &DevupError) -> bool { - let kind = match error.code { - ErrorCode::DevupFigmaCatalogRejected => UpstreamFailureKind::CatalogRejected, - ErrorCode::DevupAuthRequired => UpstreamFailureKind::AuthUnavailable, - ErrorCode::DevupFigmaDirectUnavailable => UpstreamFailureKind::CapabilityUnavailable, - ErrorCode::DevupFigmaPermissionDenied => UpstreamFailureKind::PermissionDenied, - ErrorCode::DevupFigmaRateLimited => UpstreamFailureKind::RateLimited, - ErrorCode::DevupFigmaNodeNotFound => UpstreamFailureKind::NodeNotFound, - ErrorCode::DevupFigmaVersionChanged => UpstreamFailureKind::VersionChanged, - _ => return false, - }; - fallback_allowed(policy, kind) -} - pub fn classify_upstream_failure( context: UpstreamFailureContext, status: Option, @@ -118,55 +86,58 @@ impl UpstreamFailureKind { let (code, message, retryable) = match self { Self::CatalogRejected => ( ErrorCode::DevupFigmaCatalogRejected, - "이 client는 Figma MCP Catalog에서 승인되지 않았습니다.", + "This client is not approved in the Figma MCP Catalog.", false, ), Self::AuthUnavailable => ( ErrorCode::DevupAuthRequired, - "Figma direct 연결 인증을 사용할 수 없습니다.", + "Figma direct connection authentication is unavailable.", false, ), Self::CapabilityUnavailable => ( ErrorCode::DevupFigmaDirectUnavailable, - "Figma direct 연결에 필요한 읽기 capability가 없습니다.", + "The read capability required for a Figma direct connection is missing.", false, ), Self::PermissionDenied => ( ErrorCode::DevupFigmaPermissionDenied, - "Figma 파일을 읽을 권한이 없습니다.", + "No permission to read this Figma file.", false, ), Self::RateLimited => ( ErrorCode::DevupFigmaRateLimited, - "Figma 요청 한도에 도달했습니다.", + "Figma request rate limit reached.", true, ), Self::NodeNotFound => ( ErrorCode::DevupFigmaNodeNotFound, - "Figma node를 찾지 못했습니다.", + "Figma node not found.", false, ), Self::VersionChanged => ( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during collection.", true, ), Self::Transport => ( ErrorCode::DevupFigmaDirectUnavailable, - "Figma direct 연결을 완료하지 못했습니다.", + "Failed to complete the Figma direct connection.", true, ), Self::InvalidResponse => ( ErrorCode::DevupSnapshotUnsupported, - "Figma MCP 응답을 안전하게 해석하지 못했습니다.", + "Failed to safely interpret the Figma MCP response.", false, ), }; - DevupError::with_details( - code, - message, - retryable, - json!({ "source": "direct", "status": status }), - ) + let mut details = json!({ "source": "direct", "status": status }); + if self == Self::CatalogRejected { + details["options"] = json!([ + "Register devup-mcp on the Figma MCP Catalog waitlist: https://www.figma.com/mcp-catalog/", + "Inject client credentials you obtained yourself via devup_figma_auth { action: \"configure\", clientId, clientSecret }", + "Hand off to the official Figma MCP registered on the host (sourcePolicy: auto or host, the current default fallback)" + ]); + } + DevupError::with_details(code, message, retryable, details) } } diff --git a/crates/devup-mcp-figma/src/upstream.rs b/crates/devup-mcp-figma/src/upstream.rs index ac7859e4..62a5a472 100644 --- a/crates/devup-mcp-figma/src/upstream.rs +++ b/crates/devup-mcp-figma/src/upstream.rs @@ -19,7 +19,8 @@ use tokio::sync::Mutex; use super::{ AssetRequest, CredentialStore, DevupError, LargeValueReadOptions, OAuthManager, ResourceBatch, - UpstreamFailureContext, UpstreamFailureKind, upstream_failure_error, + UpstreamFailureContext, UpstreamFailureKind, collector::MAX_REFERENCE_PNG_DIMENSION, + upstream_failure_error, }; const DEFAULT_FIGMA_MCP_ENDPOINT: &str = "https://mcp.figma.com/mcp"; @@ -236,23 +237,13 @@ impl BuiltinScript { }) .unwrap_or_else(|| json!({})); let asset = serde_json::to_string(&asset).expect("asset options serialize"); - let section_index_probe = if self == Self::FastSnapshotEnvelope { - let mut probe = include_str!("scripts/section_index.js").replacen( - "if (section.type !== \"SECTION\") throw new Error(\"DEVUP_SECTION_REQUIRED\");", - "if (section.type === \"SECTION\") {", - 1, - ); - probe.push_str("\n}"); - format!("{{\n{probe}\n}}") - } else { - String::new() - }; + let theme = serde_json::to_string(&json!({ "offset": inputs.theme_offset.unwrap_or(0) })) + .expect("theme page options serialize"); source .replace( "\"__DEVUP_LARGE_VALUE_HELPERS__\"", include_str!("scripts/large_value_helpers.js"), ) - .replace("\"__DEVUP_SECTION_INDEX_PROBE__\"", §ion_index_probe) .replace("\"__DEVUP_NODE_ID__\"", &node_id) .replace("\"__DEVUP_ROOT_IDS__\"", &root_ids) .replace( @@ -269,6 +260,7 @@ impl BuiltinScript { .replace("\"__DEVUP_SNAPSHOT__\"", &snapshot) .replace("\"__DEVUP_LARGE_VALUE__\"", &large_value) .replace("\"__DEVUP_ASSET__\"", &asset) + .replace("\"__DEVUP_THEME__\"", &theme) } } @@ -281,6 +273,7 @@ struct ScriptInputs<'a> { root_ids: Option<&'a [String]>, large_value: Option<&'a LargeValueReadOptions>, asset: Option<(&'a AssetRequest, Option<&'a str>)>, + theme_offset: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -295,7 +288,11 @@ impl Default for SnapshotReadOptions { fn default() -> Self { Self { offset: 0, - max_payload_bytes: 12_000, + // The node payload of one page. The Figma MCP cuts a text result at + // 20,480 UTF-8 bytes, and the fast script packs the page against + // that with the resources it carries; the measurement is written + // up in `scripts/fast_snapshot.js`. + max_payload_bytes: 15_000, max_field_bytes: 4_096, } } @@ -372,6 +369,9 @@ pub enum ReadToolCall { }, FastTheme { file_key: String, + /// Where in the file's resources the page starts; the first page is + /// 0, and each page says where the next begins. + offset: usize, }, LargeValue { file_key: String, @@ -452,12 +452,24 @@ impl ReadToolCall { } pub fn fast_snapshot(file_key: impl Into, node_id: impl Into) -> Self { + Self::fast_snapshot_page(file_key, node_id, SnapshotReadOptions::default()) + } + + /// A single round of the fast (text-paginated) node snapshot. `options.offset` + /// selects the starting node index; the script dynamically packs as many + /// nodes as fit under `options.max_payload_bytes` and reports a cursor for + /// the next round via the standard `__DEVUP_SNAPSHOT_CURSOR__` marker node. + pub fn fast_snapshot_page( + file_key: impl Into, + node_id: impl Into, + options: SnapshotReadOptions, + ) -> Self { Self::Snapshot { file_key: file_key.into(), node_id: node_id.into(), script: BuiltinScript::FastSnapshotEnvelope, resources: None, - snapshot: None, + snapshot: Some(options), root_ids: None, } } @@ -489,8 +501,14 @@ impl ReadToolCall { } pub fn fast_theme(file_key: impl Into) -> Self { + Self::fast_theme_page(file_key, 0) + } + + /// One page of the file's resources, from `offset`. + pub fn fast_theme_page(file_key: impl Into, offset: usize) -> Self { Self::FastTheme { file_key: file_key.into(), + offset, } } @@ -592,15 +610,42 @@ impl ReadToolCall { pub fn arguments(&self) -> Map { let value = match self { - Self::Metadata { file_key, node_id } => { - json!({ "fileKey": file_key, "nodeId": node_id }) - } + // The file's own metadata - its pages - is asked for with no + // `nodeId` at all: the official schema takes the key as optional + // and refuses it as `null` ("expected string, received null"), + // which is what a file-scope theme export ran into. + Self::Metadata { + file_key, + node_id: None, + } => json!({ "fileKey": file_key }), + Self::Metadata { + file_key, + node_id: Some(node_id), + } => json!({ "fileKey": file_key, "nodeId": node_id }), Self::VariableDefs { file_key, node_id } | Self::DesignContext { file_key, node_id } - | Self::CodeConnectMap { file_key, node_id } - | Self::Screenshot { file_key, node_id } => { + | Self::CodeConnectMap { file_key, node_id } => { json!({ "fileKey": file_key, "nodeId": node_id }) } + // The official `get_screenshot` answers by default with the PNG's + // URL and curl instructions as text, and inlines the PNG only when + // asked; the collector has no HTTP client, so it asks. And it caps + // the longer edge at 1024px unless told otherwise, which would + // hand back a 1920px screen at half size; the cap is raised to the + // most this accepts, and a node smaller than that keeps its size. + Self::Screenshot { file_key, node_id } => json!({ + "fileKey": file_key, + "nodeId": node_id, + "enableBase64Response": true, + "maxDimension": MAX_REFERENCE_PNG_DIMENSION, + }), + // These variants all route to the official `use_figma` tool, whose + // schema is `{ fileKey, code, description, skillNames? }` with + // `additionalProperties: false`. `nodeId` is NOT part of that + // schema and must never appear here (real Figma MCP hosts reject + // unknown properties); the node this call targets is tracked + // separately in `PlannedCall::expected_node_id` and surfaced to + // handoff consumers outside `arguments`, not inside it. Self::Snapshot { file_key, node_id, @@ -610,7 +655,7 @@ impl ReadToolCall { root_ids, } => json!({ "fileKey": file_key, - "nodeId": node_id, + "description": self.description(), "code": script.source(node_id, ScriptInputs { resources: resources.as_ref(), snapshot: snapshot.as_ref(), @@ -624,7 +669,7 @@ impl ReadToolCall { options, } => json!({ "fileKey": file_key, - "nodeId": node_id, + "description": self.description(), "code": BuiltinScript::SearchSnapshot.source(node_id, ScriptInputs { search: Some(options), ..ScriptInputs::default() @@ -632,6 +677,7 @@ impl ReadToolCall { }), Self::PageCatalog { file_key } => json!({ "fileKey": file_key, + "description": self.description(), "code": BuiltinScript::PageCatalog.source("", ScriptInputs::default()) }), Self::ExploreSnapshot { @@ -640,19 +686,23 @@ impl ReadToolCall { options, } => json!({ "fileKey": file_key, - "nodeId": node_id, + "description": self.description(), "code": BuiltinScript::ExploreSnapshot.source(node_id, ScriptInputs { explore: Some(options), ..ScriptInputs::default() }) }), - Self::FastTheme { file_key } => json!({ + Self::FastTheme { file_key, offset } => json!({ "fileKey": file_key, - "code": BuiltinScript::FastThemeEnvelope.source("", ScriptInputs::default()) + "description": self.description(), + "code": BuiltinScript::FastThemeEnvelope.source("", ScriptInputs { + theme_offset: Some(*offset), + ..ScriptInputs::default() + }) }), Self::LargeValue { file_key, options } => json!({ "fileKey": file_key, - "nodeId": options.node_id, + "description": self.description(), "code": BuiltinScript::LargeValue.source(&options.node_id, ScriptInputs { large_value: Some(options), ..ScriptInputs::default() @@ -664,7 +714,7 @@ impl ReadToolCall { request, } => json!({ "fileKey": file_key, - "nodeId": request.node_id, + "description": self.description(), "code": BuiltinScript::AssetExport.source(&request.node_id, ScriptInputs { asset: Some((request, version.as_deref())), ..ScriptInputs::default() @@ -673,6 +723,63 @@ impl ReadToolCall { }; value.as_object().cloned().unwrap_or_default() } + + /// Human-readable `description` required by the official `use_figma` + /// schema. Only meaningful for the `use_figma`-routed variants; other + /// variants never reach this (their `arguments()` don't call it). + fn description(&self) -> String { + let node_id = self.node_id_for_description(); + match self { + Self::Snapshot { script, .. } => match script { + BuiltinScript::FastSnapshotEnvelope | BuiltinScript::MultiRootSnapshotEnvelope => { + format!("devup-mcp fast node snapshot for node {node_id} (read-only)") + } + BuiltinScript::NodeSnapshot => { + format!("devup-mcp paginated node snapshot for node {node_id} (read-only)") + } + BuiltinScript::SectionIndex => { + format!("devup-mcp Section screen index for node {node_id} (read-only)") + } + BuiltinScript::VariableCatalog => { + format!("devup-mcp local variable/style catalog for node {node_id} (read-only)") + } + BuiltinScript::LocalVariables | BuiltinScript::UsedResources => { + format!( + "devup-mcp variable/style resource batch for node {node_id} (read-only)" + ) + } + _ => format!("devup-mcp Figma read for node {node_id} (read-only)"), + }, + Self::SearchSnapshot { .. } => { + format!("devup-mcp page-scoped name search for node {node_id} (read-only)") + } + Self::PageCatalog { .. } => "devup-mcp file page catalog (read-only)".to_owned(), + Self::ExploreSnapshot { .. } => { + format!("devup-mcp screen candidate exploration near node {node_id} (read-only)") + } + Self::FastTheme { .. } => { + "devup-mcp fast file-wide theme snapshot (read-only)".to_owned() + } + Self::LargeValue { .. } => { + format!("devup-mcp large field value fragment for node {node_id} (read-only)") + } + Self::AssetExport { .. } => { + format!("devup-mcp asset export for node {node_id} (read-only)") + } + _ => "devup-mcp Figma read (read-only)".to_owned(), + } + } + + fn node_id_for_description(&self) -> &str { + match self { + Self::Snapshot { node_id, .. } + | Self::SearchSnapshot { node_id, .. } + | Self::ExploreSnapshot { node_id, .. } => node_id, + Self::LargeValue { options, .. } => &options.node_id, + Self::AssetExport { request, .. } => &request.node_id, + _ => "", + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/devup-mcp-figma/src/url.rs b/crates/devup-mcp-figma/src/url.rs index 50a61258..82bf7e1c 100644 --- a/crates/devup-mcp-figma/src/url.rs +++ b/crates/devup-mcp-figma/src/url.rs @@ -15,11 +15,11 @@ pub struct FigmaTarget { impl FigmaTarget { pub fn parse(input: &str) -> Result { let url = Url::parse(input) - .map_err(|_| DevupError::unsupported_file("올바른 Figma 링크가 아닙니다."))?; + .map_err(|_| DevupError::unsupported_file("Not a valid Figma link."))?; if url.scheme() != "https" || !matches!(url.host_str(), Some("figma.com" | "www.figma.com")) { return Err(DevupError::unsupported_file( - "HTTPS Figma 디자인 링크만 사용할 수 있습니다.", + "Only HTTPS Figma design links are supported.", )); } @@ -34,7 +34,7 @@ impl FigmaTarget { } _ => { return Err(DevupError::unsupported_file( - "지원하는 Figma design, file 또는 branch 링크가 아닙니다.", + "Not a supported Figma design, file, or branch link.", )); } }; @@ -65,7 +65,7 @@ fn validate_key(key: &str) -> Result<(), DevupError> { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) { return Err(DevupError::unsupported_file( - "Figma 파일 또는 브랜치 키 형식이 올바르지 않습니다.", + "Figma file or branch key format is invalid.", )); } Ok(()) @@ -78,7 +78,7 @@ fn normalize_node_id(node_id: &str) -> Result { format!("{left}:{right}") } else { return Err(DevupError::unsupported_file( - "Figma node-id 형식이 올바르지 않습니다.", + "Figma node-id format is invalid.", )); }; @@ -90,7 +90,7 @@ fn normalize_node_id(node_id: &str) -> Result { && right.bytes().all(|byte| byte.is_ascii_digit())); if !valid { return Err(DevupError::unsupported_file( - "Figma node-id 형식이 올바르지 않습니다.", + "Figma node-id format is invalid.", )); } Ok(normalized) diff --git a/crates/devup-mcp-figma/src/variables.rs b/crates/devup-mcp-figma/src/variables.rs index 7c16c29d..c7c8717e 100644 --- a/crates/devup-mcp-figma/src/variables.rs +++ b/crates/devup-mcp-figma/src/variables.rs @@ -125,7 +125,7 @@ pub(crate) fn merge_variable_results( let mut style = styles_by_id.remove(&style_ref.id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma style이 삭제되거나 변경되었습니다.", + "A Figma style was deleted or changed during collection.", true, ) })?; @@ -271,7 +271,7 @@ fn expand_consumer_entry(entry: Value) -> Result { fn incomplete_consumers() -> DevupError { DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma style consumer 목록이 변경되었습니다.", + "The Figma style consumer list changed during collection.", true, ) } @@ -307,7 +307,7 @@ where fn invalid_variable_result() -> DevupError { DevupError::new( ErrorCode::DevupThemeConflict, - "Figma MCP 응답에서 변수/style batch를 찾지 못했습니다.", + "variable/style batch not found in the Figma MCP response.", false, ) } diff --git a/crates/devup-mcp-figma/tests/assets.rs b/crates/devup-mcp-figma/tests/assets.rs index 539705af..d0fbd628 100644 --- a/crates/devup-mcp-figma/tests/assets.rs +++ b/crates/devup-mcp-figma/tests/assets.rs @@ -2,11 +2,12 @@ use std::collections::BTreeMap; use base64::{Engine as _, engine::general_purpose::STANDARD}; use devup_mcp_figma::{ - AssetFormat, AssetRequest, AssetSelection, AssetStatus, CollectionRequest, CollectionScope, - CollectorSession, CollectorStep, FigmaTarget, RawNode, ReadToolCall, Snapshot, UpstreamResult, - asset_export_from_result, discover_asset_manifest, + AssetExportOutcome, AssetFormat, AssetRequest, AssetSelection, AssetStatus, CollectionRequest, + CollectionScope, CollectorSession, CollectorStep, FigmaTarget, RawNode, ReadToolCall, Snapshot, + UpstreamResult, asset_export_from_result, discover_asset_manifest, validate_asset_requests, }; use serde_json::{Map, json}; +use sha2::Digest as _; fn node(id: &str, node_type: &str, fields: serde_json::Value) -> RawNode { RawNode { @@ -24,7 +25,7 @@ fn collector_exports_only_explicit_assets_and_preserves_snapshot_on_export_failu FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=1-1").unwrap(); let mut request = CollectionRequest::new(target, CollectionScope::Node); request.asset_selections = vec![AssetSelection { - asset_id: "1:1:fills:1".to_owned(), + asset_id: "1:1:fills:0".to_owned(), format: AssetFormat::Png, scale: 2, }]; @@ -54,7 +55,7 @@ fn collector_exports_only_explicit_assets_and_preserves_snapshot_on_export_failu "fileKey":"FileKey123","version":"v1","rootIds":["1:1"], "nodes":[ serde_json::to_value(snapshot().nodes["1:1"].clone()).unwrap(), - {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} + {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"offset":0,"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} ],"diagnostics":[] }), }, @@ -67,14 +68,14 @@ fn collector_exports_only_explicit_assets_and_preserves_snapshot_on_export_failu let ReadToolCall::AssetExport { request, .. } = asset_call.call else { panic!("asset export call") }; - assert_eq!(request.asset_id, "1:1:fills:1"); + assert_eq!(request.asset_id, "1:1:fills:0"); collector .accept( &asset_call.id, UpstreamResult { raw: json!({ "kind":"devupAssetExport","fileKey":"FileKey123","version":"v1", - "assetId":"1:1:fills:1","nodeId":"1:1","field":"fills/1", + "assetId":"1:1:fills:0","nodeId":"1:1","field":"fills/0", "imageHash":"image-hash-123","format":"png","scale":2, "status":"failed","byteLength":null,"sha256":null, "errorCode":"DEVUP_ASSET_EXPORT_FAILED" @@ -96,29 +97,109 @@ fn collector_exports_only_explicit_assets_and_preserves_snapshot_on_export_failu ); } +/// Figma's remote MCP returns a written PNG as an image attachment but does +/// not return a written `.svg` at all — the response carries only the +/// descriptor, as JSON inside a text block. So an SVG export inlines its own +/// payload beside the descriptor, and the payload search has to step through +/// that JSON encoding to reach it. Before this, every SVG request failed with +/// "asset export response does not contain the requested binary" while PNG +/// worked, and the error said nothing about why. +#[test] +fn an_svg_payload_inlined_beside_the_descriptor_is_decoded_from_its_text() { + let svg = ""; + let bytes = svg.as_bytes(); + let sha256: String = sha2::Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let descriptor = json!({ + "kind": "devupAssetExport", "fileKey": "FileKey123", "version": "v1", + "assetId": "1:2:node", "nodeId": "1:2", "field": "node", + "imageHash": null, "format": "svg", "scale": 1, + "status": "exported", "byteLength": bytes.len(), "sha256": sha256, + "mimeType": "image/svg+xml", "text": svg, "errorCode": null + }); + // Exactly how it arrives: the descriptor serialized into a text block. + let result = UpstreamResult { + raw: json!({"content": [{"type": "text", "text": descriptor.to_string()}]}), + }; + let request = AssetRequest { + asset_id: "1:2:node".to_owned(), + node_id: "1:2".to_owned(), + field: "node".to_owned(), + image_hash: None, + format: AssetFormat::Svg, + scale: 1, + }; + + let AssetExportOutcome::Entry(entry) = + asset_export_from_result(&result, "FileKey123", Some("v1"), &request) + .expect("an inlined SVG payload must decode") + else { + panic!("an SVG under the inline cap is exported in one answer") + }; + + assert_eq!(entry.status, AssetStatus::Exported); + assert_eq!(entry.byte_length, Some(bytes.len())); + assert_eq!(entry.mime_type.as_deref(), Some("image/svg+xml")); + // Re-encoded to base64 so every consumer downstream is shape-independent. + let decoded = STANDARD + .decode(entry.data_base64.expect("payload").as_bytes()) + .expect("base64"); + assert_eq!(decoded, bytes); +} + +/// A response that carries no payload at all must say what it *did* carry, +/// so "nothing came back", "wrong mime type" and "unread field" stay +/// distinguishable instead of collapsing into one opaque sentence. +#[test] +fn a_missing_asset_payload_reports_the_shapes_that_were_present() { + let descriptor = json!({ + "kind": "devupAssetExport", "fileKey": "FileKey123", "version": "v1", + "assetId": "1:2:node", "nodeId": "1:2", "field": "node", + "imageHash": null, "format": "svg", "scale": 1, + "status": "exported", "byteLength": 10, "sha256": "00", "errorCode": null + }); + let result = UpstreamResult { + raw: json!({"content": [{"type": "text", "text": descriptor.to_string()}]}), + }; + let request = AssetRequest { + asset_id: "1:2:node".to_owned(), + node_id: "1:2".to_owned(), + field: "node".to_owned(), + image_hash: None, + format: AssetFormat::Svg, + scale: 1, + }; + + let error = asset_export_from_result(&result, "FileKey123", Some("v1"), &request) + .expect_err("no payload is an error"); + + assert_eq!(error.details["expectedMimeType"], "image/svg+xml"); + let observed = error.details["observed"].as_array().expect("observed"); + assert!( + observed.iter().any(|entry| entry + .as_str() + .unwrap_or_default() + .contains("carries=[text]")), + "the diagnostic must name the shapes that were present: {observed:?}" + ); +} + fn snapshot() -> Snapshot { Snapshot { file_key: "FileKey123".to_owned(), version: Some("v1".to_owned()), roots: vec!["1:1".to_owned()], - nodes: [ - node( - "1:1", - "FRAME", - json!({ - "childrenIds": ["1:2"], - "fills": [ - {"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}, - {"type": "IMAGE", "imageHash": "image-hash-123", "scaleMode": "FILL"} - ] - }), - ), - node( - "1:2", - "VECTOR", - json!({"parentId": "1:1", "childrenIds": [], "fills": []}), - ), - ] + nodes: [node( + "1:1", + "FRAME", + json!({ + "childrenIds": [], + "isAsset": true, + "fills": [{"type": "IMAGE", "imageHash": "image-hash-123", "scaleMode": "FILL"}] + }), + )] .into_iter() .map(|node| (node.id.clone(), node)) .collect(), @@ -131,19 +212,205 @@ fn manifest_preserves_image_and_vector_source_details_without_exporting_bytes() let manifest = discover_asset_manifest(&snapshot()); assert_eq!(manifest.version, 1); - assert_eq!(manifest.assets.len(), 2); - assert_eq!(manifest.assets[0].asset_id, "1:1:fills:1"); + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].asset_id, "1:1:fills:0"); assert_eq!(manifest.assets[0].node_id, "1:1"); - assert_eq!(manifest.assets[0].field, "fills/1"); + assert_eq!(manifest.assets[0].field, "fills/0"); assert_eq!(manifest.assets[0].source_kind, "image-fill"); assert_eq!( manifest.assets[0].image_hash.as_deref(), Some("image-hash-123") ); assert_eq!(manifest.assets[0].status, AssetStatus::Available); - assert_eq!(manifest.assets[1].asset_id, "1:2:node"); - assert_eq!(manifest.assets[1].source_kind, "vector-node"); - assert!(manifest.assets[1].data_base64.is_none()); + assert!(manifest.assets[0].data_base64.is_none()); +} + +fn hidden_asset_snapshot() -> Snapshot { + Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["1:1".to_owned()], + nodes: [node( + "1:1", + "FRAME", + json!({ + "childrenIds": [], + "visible": false, + "isAsset": true, + "fills": [{"type": "IMAGE", "imageHash": "image-hash-123", "scaleMode": "FILL"}] + }), + )] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + } +} + +#[test] +fn hidden_node_is_reported_as_unexportable_instead_of_available() { + let manifest = discover_asset_manifest(&hidden_asset_snapshot()); + + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].status, AssetStatus::Failed); + assert_eq!( + manifest.assets[0].error_code.as_deref(), + Some("DEVUP_ASSET_NODE_HIDDEN") + ); +} + +#[test] +fn requesting_a_hidden_asset_is_rejected_with_the_reason() { + let error = validate_asset_requests( + &hidden_asset_snapshot(), + &[AssetRequest { + asset_id: "1:1:fills:0".to_owned(), + node_id: "1:1".to_owned(), + field: "fills/0".to_owned(), + image_hash: Some("image-hash-123".to_owned()), + format: AssetFormat::Png, + scale: 1, + }], + ) + .expect_err("a hidden node cannot be exported, so the request must be refused"); + + assert!(format!("{error:?}").contains("hidden"), "{error:?}"); +} + +fn manifest_for(roots: &[&str], nodes: Vec) -> devup_mcp_figma::AssetManifest { + discover_asset_manifest(&Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: roots.iter().map(|root| (*root).to_owned()).collect(), + nodes: nodes + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + }) +} + +#[test] +fn icon_container_wins_over_its_vector_fragments() { + let manifest = manifest_for( + &["3997:46297"], + vec![ + node( + "3997:46297", + "FRAME", + json!({"name": "input", "childrenIds": ["3997:46298", "3997:46301"]}), + ), + node( + "3997:46298", + "FRAME", + json!({ + "name": "kakao-talk_2111496 1", + "parentId": "3997:46297", + "isAsset": true, + "childrenIds": ["3997:46299", "3997:46300"] + }), + ), + node( + "3997:46299", + "VECTOR", + json!({"name": "Vector", "parentId": "3997:46298"}), + ), + node( + "3997:46300", + "VECTOR", + json!({"name": "Vector", "parentId": "3997:46298"}), + ), + node( + "3997:46301", + "TEXT", + json!({"name": "카카오로 공유하기", "parentId": "3997:46297"}), + ), + ], + ); + + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].asset_id, "3997:46298:node"); + assert_eq!(manifest.assets[0].node_id, "3997:46298"); + assert_eq!(manifest.assets[0].field, "node"); + assert_eq!(manifest.assets[0].source_kind, "vector-node"); + assert_eq!(manifest.assets[0].image_hash, None); +} + +#[test] +fn bare_vector_is_an_svg_asset_but_text_is_not() { + let manifest = manifest_for( + &["1:vector", "1:text"], + vec![ + node("1:vector", "VECTOR", json!({})), + node("1:text", "TEXT", json!({})), + ], + ); + + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].asset_id, "1:vector:node"); + assert_eq!(manifest.assets[0].source_kind, "vector-node"); +} + +#[test] +fn decorated_single_child_containers_do_not_replace_their_children() { + let manifest = manifest_for( + &["1:padding", "1:filled"], + vec![ + node( + "1:padding", + "FRAME", + json!({"childrenIds": ["1:padding-vector"], "paddingLeft": 8}), + ), + node( + "1:padding-vector", + "VECTOR", + json!({"parentId": "1:padding"}), + ), + node( + "1:filled", + "FRAME", + json!({ + "childrenIds": ["1:filled-vector"], + "fills": [{"type": "SOLID", "visible": true}] + }), + ), + node("1:filled-vector", "VECTOR", json!({"parentId": "1:filled"})), + ], + ); + + let asset_ids = manifest + .assets + .iter() + .map(|asset| asset.asset_id.as_str()) + .collect::>(); + assert_eq!( + asset_ids, + vec!["1:filled-vector:node", "1:padding-vector:node"] + ); +} + +#[test] +fn asset_leaf_with_one_non_tiled_image_fill_is_a_png_asset() { + let manifest = manifest_for( + &["1:image"], + vec![node( + "1:image", + "RECTANGLE", + json!({ + "isAsset": true, + "fills": [{"type": "IMAGE", "scaleMode": "FILL", "imageRef": "image-ref-123"}] + }), + )], + ); + + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].asset_id, "1:image:fills:0"); + assert_eq!(manifest.assets[0].field, "fills/0"); + assert_eq!(manifest.assets[0].source_kind, "image-fill"); + assert_eq!( + manifest.assets[0].image_hash.as_deref(), + Some("image-ref-123") + ); } #[test] @@ -170,7 +437,11 @@ fn exported_asset_validates_descriptor_bytes_hash_and_requested_settings() { ]}), }; - let exported = asset_export_from_result(&result, "FileKey123", Some("v1"), &request).unwrap(); + let AssetExportOutcome::Entry(exported) = + asset_export_from_result(&result, "FileKey123", Some("v1"), &request).unwrap() + else { + panic!("a PNG is exported in one answer") + }; assert_eq!(exported.status, AssetStatus::Exported); assert_eq!(exported.byte_length, Some(bytes.len())); let encoded = STANDARD.encode(bytes); @@ -187,3 +458,454 @@ fn exported_asset_validates_descriptor_bytes_hash_and_requested_settings() { .into(); assert!(asset_export_from_result(&mismatched, "FileKey123", Some("v1"), &request).is_err()); } + +/// An SVG past what one text answer holds is announced, not written: the +/// export script answers `chunked` with the length and hash, the collector +/// reads it back through the large-value script under the virtual field +/// `$export:svg` a fragment at a time, and the bytes the fragments assemble +/// to are the asset. Before this a 14 KB logo failed outright. +#[test] +fn an_svg_over_the_inline_cap_arrives_in_fragments() { + use devup_mcp_figma::{LargeValueReadOptions, SVG_EXPORT_FIELD}; + + let target = + FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=1-1").unwrap(); + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.asset_selections = vec![AssetSelection { + asset_id: "1:1:node".to_owned(), + format: AssetFormat::Svg, + scale: 1, + }]; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata) = collector.advance().unwrap() else { + panic!("metadata") + }; + collector + .accept( + &metadata.id, + UpstreamResult { + raw: json!({"structuredContent":{"devupMetadata":{ + "fileKey":"FileKey123","version":"v1","rootId":"1:1", + "nodes":[{"id":"1:1","type":"VECTOR","childrenIds":[],"descendantCount":0}] + }}}), + }, + ) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!("snapshot") + }; + let vector = node( + "1:1", + "VECTOR", + json!({"childrenIds": [], "isAsset": true, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}, "visible": true}]}), + ); + collector + .accept( + &snapshot_call.id, + UpstreamResult { + raw: json!({ + "fileKey":"FileKey123","version":"v1","rootIds":["1:1"], + "nodes":[ + serde_json::to_value(vector).unwrap(), + {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"offset":0,"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} + ],"diagnostics":[] + }), + }, + ) + .unwrap(); + + // The export announces an SVG of 40 bytes, to come in two fragments. + let svg = b""; + assert_eq!(svg.len(), 41); + let sha256: String = sha2::Sha256::digest(svg) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let CollectorStep::Call(asset_call) = collector.advance().unwrap() else { + panic!("explicit asset export") + }; + collector + .accept( + &asset_call.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": json!({ + "kind":"devupAssetExport","fileKey":"FileKey123","version":"v1", + "assetId":"1:1:node","nodeId":"1:1","field":"node", + "imageHash":null,"format":"svg","scale":1, + "status":"chunked","byteLength":svg.len(),"sha256":sha256, + "cursor":{"nextOffset":0,"maxChunkBytes":24},"errorCode":null + }).to_string()}]}), + }, + ) + .unwrap(); + + // Then the fragments, each asked for under the virtual field. + let mut offset = 0; + while offset < svg.len() { + let CollectorStep::Call(fragment_call) = collector.advance().unwrap() else { + panic!("a fragment read is expected at offset {offset}") + }; + let ReadToolCall::LargeValue { options, .. } = &fragment_call.call else { + panic!("fragment reads go through the large-value script") + }; + let LargeValueReadOptions { + node_id, + field, + offset: asked, + max_chunk_bytes, + .. + } = options; + assert_eq!(node_id, "1:1"); + assert_eq!(field, SVG_EXPORT_FIELD); + assert_eq!(*asked, offset); + let next = (offset + max_chunk_bytes).min(svg.len()); + collector + .accept( + &fragment_call.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": json!({ + "kind":"devupLargeValueFragment","fileKey":"FileKey123","version":"v1", + "nodeId":"1:1","field":SVG_EXPORT_FIELD, + "offset":offset,"nextOffset":next,"byteLength":svg.len(),"sha256":sha256, + "dataBase64":STANDARD.encode(&svg[offset..next]),"complete":next == svg.len() + }).to_string()}]}), + }, + ) + .unwrap(); + offset = next; + } + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("complete") + }; + assert_eq!(parts.assets.len(), 1); + let exported = &parts.assets[0]; + assert_eq!(exported.status, AssetStatus::Exported); + assert_eq!(exported.asset_id, "1:1:node"); + assert_eq!(exported.field, "node"); + assert_eq!(exported.byte_length, Some(svg.len())); + assert_eq!(exported.mime_type.as_deref(), Some("image/svg+xml")); + assert_eq!( + STANDARD + .decode(exported.data_base64.as_deref().unwrap()) + .unwrap(), + svg + ); + // The announcement, then two fragments of 24 bytes. + assert_eq!(parts.stats.figma_tool_calls, 5); +} + +/// A PNG past what one attachment carries comes back in fragments too. +/// Figma's remote MCP returns a written PNG as an attachment only up to +/// about a megabyte once base64-encoded: the devup-ui landing page's hero, +/// 950 KB at 1232x1232, was written, reported exported, and never arrived. +/// Now the export script announces it `chunked` and the collector reads it +/// back under `$export:png@`, the scale on the field so the re-export +/// behind each fragment is the same bytes that were announced. +#[test] +fn a_png_over_the_attachment_cap_arrives_in_fragments() { + use devup_mcp_figma::{LargeValueReadOptions, PNG_EXPORT_FIELD}; + + let target = + FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=1-1").unwrap(); + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.asset_selections = vec![AssetSelection { + asset_id: "1:1:fills:0".to_owned(), + format: AssetFormat::Png, + scale: 2, + }]; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata) = collector.advance().unwrap() else { + panic!("metadata") + }; + collector + .accept( + &metadata.id, + UpstreamResult { + raw: json!({"structuredContent":{"devupMetadata":{ + "fileKey":"FileKey123","version":"v1","rootId":"1:1", + "nodes":[{"id":"1:1","type":"RECTANGLE","childrenIds":[],"descendantCount":0}] + }}}), + }, + ) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!("snapshot") + }; + let picture = node( + "1:1", + "RECTANGLE", + json!({"childrenIds": [], "isAsset": true, "fills": [{"type": "IMAGE", "imageHash": "abc", "scaleMode": "FILL", "visible": true}]}), + ); + collector + .accept( + &snapshot_call.id, + UpstreamResult { + raw: json!({ + "fileKey":"FileKey123","version":"v1","rootIds":["1:1"], + "nodes":[ + serde_json::to_value(picture).unwrap(), + {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"offset":0,"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} + ],"diagnostics":[] + }), + }, + ) + .unwrap(); + + // The export announces a PNG of 100 bytes, to come in three fragments. + // Binary, not text: every byte value below is outside UTF-8. + let png: Vec = (0..100u8).map(|i| 0x80 | i).collect(); + let sha256: String = sha2::Sha256::digest(&png) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let CollectorStep::Call(asset_call) = collector.advance().unwrap() else { + panic!("explicit asset export") + }; + collector + .accept( + &asset_call.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": json!({ + "kind":"devupAssetExport","fileKey":"FileKey123","version":"v1", + "assetId":"1:1:fills:0","nodeId":"1:1","field":"fills/0", + "imageHash":"abc","format":"png","scale":2, + "status":"chunked","byteLength":png.len(),"sha256":sha256, + "cursor":{"nextOffset":0,"maxChunkBytes":40},"errorCode":null + }).to_string()}]}), + }, + ) + .unwrap(); + + let expected_field = format!("{PNG_EXPORT_FIELD}@2"); + let mut offset = 0; + while offset < png.len() { + let CollectorStep::Call(fragment_call) = collector.advance().unwrap() else { + panic!("a fragment read is expected at offset {offset}") + }; + let ReadToolCall::LargeValue { options, .. } = &fragment_call.call else { + panic!("fragment reads go through the large-value script") + }; + let LargeValueReadOptions { + node_id, + field, + offset: asked, + max_chunk_bytes, + .. + } = options; + assert_eq!(node_id, "1:1"); + assert_eq!(field, &expected_field, "the scale rides on the field"); + assert_eq!(*asked, offset); + let next = (offset + max_chunk_bytes).min(png.len()); + collector + .accept( + &fragment_call.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": json!({ + "kind":"devupLargeValueFragment","fileKey":"FileKey123","version":"v1", + "nodeId":"1:1","field":expected_field, + "offset":offset,"nextOffset":next,"byteLength":png.len(),"sha256":sha256, + "dataBase64":STANDARD.encode(&png[offset..next]),"complete":next == png.len() + }).to_string()}]}), + }, + ) + .unwrap(); + offset = next; + } + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("complete") + }; + assert_eq!(parts.assets.len(), 1); + let exported = &parts.assets[0]; + assert_eq!(exported.status, AssetStatus::Exported); + assert_eq!(exported.asset_id, "1:1:fills:0"); + assert_eq!(exported.field, "fills/0"); + assert_eq!(exported.format, Some(AssetFormat::Png)); + assert_eq!(exported.scale, Some(2)); + assert_eq!(exported.byte_length, Some(png.len())); + assert_eq!(exported.mime_type.as_deref(), Some("image/png")); + assert_eq!( + STANDARD + .decode(exported.data_base64.as_deref().unwrap()) + .unwrap(), + png + ); + // The announcement, then three fragments of 40 bytes. + assert_eq!(parts.stats.figma_tool_calls, 6); +} + +/// A PDF is neither text nor an attachment Figma returns, so an announced +/// fragment transport for one is refused where the announcement is read, +/// not after fragments have been fetched for nothing. +#[test] +fn only_svg_and_png_exports_are_carried_in_fragments() { + let request = AssetRequest { + asset_id: "1:1:node".to_owned(), + node_id: "1:1".to_owned(), + field: "node".to_owned(), + image_hash: None, + format: AssetFormat::Pdf, + scale: 1, + }; + let result = json!({"content": [{"type": "text", "text": json!({ + "kind":"devupAssetExport","fileKey":"FileKey123","version":"v1", + "assetId":"1:1:node","nodeId":"1:1","field":"node", + "imageHash":null,"format":"pdf","scale":1, + "status":"chunked","byteLength":100,"sha256":"00", + "cursor":{"nextOffset":0,"maxChunkBytes":40},"errorCode":null + }).to_string()}]}); + let error = asset_export_from_result( + &UpstreamResult { raw: result }, + "FileKey123", + Some("v1"), + &request, + ) + .unwrap_err(); + assert!(error.to_string().contains("SVG or PNG"), "{error}"); +} + +/// A frame that draws nothing of its own and holds a single picture is that +/// picture, and the code names the file after the frame. It does not carry +/// the fill, though, so it was listed as `:fills:0` - a fill index +/// the node does not have, which Figma can only answer with +/// DEVUP_ASSET_SOURCE_CHANGED. The devup-ui landing page's footer logo sits +/// in such a frame and was the one asset of 182 that never arrived. It is +/// listed as the node instead, which renders to the same picture. +#[test] +fn a_frame_holding_one_picture_is_exported_as_the_node_not_a_fill_it_lacks() { + let manifest = manifest_for( + &["1:frame"], + vec![ + node( + "1:frame", + "FRAME", + json!({"name": "Logo", "childrenIds": ["1:picture"]}), + ), + node( + "1:picture", + "FRAME", + json!({ + "name": "Bitmap", + "parentId": "1:frame", + "isAsset": true, + "childrenIds": [], + "fills": [{"type": "IMAGE", "scaleMode": "FIT", "imageHash": "image-hash-123"}] + }), + ), + ], + ); + + assert_eq!(manifest.assets.len(), 1); + let asset = &manifest.assets[0]; + assert_eq!(asset.asset_id, "1:frame:node"); + assert_eq!(asset.node_id, "1:frame", "the frame is what the code names"); + assert_eq!( + asset.field, "node", + "the frame has no fills, so its bytes come from rendering it" + ); + assert_eq!(asset.source_kind, "image-node"); + assert_eq!(asset.image_hash.as_deref(), Some("image-hash-123")); + assert_eq!(asset.status, AssetStatus::Available); + + // And the request that reaches Figma names the node, so the export + // script's fill check - the one that refused this - never applies. + let requests = devup_mcp_figma::resolve_asset_selections( + &Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["1:frame".to_owned()], + nodes: [ + node( + "1:frame", + "FRAME", + json!({"name": "Logo", "childrenIds": ["1:picture"]}), + ), + node( + "1:picture", + "FRAME", + json!({ + "name": "Bitmap", + "parentId": "1:frame", + "isAsset": true, + "childrenIds": [], + "fills": [{"type": "IMAGE", "scaleMode": "FIT", "imageHash": "image-hash-123"}] + }), + ), + ] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + }, + &[AssetSelection { + asset_id: "1:frame:node".to_owned(), + format: AssetFormat::Png, + scale: 1, + }], + ) + .expect("the frame is exportable"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].node_id, "1:frame"); + assert_eq!(requests[0].field, "node"); + assert_eq!( + devup_mcp_figma::source_kind_of(&requests[0]), + "image-node", + "what the export reports has to match what discovery listed" + ); +} + +/// A node pushed entirely outside an ancestor that clips is visible and +/// opaque and still draws nothing, and Figma says so by leaving +/// `absoluteRenderBounds` off it. Asked to export one, Figma refuses - and +/// the refusal used to arrive from inside Figma, after the request, taking +/// the whole batch of sixteen down with it. The devup-ui landing page's +/// mobile and tablet each carry one such icon. +#[test] +fn a_node_clipped_out_of_sight_is_reported_as_unexportable() { + let manifest = manifest_for( + &["1:clipped", "1:drawn"], + vec![ + node( + "1:clipped", + "VECTOR", + json!({ + "name": "off the edge", + "visible": true, + "opacity": 1, + "absoluteBoundingBox": {"x": 900, "y": 0, "width": 10, "height": 10} + }), + ), + node( + "1:drawn", + "VECTOR", + json!({ + "name": "on the page", + "visible": true, + "opacity": 1, + "absoluteBoundingBox": {"x": 0, "y": 0, "width": 10, "height": 10}, + "absoluteRenderBounds": {"x": 0, "y": 0, "width": 10, "height": 10} + }), + ), + ], + ); + + let clipped = manifest + .assets + .iter() + .find(|asset| asset.node_id == "1:clipped") + .expect("the node is still listed, so the code pointing at it is accounted for"); + assert_eq!(clipped.status, AssetStatus::Failed); + assert_eq!( + clipped.error_code.as_deref(), + Some("DEVUP_ASSET_NODE_HIDDEN"), + "the reason travels with the entry instead of arriving later from Figma" + ); + + let drawn = manifest + .assets + .iter() + .find(|asset| asset.node_id == "1:drawn") + .expect("a node that renders is listed"); + assert_eq!(drawn.status, AssetStatus::Available); +} diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index b480a907..20f142b0 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -18,11 +18,19 @@ fn exact_node_fast_path_completes_in_one_call() { panic!("fast snapshot call expected") }; assert_eq!(fast_call.call.tool_name(), "use_figma"); + let arguments = fast_call.call.arguments(); + assert!(!arguments.contains_key("nodeId")); assert!( - fast_call.call.arguments()["code"] + arguments["code"] .as_str() .unwrap() - .contains("devupFastSnapshotDescriptor") + .contains("devupFastSnapshotEnvelope") + ); + assert!( + !arguments["code"] + .as_str() + .unwrap() + .contains("figma.io.write") ); collector @@ -35,7 +43,7 @@ fn exact_node_fast_path_completes_in_one_call() { assert_eq!(parts.snapshot_chunks.len(), 1); assert_eq!(parts.snapshot_chunks[0].nodes.len(), 1); assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-envelope-v1"); + assert_eq!(parts.stats.transport, "text"); assert!(!parts.stats.fallback_used); assert_eq!(parts.stats.node_count, 1); assert_eq!(parts.stats.variable_count, 0); @@ -68,10 +76,140 @@ fn exact_node_fast_path_accepts_the_stringified_handoff_contract() { panic!("stringified fast snapshot should complete without fallback") }; assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-envelope-v1"); + assert_eq!(parts.stats.transport, "text"); assert!(!parts.stats.fallback_used); } +/// The about screen as the script really answers it: asked for `mobile`, it +/// gathers `tablet` and `desktop` beside it and pages the three through the +/// fast path. The collector must ride that to the end — it had been reading +/// the family as a target mismatch and restarting a legacy walk that cost +/// 282 calls for a screen the fast path pages through in a handful. +#[test] +fn a_family_gathered_around_the_target_pages_through_the_fast_path() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(first_call) = collector.advance().unwrap() else { + panic!("fast snapshot call expected") + }; + let family = json!(["1:2", "2:2", "3:2"]); + collector + .accept( + &first_call.id, + family_page( + &family, + json!([ + {"id": "1:2", "type": "FRAME", "fields": {"name": "mobile", "childrenIds": ["1:3"]}, "extra": {}, "fieldErrors": {}}, + {"id": "2:2", "type": "FRAME", "fields": {"name": "tablet", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, + {"id": "3:2", "type": "FRAME", "fields": {"name": "desktop", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, + ]), + (0, 3, false, 4), + ), + ) + .unwrap(); + + let CollectorStep::Call(second_call) = collector.advance().unwrap() else { + panic!("the second fast page expected, not a legacy restart") + }; + assert_eq!(second_call.call.tool_name(), "use_figma"); + let code = second_call.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(code.contains("devupFastSnapshotEnvelope"), "{code}"); + assert!(code.contains("\"offset\":3"), "{code}"); + collector + .accept( + &second_call.id, + family_page( + &family, + json!([ + {"id": "1:3", "type": "TEXT", "fields": {"name": "Child", "characters": "Done", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, + ]), + (3, 4, true, 4), + ), + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("the family should complete on the fast path") + }; + assert_eq!(parts.stats.figma_tool_calls, 2); + assert_eq!(parts.stats.transport, "text-paginated"); + assert!( + !parts.stats.fallback_used, + "{:?}", + parts.stats.fallback_reason + ); + assert_eq!(parts.stats.node_count, 4); + assert_eq!(parts.metadata["rootId"], "1:2"); + assert!( + parts + .snapshot_chunks + .iter() + .all(|chunk| json!(chunk.root_ids) == family), + "every page is rooted at the family" + ); +} + +/// One page of a fast snapshot that the script rooted at `root_ids`. +fn family_page( + root_ids: &Value, + nodes: Value, + (offset, next_offset, complete, total_nodes): (u64, u64, bool, u64), +) -> UpstreamResult { + let mut nodes = nodes.as_array().cloned().unwrap(); + // The cursor marker is a node of the page and counts in `nodeCount`, as + // the script counts it. + let node_count = nodes.len() + 1; + nodes.push(json!({ + "id": "__DEVUP_SNAPSHOT_CURSOR__", + "type": "DEVUP_INTERNAL", + "fields": {"offset": offset, "nextOffset": next_offset, "complete": complete, "totalNodes": total_nodes}, + "extra": {}, + "fieldErrors": {} + })); + let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", + "schemaVersion": 1, + "source": {"fileKey": "FileKey123", "rootId": "1:2"}, + "snapshot": { + "fileKey": "FileKey123", + "version": "v1", + "rootIds": root_ids, + "nodes": nodes, + "diagnostics": [] + }, + "resources": { + "collections": [], + "variables": [], + "styles": [], + "usedRemoteVariables": [], + "localComplete": false, + "usedRemoteComplete": true, + "unresolved": [] + }, + "integrity": { + "nodeCount": node_count, + "variableRefCount": 0, + "styleRefCount": 0, + "utf8Bytes": 0 + } + }); + loop { + let bytes = serde_json::to_vec(&envelope).unwrap(); + if envelope["integrity"]["utf8Bytes"] == bytes.len() as u64 { + break; + } + envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); + } + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), + } +} + #[test] fn requested_reference_png_is_collected_after_the_design_snapshot() { let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); @@ -92,12 +230,25 @@ fn requested_reference_png_is_collected_after_the_design_snapshot() { assert_eq!(screenshot_call.call.tool_name(), "get_screenshot"); assert_eq!(screenshot_call.call.arguments()["fileKey"], "FileKey123"); assert_eq!(screenshot_call.call.arguments()["nodeId"], "1:2"); + // The official tool inlines the PNG only when asked, and halves a 1920px + // screen unless its 1024px cap is raised. + assert_eq!( + screenshot_call.call.arguments()["enableBase64Response"], + true + ); + assert_eq!(screenshot_call.call.arguments()["maxDimension"], 8192); let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + // As the official tool answers, measured 2026-09-07: the URL as JSON in + // a text block, how to fetch it in another, and the PNG inlined after. collector .accept( &screenshot_call.id, UpstreamResult { - raw: json!({"content": [{"type": "image", "mimeType": "image/png", "data": data}]}), + raw: json!({"content": [ + {"type": "text", "text": "{\"image_url\":\"https://www.figma.com/api/mcp/asset/x.png\",\"width\":1,\"height\":1,\"format\":\"png\"}"}, + {"type": "text", "text": "The screenshot is hosted at the URL in the first content entry (as JSON)."}, + {"type": "image", "mimeType": "image/png", "data": data} + ]}), }, ) .unwrap(); @@ -302,7 +453,7 @@ fn malformed_fast_result_restarts_legacy_from_metadata() { assert!(parts.stats.fallback_used); assert_eq!( parts.stats.fallback_reason.as_deref(), - Some("descriptorMissing") + Some("textEnvelopeMissing") ); assert_eq!(parts.stats.node_count, 1); } @@ -422,6 +573,7 @@ fn valid_reference_png_base64() -> &'static str { fn fast_envelope_result() -> UpstreamResult { let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "rootId": "1:2"}, "snapshot": { @@ -460,42 +612,20 @@ fn fast_envelope_result() -> UpstreamResult { } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; - - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut payload = Vec::with_capacity(envelope_bytes.len() + 8); - payload.extend_from_slice(&0_u32.to_be_bytes()); - payload.extend_from_slice(&1_u32.to_be_bytes()); - payload.extend_from_slice(&envelope_bytes); - push_png_chunk(&mut png, b"duVp", &payload); - push_png_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_png_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "1:2", - "nodeCount": 1, - "variableRefCount": 0, - "styleRefCount": 0, - "utf8Bytes": envelope_bytes.len(), - "chunkCount": 1 - }); + let _ = envelope_bytes; + // No binary transport exists any more: fast snapshots are always plain + // text. Omitting the `__DEVUP_SNAPSHOT_CURSOR__` marker node is treated + // by the decoder as a single, already-complete page. UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } fn fast_theme_envelope_result() -> UpstreamResult { let mut envelope = json!({ + "kind": "devupFastThemeEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "version": "v2"}, "resources": { @@ -524,60 +654,14 @@ fn fast_theme_envelope_result() -> UpstreamResult { } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut payload = Vec::with_capacity(envelope_bytes.len() + 8); - payload.extend_from_slice(&0_u32.to_be_bytes()); - payload.extend_from_slice(&1_u32.to_be_bytes()); - payload.extend_from_slice(&envelope_bytes); - push_png_chunk(&mut png, b"duVp", &payload); - push_png_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_png_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastThemeDescriptor", - "schemaVersion": 1, - "collectionCount": 1, - "variableCount": 1, - "styleCount": 1, - "unresolvedCount": 0, - "utf8Bytes": envelope_bytes.len(), - "chunkCount": 1 - }); + let _ = envelope_bytes; UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } -fn push_png_chunk(output: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { - output.extend_from_slice(&(data.len() as u32).to_be_bytes()); - output.extend_from_slice(chunk_type); - output.extend_from_slice(data); - let mut crc_input = Vec::with_capacity(4 + data.len()); - crc_input.extend_from_slice(chunk_type); - crc_input.extend_from_slice(data); - output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); -} - -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} - fn file_target() -> FigmaTarget { FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture").unwrap() } @@ -641,7 +725,7 @@ fn official_top_level_pages() -> UpstreamResult { raw: json!({ "content": [{ "type": "text", - "text": "No nodeId was provided. Listing the top-level pages of the document. Call get_metadata again with one of the page ids below (or any node id underneath) to get the XML metadata for that subtree.\n\nTop-level pages of the document:\n- 0:1: 표지\n- 12:34: 본문: 교정" + "text": "No nodeId was provided. Listing the top-level pages of the document. Call get_metadata again with one of the page ids below (or any node id underneath) to get the XML metadata for that subtree.\n\nTop-level pages of the document:\n- 0:1: Cover\n- 12:34: Body: Proofread" }] }), } @@ -659,7 +743,7 @@ fn file_page_metadata() -> UpstreamResult { { "id": "0:1", "type": "PAGE", - "name": "표지", + "name": "Cover", "childrenIds": ["1:2"], "descendantCount": 1 }, @@ -687,7 +771,8 @@ fn file_scope_starts_from_the_file_even_when_the_url_contains_a_node() { }; assert_eq!(metadata_call.call.tool_name(), "get_metadata"); assert_eq!(metadata_call.expected_node_id, None); - assert_eq!(metadata_call.call.arguments()["nodeId"], json!(null)); + // Omitted, not null: the official schema refuses `nodeId: null`. + assert!(!metadata_call.call.arguments().contains_key("nodeId")); } #[test] @@ -740,7 +825,7 @@ fn metadata_only_file_collection_completes_without_snapshot_calls() { second.raw["structuredContent"]["devupMetadata"]["nodes"] = json!([{ "id": "12:34", "type": "PAGE", - "name": "본문: 교정", + "name": "Body: Proofread", "childrenIds": [], "descendantCount": 0 }]); @@ -767,11 +852,13 @@ fn variables_only_file_collection_skips_page_and_node_snapshots() { panic!("fast theme call expected") }; assert_eq!(fast_theme.call.tool_name(), "use_figma"); + let arguments = fast_theme.call.arguments(); + assert!(!arguments.contains_key("nodeId")); assert!( - fast_theme.call.arguments()["code"] + arguments["code"] .as_str() .unwrap() - .contains("devupFastThemeDescriptor") + .contains("devupFastThemeEnvelope") ); collector .accept(&fast_theme.id, fast_theme_envelope_result()) @@ -781,13 +868,162 @@ fn variables_only_file_collection_skips_page_and_node_snapshots() { panic!("valid fast theme should complete in one call") }; assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-theme-envelope-v1"); + assert_eq!(parts.stats.transport, "text"); assert!(!parts.stats.fallback_used); assert_eq!(parts.stats.variable_count, 1); assert_eq!(parts.stats.style_count, 1); assert_eq!(parts.variables.as_ref().unwrap().raw["localComplete"], true); } +/// A file's resources rarely fit one answer, so the theme script pages +/// them: each page carries a run of the resources from an offset and says +/// where the next starts, and the collector asks for the next page from +/// there until one says it is the last. The pages are merged in order, a +/// resource two pages both carry is kept once, and the scan every page +/// repeats comes from the last one. +#[test] +fn a_paged_fast_theme_is_read_page_by_page_and_merged() { + let mut request = CollectionRequest::new(file_target(), CollectionScope::File); + request.resource_scope = ResourceScope::File; + request.variables_only = true; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!("fast theme call expected") + }; + assert!( + first.call.arguments()["code"] + .as_str() + .unwrap() + .contains("{\"offset\":0}") + ); + collector + .accept( + &first.id, + fast_theme_page_result( + json!({"collections": [{"id": "c", "name": "Theme"}], "variables": [{"id": "v1", "name": "primary"}], "styles": [], "usedVariableIds": ["v1"]}), + json!({"offset": 0, "nextOffset": 2, "complete": false, "totalItems": 6}), + ), + ) + .unwrap(); + + let CollectorStep::Call(second) = collector.advance().unwrap() else { + panic!("the next page is asked for") + }; + assert_eq!(second.call.tool_name(), "use_figma"); + assert!( + second.call.arguments()["code"] + .as_str() + .unwrap() + .contains("{\"offset\":2}") + ); + collector + .accept( + &second.id, + fast_theme_page_result( + json!({"collections": [], "variables": [{"id": "v1", "name": "primary"}, {"id": "v2", "name": "text"}], "styles": [{"id": "s", "name": "body", "styleType": "TEXT"}], "usedVariableIds": ["v1", "v2"], "usedStyleIds": ["s"]}), + json!({"offset": 2, "nextOffset": 6, "complete": true, "totalItems": 6}), + ), + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("the last page completes the theme") + }; + assert_eq!(parts.stats.figma_tool_calls, 2); + assert_eq!(parts.stats.transport, "text"); + assert!(!parts.stats.fallback_used); + let resources = &parts.variables.as_ref().unwrap().raw; + assert_eq!(resources["collections"].as_array().unwrap().len(), 1); + // v1 was on both pages and is kept once. + assert_eq!( + resources["variables"] + .as_array() + .unwrap() + .iter() + .map(|variable| variable["id"].as_str().unwrap()) + .collect::>(), + ["v1", "v2"] + ); + assert_eq!(resources["styles"].as_array().unwrap().len(), 1); + assert_eq!(resources["localComplete"], true); + // The scan came in two shares, v1 on both, and is whole and unrepeated. + assert_eq!(resources["usedVariableIds"], json!(["v1", "v2"])); + assert_eq!(resources["usedStyleIds"], json!(["s"])); + assert_eq!(parts.stats.variable_count, 2); + assert_eq!(parts.stats.style_count, 1); + assert_eq!(parts.metadata["pageCount"], 2); +} + +/// A page that does not move forward is refused, not asked for forever. +#[test] +fn a_fast_theme_page_that_does_not_advance_is_refused() { + let mut request = CollectionRequest::new(file_target(), CollectionScope::File); + request.resource_scope = ResourceScope::File; + request.variables_only = true; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!("fast theme call expected") + }; + let error = collector + .accept( + &first.id, + fast_theme_page_result( + json!({"collections": [], "variables": [], "styles": []}), + json!({"offset": 0, "nextOffset": 0, "complete": false, "totalItems": 4}), + ), + ) + .expect_err("a page cursor that stands still is an error"); + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +fn fast_theme_page_result(resources: Value, page: Value) -> UpstreamResult { + // The scan is paged like the resources: this page's share of the used + // ids, or none. + let used_variable_ids = resources + .get("usedVariableIds") + .cloned() + .unwrap_or_else(|| json!([])); + let used_style_ids = resources + .get("usedStyleIds") + .cloned() + .unwrap_or_else(|| json!([])); + let mut envelope = json!({ + "kind": "devupFastThemeEnvelope", + "schemaVersion": 1, + "source": {"fileKey": "FileKey123", "version": "v2"}, + "resources": { + "collections": resources["collections"], + "variables": resources["variables"], + "styles": resources["styles"], + "usedRemoteVariables": [], + "usedVariableIds": used_variable_ids, + "usedStyleIds": used_style_ids, + "localComplete": true, + "usedRemoteComplete": true, + "unresolved": [] + }, + "page": page, + "integrity": { + "collectionCount": resources["collections"].as_array().map_or(0, Vec::len), + "variableCount": resources["variables"].as_array().map_or(0, Vec::len), + "styleCount": resources["styles"].as_array().map_or(0, Vec::len), + "unresolvedCount": 0, + "utf8Bytes": 0 + } + }); + loop { + let bytes = serde_json::to_vec(&envelope).unwrap(); + if envelope["integrity"]["utf8Bytes"] == bytes.len() as u64 { + break; + } + envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); + } + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), + } +} + #[test] fn malformed_or_rejected_fast_theme_restarts_legacy_collection_atomically() { let mut request = CollectionRequest::new(file_target(), CollectionScope::File); @@ -1407,7 +1643,7 @@ fn node_snapshot_follows_the_compiled_cursor_until_complete() { "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], "nodes": [ {"id": "1:2", "type": "FRAME", "fields": {"name": "Root", "childrenIds": ["1:3"]}, "extra": {}, "fieldErrors": {}}, - {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"nextOffset": 1, "complete": false, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} + {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"offset":0,"nextOffset": 1, "complete": false, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} ], "diagnostics": [] }), }, @@ -1430,8 +1666,8 @@ fn node_snapshot_follows_the_compiled_cursor_until_complete() { raw: json!({ "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], "nodes": [ - {"id": "1:3", "type": "TEXT", "fields": {"name": "Child", "characters": "완료", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, - {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"nextOffset": 2, "complete": true, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} + {"id": "1:3", "type": "TEXT", "fields": {"name": "Child", "characters": "Done", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, + {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"offset":0,"nextOffset": 2, "complete": true, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} ], "diagnostics": [] }), }, @@ -1474,13 +1710,108 @@ fn section_collection_indexes_before_planning_selected_roots() { .accept(&index_call.id, compact_section_index()) .unwrap(); - let CollectorStep::Call(batch_call) = collector.advance().unwrap() else { - panic!("one bounded multi-root call expected") + let CollectorStep::Call(first_root_call) = collector.advance().unwrap() else { + panic!("first selected root call expected") + }; + let CollectorStep::Call(second_root_call) = collector.advance().unwrap() else { + panic!("second selected root call expected") + }; + assert_eq!(multi_root_ids(&first_root_call.call), ["10:3"]); + assert_eq!(multi_root_ids(&second_root_call.call), ["10:2"]); + assert_eq!(first_root_call.expected_node_id.as_deref(), Some("10:1")); +} + +#[test] +fn rejected_exact_section_probe_pivots_to_the_compact_index() { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast section probe expected") + }; + + let recovered = collector + .reject( + &fast_call.id, + &DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "Error: DEVUP_TARGET_IS_SECTION", + false, + ), + ) + .unwrap(); + + assert!(recovered); + let CollectorStep::Call(index_call) = collector.advance().unwrap() else { + panic!("compact section index expected") + }; + assert!( + index_call.call.arguments()["code"] + .as_str() + .unwrap() + .contains("subtreeNodeCount") + ); +} + +#[test] +fn failed_fast_and_legacy_section_root_is_reported_without_losing_siblings() { + 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(), "root-1".to_owned()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[3_000, 3_000])); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!() + }; + let CollectorStep::Call(second) = collector.advance().unwrap() else { + panic!() }; - let arguments = batch_call.call.arguments(); - let code = arguments["code"].as_str().unwrap(); - assert!(code.contains("[\"10:3\",\"10:2\"]")); - assert_eq!(batch_call.expected_node_id.as_deref(), Some("10:1")); + collector + .accept( + &second.id, + fast_multi_envelope_result(&["root-1"], &["variable-success"]), + ) + .unwrap(); + assert!( + collector + .reject( + &first.id, + &DevupError::new(ErrorCode::DevupFigmaDirectUnavailable, "fast failed", true,) + ) + .unwrap() + ); + let CollectorStep::Call(legacy) = collector.advance().unwrap() else { + panic!("legacy retry expected") + }; + assert!( + collector + .reject( + &legacy.id, + &DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "legacy failed", + true, + ) + ) + .unwrap() + ); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("successful sibling should complete") + }; + assert_eq!( + merge_chunks(parts.snapshot_chunks).unwrap().roots, + ["root-1"] + ); + assert_eq!(parts.failures.len(), 1); + assert_eq!(parts.failures[0].node_id, "root-0"); + assert_eq!( + parts.failures[0].error_code, + ErrorCode::DevupFigmaDirectUnavailable + ); } #[test] @@ -1821,6 +2152,7 @@ fn fast_multi_envelope_result(root_ids: &[&str], variable_ids: &[&str]) -> Upstr .map(|id| json!({"id": id, "name": id})) .collect::>(); let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "rootId": "10:1"}, "snapshot": { @@ -1855,35 +2187,10 @@ fn fast_multi_envelope_result(root_ids: &[&str], variable_ids: &[&str]) -> Upstr } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut payload = Vec::with_capacity(envelope_bytes.len() + 8); - payload.extend_from_slice(&0_u32.to_be_bytes()); - payload.extend_from_slice(&1_u32.to_be_bytes()); - payload.extend_from_slice(&envelope_bytes); - push_png_chunk(&mut png, b"duVp", &payload); - push_png_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_png_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "10:1", - "nodeCount": root_ids.len(), - "variableRefCount": variable_ids.len(), - "styleRefCount": 0, - "utf8Bytes": envelope_bytes.len(), - "chunkCount": 1 - }); + let _ = envelope_bytes; UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } diff --git a/crates/devup-mcp-figma/tests/envelope.rs b/crates/devup-mcp-figma/tests/envelope.rs index b8e97b4d..08e3ffe2 100644 --- a/crates/devup-mcp-figma/tests/envelope.rs +++ b/crates/devup-mcp-figma/tests/envelope.rs @@ -1,19 +1,21 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; use devup_mcp_figma::{ FigmaTarget, UpstreamResult, decode_fast_multi_snapshot, decode_fast_snapshot, decode_fast_theme, }; use serde_json::{Value, json}; -const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; +// No binary (PNG-chunked) transport exists any more — real-world hosts +// silently discarded the old image attachments, so it never actually worked +// end to end. Fast snapshots and fast themes are delivered as plain text +// only now; a node subtree that doesn't fit in one round is paginated across +// several text rounds instead (see the `paginated_*` tests below). #[test] -fn valid_multichunk_envelope_round_trips() { - let target = target(); +fn valid_snapshot_envelope_round_trips_without_an_image() { let envelope = complete_envelope(); - let result = upstream_result(envelope.clone(), 2); + let result = text_upstream_result(&envelope); - let decoded = decode_fast_snapshot(&result, &target).expect("valid envelope"); + let decoded = decode_fast_snapshot(&result, &target()).expect("valid text envelope"); assert_eq!(decoded.snapshot.file_key, "fileKey123"); assert_eq!(decoded.snapshot.root_ids, ["1:1"]); @@ -24,36 +26,23 @@ fn valid_multichunk_envelope_round_trips() { ); assert_eq!(decoded.resources.raw["styles"].as_array().unwrap().len(), 1); assert_eq!(decoded.stats.raw_bytes, envelope.len()); - assert!(decoded.stats.wire_bytes > envelope.len()); - assert_eq!(decoded.stats.chunk_count, 2); -} - -#[test] -fn valid_multi_image_envelope_round_trips() { - let target = target(); - let envelope = complete_envelope(); - let result = upstream_result_with_split_pngs(envelope.clone(), 2); - - let decoded = decode_fast_snapshot(&result, &target).expect("valid split envelope"); - - assert_eq!(decoded.snapshot.nodes.len(), 2); - assert_eq!(decoded.stats.raw_bytes, envelope.len()); - assert_eq!(decoded.stats.chunk_count, 2); - assert!(decoded.stats.wire_bytes > envelope.len()); + assert_eq!(decoded.stats.wire_bytes, envelope.len()); + assert_eq!(decoded.stats.chunk_count, 0); + assert_eq!(decoded.stats.transport, "text"); } #[test] fn json_stringified_official_mcp_result_round_trips() { let target = target(); let envelope = complete_envelope(); - let mut result = upstream_result_with_split_pngs(envelope, 2); + let mut result = text_upstream_result(&envelope); result.raw = Value::String(result.raw.to_string()); let decoded = decode_fast_snapshot(&result, &target) .expect("official handoff schema transports the MCP result as a JSON string"); assert_eq!(decoded.snapshot.root_ids, ["1:1"]); - assert_eq!(decoded.stats.chunk_count, 2); + assert_eq!(decoded.stats.transport, "text"); } #[test] @@ -71,173 +60,146 @@ fn oversized_stringified_upstream_result_is_rejected_before_json_decode() { assert_eq!(error.details["category"], "upstreamResultJson"); } +/// The decoder's ceiling sits above the 19 KiB the producing script budgets +/// itself to, so a relay that re-serializes the JSON (pretty-printing, +/// different escaping) cannot inflate a valid envelope into a rejection. +/// A bound still exists, and this pins both halves of that: comfortably over +/// the producer's budget is accepted, far over the decoder's ceiling is not. #[test] -fn valid_multi_root_envelope_requires_the_exact_ordered_root_set() { - let envelope = mutate_envelope(|value| { - value["source"]["rootId"] = json!("9:9"); - value["snapshot"]["rootIds"] = json!(["1:1", "1:2"]); +fn a_text_envelope_is_bounded_but_leaves_headroom_above_the_producer_budget() { + let inflated_by_a_relay = mutate_envelope(|value| { + value["snapshot"]["nodes"][1]["fields"]["characters"] = json!("x".repeat(20 * 1024)); }); - let mut result = upstream_result(envelope, 1); - let mut descriptor: Value = - serde_json::from_str(result.raw["content"][0]["text"].as_str().unwrap()).unwrap(); - descriptor["rootId"] = json!("9:9"); - result.raw["content"][0]["text"] = json!(descriptor.to_string()); - let section_target = FigmaTarget { - node_id: Some("9:9".to_owned()), - ..target() - }; + decode_fast_snapshot(&text_upstream_result(&inflated_by_a_relay), &target()) + .expect("20 KiB is over the producer budget but within the decoder's headroom"); - let decoded = decode_fast_multi_snapshot( - &result, - §ion_target, - &["1:1".to_owned(), "1:2".to_owned()], - ) - .expect("valid multi-root envelope"); - assert_eq!(decoded.snapshot.root_ids, ["1:1", "1:2"]); + let oversized = mutate_envelope(|value| { + value["snapshot"]["nodes"][1]["fields"]["characters"] = json!("x".repeat(96 * 1024)); + }); + let error = decode_fast_snapshot(&text_upstream_result(&oversized), &target()) + .expect_err("oversized text envelope"); - let error = decode_fast_multi_snapshot( - &result, - §ion_target, - &["1:2".to_owned(), "1:1".to_owned()], - ) - .expect_err("ordered root mismatch"); - assert_eq!(error.details["category"], "targetMismatch"); + assert_eq!(error.details["category"], "textEnvelope"); } #[test] -fn out_of_order_chunks_are_rejected() { - let envelope = complete_envelope(); - let png = envelope_png_with_order(&envelope, &[1, 0]); - let result = upstream_result_with_png(png, envelope.len(), 2); +fn missing_fast_envelope_text_is_rejected() { + let result = UpstreamResult { + raw: json!({"content": [{"type": "text", "text": "not an envelope"}]}), + }; - let error = decode_fast_snapshot(&result, &target()).expect_err("out of order chunks"); + let error = decode_fast_snapshot(&result, &target()).expect_err("no tagged envelope"); - assert_eq!(error.details["category"], "envelopeChunkSequence"); + assert_eq!(error.details["category"], "textEnvelopeMissing"); } #[test] -fn noncanonical_png_header_is_rejected() { +fn duplicate_tagged_envelopes_are_rejected() { let envelope = complete_envelope(); - let png = envelope_png_with_ihdr(&envelope, &[0, 0, 0, 2, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let result = upstream_result_with_png(png, envelope.len(), 1); + let text = std::str::from_utf8(&envelope).unwrap(); + let result = UpstreamResult { + raw: json!({"content": [ + {"type": "text", "text": text}, + {"type": "text", "text": text} + ]}), + }; - let error = decode_fast_snapshot(&result, &target()).expect_err("noncanonical PNG"); + let error = decode_fast_snapshot(&result, &target()).expect_err("duplicate envelope text"); - assert_eq!(error.details["category"], "pngIhdr"); + assert_eq!(error.details["category"], "textEnvelopeMultiplicity"); } +/// The snapshot script gathers a screen's other widths on its own: asked for +/// a frame named for a breakpoint inside a Section, it answers rooted at that +/// frame and its similarly named siblings. That answer is still the target, +/// and the decoder must take it — on every page, not only the first. It had +/// been calling the family a mismatch and sending the whole collection down +/// the legacy walk. #[test] -fn png_without_idat_is_rejected() { - let envelope = complete_envelope(); - let mut png = PNG_SIGNATURE.to_vec(); - push_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut data = Vec::with_capacity(envelope.len() + 8); - data.extend_from_slice(&0_u32.to_be_bytes()); - data.extend_from_slice(&1_u32.to_be_bytes()); - data.extend_from_slice(&envelope); - push_chunk(&mut png, b"duVp", &data); - push_chunk(&mut png, b"IEND", &[]); +fn a_single_root_request_accepts_the_family_the_script_gathered_around_it() { + let family = mutate_envelope(|value| { + value["snapshot"]["rootIds"] = json!(["0:9", "1:1", "2:2"]); + push_cursor_marker(value, 0, 2, false, 5); + }); + let decoded = decode_fast_snapshot(&text_upstream_result(&family), &target()) + .expect("the requested root, with its siblings, is the target"); + assert_eq!(decoded.snapshot.root_ids, ["0:9", "1:1", "2:2"]); + + // Page two carries the same list and no root at all; it is the same + // collection continuing. + let second_page = mutate_envelope(|value| { + value["snapshot"]["rootIds"] = json!(["0:9", "1:1", "2:2"]); + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + nodes.remove(0); + value["integrity"]["nodeCount"] = json!(1); + value["integrity"]["variableRefCount"] = json!(0); + value["integrity"]["styleRefCount"] = json!(1); + value["resources"]["variables"] = json!([]); + push_cursor_marker(value, 2, 5, true, 5); + }); + decode_fast_snapshot(&text_upstream_result(&second_page), &target()) + .expect("a continuation page of the family is the same collection"); + // A list the request is not in is another target; so is a list that + // names one root twice. + let elsewhere = mutate_envelope(|value| { + value["snapshot"]["rootIds"] = json!(["0:9", "2:2"]); + }); assert_category( - upstream_result_with_png(png, envelope.len(), 1), + text_upstream_result(&elsewhere), &target(), - "pngIdat", + "targetMismatch", ); + let doubled = mutate_envelope(|value| { + value["snapshot"]["rootIds"] = json!(["1:1", "1:1"]); + }); + assert_category(text_upstream_result(&doubled), &target(), "targetMismatch"); } #[test] -fn invalid_utf8_is_rejected_before_json_decode() { - let bytes = vec![0xff, 0xfe, 0xfd]; - let result = upstream_result_with_png(envelope_png(&bytes, 1), bytes.len(), 1); +fn valid_multi_root_envelope_requires_the_exact_ordered_root_set() { + let envelope = mutate_envelope(|value| { + value["source"]["rootId"] = json!("9:9"); + value["snapshot"]["rootIds"] = json!(["1:1", "1:2"]); + }); + let result = text_upstream_result(&envelope); + let section_target = FigmaTarget { + node_id: Some("9:9".to_owned()), + ..target() + }; - let error = decode_fast_snapshot(&result, &target()).expect_err("invalid UTF-8"); + let decoded = decode_fast_multi_snapshot( + &result, + §ion_target, + &["1:1".to_owned(), "1:2".to_owned()], + ) + .expect("valid multi-root envelope"); + assert_eq!(decoded.snapshot.root_ids, ["1:1", "1:2"]); - assert_eq!(error.details["category"], "envelopeUtf8"); + let error = decode_fast_multi_snapshot( + &result, + §ion_target, + &["1:2".to_owned(), "1:1".to_owned()], + ) + .expect_err("ordered root mismatch"); + assert_eq!(error.details["category"], "targetMismatch"); } #[test] -fn corrupt_transport_shapes_are_rejected_without_panicking() { - let envelope = complete_envelope(); - - let mut bad_signature = envelope_png(&envelope, 1); - bad_signature[0] = 0; - assert_category( - upstream_result_with_png(bad_signature, envelope.len(), 1), - &target(), - "pngSignature", - ); - - let mut bad_crc = envelope_png(&envelope, 1); - let marker = bad_crc - .windows(4) - .position(|window| window == b"duVp") - .unwrap(); - bad_crc[marker + 12] ^= 1; - assert_category( - upstream_result_with_png(bad_crc, envelope.len(), 1), - &target(), - "pngCrc", - ); - - let mut truncated = envelope_png(&envelope, 1); - truncated.pop(); - assert_category( - upstream_result_with_png(truncated, envelope.len(), 1), - &target(), - "pngLength", - ); - +fn schema_target_and_resource_integrity_are_validated() { + let unsupported = mutate_envelope(|value| value["schemaVersion"] = Value::from(2)); assert_category( - upstream_result_with_png( - envelope_png_with_order(&envelope, &[0, 0]), - envelope.len(), - 2, - ), + text_upstream_result(&unsupported), &target(), - "envelopeChunkSequence", + "schemaVersion", ); -} - -#[test] -fn image_content_contract_is_strict() { - let envelope = complete_envelope(); - - let mut missing = upstream_result(envelope.clone(), 1); - missing.raw["content"].as_array_mut().unwrap().truncate(1); - assert_category(missing, &target(), "imageMissing"); - - let mut wrong_mime = upstream_result(envelope.clone(), 1); - wrong_mime.raw["content"][1]["mimeType"] = Value::from("image/jpeg"); - assert_category(wrong_mime, &target(), "imageMime"); - - let mut duplicate = upstream_result_with_split_pngs(envelope.clone(), 2); - let repeated = duplicate.raw["content"][1].clone(); - duplicate.raw["content"] - .as_array_mut() - .unwrap() - .push(repeated); - assert_category(duplicate, &target(), "imageMultiplicity"); - - let oversized = vec![0_u8; 11 * 1024 * 1024 + 1]; - let error = decode_fast_snapshot( - &upstream_result_with_png(oversized, envelope.len(), 1), - &target(), - ) - .expect_err("oversized PNG"); - assert_eq!(error.details["category"], "png"); -} - -#[test] -fn schema_target_graph_and_resource_integrity_are_validated() { - let unsupported = mutate_envelope(|value| value["schemaVersion"] = Value::from(2)); - assert_category(upstream_result(unsupported, 1), &target(), "schemaVersion"); let wrong_target = FigmaTarget { file_key: "otherFileKey".to_owned(), ..target() }; assert_category( - upstream_result(complete_envelope(), 1), + text_upstream_result(&complete_envelope()), &wrong_target, "targetMismatch", ); @@ -250,45 +212,169 @@ fn schema_target_graph_and_resource_integrity_are_validated() { .push(duplicate); }); assert_category( - upstream_result(duplicate_node, 1), + text_upstream_result(&duplicate_node), &target(), "duplicateNode", ); + let missing_resource = mutate_envelope(|value| { + value["resources"]["variables"] = json!([]); + }); + assert_category( + text_upstream_result(&missing_resource), + &target(), + "resourceMissing", + ); +} + +/// `integrity.utf8Bytes` is the producer's self-measurement, and the envelope +/// reaches devup-mcp through a relay that may re-serialize the JSON. Both a +/// stale counter and a re-serialized (pretty-printed) payload must decode: +/// the structural checks above are what actually detect corruption, so a byte +/// count that disagrees with the received length is not an error. +#[test] +fn a_reserialized_envelope_decodes_even_though_its_byte_count_no_longer_matches() { + let original = complete_envelope(); + let value: Value = serde_json::from_slice(&original).unwrap(); + let declared = value["integrity"]["utf8Bytes"].as_u64().unwrap() as usize; + + // Pretty-printing changes the byte length without changing the content — + // exactly what a re-serializing relay does. + let reserialized = serde_json::to_vec_pretty(&value).unwrap(); + assert_ne!( + reserialized.len(), + declared, + "the pretty-printed payload must differ in length for this test to mean anything" + ); + decode_fast_snapshot(&text_upstream_result(&reserialized), &target()) + .expect("a re-serialized envelope must still decode"); + + // A counter that is simply wrong is likewise not, by itself, corruption. + let mut stale = value; + stale["integrity"]["utf8Bytes"] = json!(1); + let stale = serde_json::to_vec(&stale).unwrap(); + decode_fast_snapshot(&text_upstream_result(&stale), &target()) + .expect("a stale utf8Bytes counter must not fail an otherwise valid envelope"); +} + +#[test] +fn a_complete_single_page_envelope_still_requires_full_child_containment() { + // No cursor marker at all: treated as a single complete page, so a + // dangling child (referencing a node that was never sent) is rejected + // exactly like the pre-pagination behavior. let dangling_child = mutate_envelope(|value| { value["snapshot"]["nodes"][0]["fields"]["childrenIds"][0] = Value::from("9:9"); }); assert_category( - upstream_result(dangling_child, 1), + text_upstream_result(&dangling_child), &target(), "danglingChild", ); +} - let missing_resource = mutate_envelope(|value| { - value["resources"]["variables"] = json!([]); +#[test] +fn a_final_page_with_an_explicit_cursor_still_requires_full_child_containment() { + let dangling_child = mutate_envelope(|value| { + value["snapshot"]["nodes"][0]["fields"]["childrenIds"][0] = Value::from("9:9"); + push_cursor_marker(value, 0, 2, true, 2); }); assert_category( - upstream_result(missing_resource, 1), + text_upstream_result(&dangling_child), &target(), - "resourceMissing", + "danglingChild", ); } #[test] -fn descriptor_must_match_the_binary_envelope() { - let mut result = upstream_result(complete_envelope(), 2); - let descriptor_text = result.raw["content"][0]["text"].as_str().unwrap(); - let mut descriptor: Value = serde_json::from_str(descriptor_text).unwrap(); - descriptor["nodeCount"] = Value::from(99); - result.raw["content"][0]["text"] = Value::from(descriptor.to_string()); +fn a_non_final_page_may_reference_children_that_have_not_arrived_yet() { + // node "1:2" (the second real node) is deliberately left out of this + // page; the root's childrenIds still references it. Because the page + // reports `complete: false`, this is expected — the child is assumed to + // arrive in a later round — and must not be rejected as dangling. + let first_page = mutate_envelope(|value| { + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + nodes.truncate(1); + value["integrity"]["nodeCount"] = json!(1); + // No boundVariables/textStyleId left in this page, so no resources + // are referenced by it. + value["snapshot"]["nodes"][0]["fields"] + .as_object_mut() + .unwrap() + .remove("boundVariables"); + value["integrity"]["variableRefCount"] = json!(0); + value["integrity"]["styleRefCount"] = json!(0); + value["resources"]["variables"] = json!([]); + value["resources"]["styles"] = json!([]); + push_cursor_marker(value, 0, 1, false, 2); + }); + let result = text_upstream_result(&first_page); + + let decoded = decode_fast_snapshot(&result, &target()).expect("valid first page"); + assert_eq!(decoded.snapshot.nodes.len(), 2); // real node + cursor marker +} + +#[test] +fn a_first_page_that_omits_the_root_is_still_rejected() { + // The root must always be present on the first page (BFS visits it at + // index 0); a first page (offset == 0) that omits it is a real error. + let missing_root = mutate_envelope(|value| { + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + nodes.remove(0); + value["integrity"]["nodeCount"] = json!(1); + value["integrity"]["variableRefCount"] = json!(0); + value["integrity"]["styleRefCount"] = json!(1); + value["resources"]["variables"] = json!([]); + push_cursor_marker(value, 0, 1, false, 2); + }); + assert_category(text_upstream_result(&missing_root), &target(), "nodeCount"); +} - assert_category(result, &target(), "nodeCount"); +#[test] +fn a_continuation_page_may_omit_the_root_that_a_prior_page_already_sent() { + let second_page = mutate_envelope(|value| { + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + nodes.remove(0); + value["integrity"]["nodeCount"] = json!(1); + value["integrity"]["variableRefCount"] = json!(0); + value["integrity"]["styleRefCount"] = json!(1); + value["resources"]["variables"] = json!([]); + push_cursor_marker(value, 1, 2, true, 2); + }); + let result = text_upstream_result(&second_page); + + let decoded = decode_fast_snapshot(&result, &target()).expect("valid continuation page"); + assert_eq!(decoded.snapshot.nodes.len(), 2); // real node + cursor marker +} + +#[test] +fn a_cursor_marker_missing_offset_is_rejected() { + // Regression: the script once emitted the marker without `offset`, so + // every real fast snapshot failed `peek_page_cursor` and silently fell + // back to legacy cursor collection. + let bad = mutate_envelope(|value| { + push_cursor_marker(value, 0, 2, true, 2); + value["snapshot"]["nodes"][2]["fields"] + .as_object_mut() + .unwrap() + .remove("offset"); + }); + assert_category(text_upstream_result(&bad), &target(), "cursorShape"); +} + +#[test] +fn duplicate_cursor_markers_are_rejected() { + let bad = mutate_envelope(|value| { + push_cursor_marker(value, 0, 2, true, 2); + push_cursor_marker(value, 0, 2, true, 2); + value["integrity"]["nodeCount"] = json!(4); + }); + assert_category(text_upstream_result(&bad), &target(), "cursorMultiplicity"); } #[test] fn valid_fast_theme_envelope_round_trips_and_validates_counts() { let envelope = theme_envelope(); - let result = theme_upstream_result(envelope.clone(), 1); + let result = theme_text_upstream_result(&envelope); let decoded = decode_fast_theme(&result, "fileKey123").expect("valid fast theme"); @@ -307,26 +393,24 @@ fn valid_fast_theme_envelope_round_trips_and_validates_counts() { assert_eq!(decoded.resources.raw["styles"].as_array().unwrap().len(), 1); assert_eq!(decoded.resources.raw["localComplete"], true); assert_eq!(decoded.stats.raw_bytes, envelope.len()); + assert_eq!(decoded.stats.transport, "text"); - let mut bad = theme_upstream_result(envelope, 1); - let descriptor = bad.raw["content"][0]["text"].as_str().unwrap(); - let mut descriptor: Value = serde_json::from_str(descriptor).unwrap(); - descriptor["variableCount"] = json!(2); - bad.raw["content"][0]["text"] = json!(descriptor.to_string()); - let error = decode_fast_theme(&bad, "fileKey123").expect_err("count mismatch"); + let bad = mutate_theme_envelope(|value| value["integrity"]["variableCount"] = json!(2)); + let error = decode_fast_theme(&theme_text_upstream_result(&bad), "fileKey123") + .expect_err("count mismatch"); assert_eq!(error.details["category"], "variableCount"); } #[test] fn json_stringified_fast_theme_result_round_trips() { let envelope = theme_envelope(); - let mut result = theme_upstream_result(envelope, 1); + let mut result = theme_text_upstream_result(&envelope); result.raw = Value::String(result.raw.to_string()); let decoded = decode_fast_theme(&result, "fileKey123").expect("stringified official theme envelope"); - assert_eq!(decoded.stats.chunk_count, 1); + assert_eq!(decoded.stats.transport, "text"); assert_eq!(decoded.resources.raw["localComplete"], true); } @@ -340,6 +424,7 @@ fn target() -> FigmaTarget { fn complete_envelope() -> Vec { finalize_envelope(json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": { "fileKey": "fileKey123", @@ -365,7 +450,7 @@ fn complete_envelope() -> Vec { "type": "TEXT", "fields": { "textStyleId": "S:style1", - "characters": "테스트" + "characters": "Test" } } ], @@ -390,7 +475,12 @@ fn complete_envelope() -> Vec { } fn theme_envelope() -> Vec { - finalize_envelope(json!({ + finalize_theme_envelope(theme_envelope_value()) +} + +fn theme_envelope_value() -> Value { + json!({ + "kind": "devupFastThemeEnvelope", "schemaVersion": 1, "source": {"fileKey": "fileKey123", "version": "v42"}, "resources": { @@ -411,38 +501,73 @@ fn theme_envelope() -> Vec { "unresolvedCount": 0, "utf8Bytes": 0 } - })) + }) } -fn theme_upstream_result(envelope: Vec, chunk_count: usize) -> UpstreamResult { - let png = envelope_png(&envelope, chunk_count); - let descriptor = json!({ - "kind": "devupFastThemeDescriptor", - "schemaVersion": 1, - "collectionCount": 1, - "variableCount": 1, - "styleCount": 1, - "unresolvedCount": 0, - "utf8Bytes": envelope.len(), - "chunkCount": chunk_count - }); +fn text_upstream_result(envelope: &[u8]) -> UpstreamResult { UpstreamResult { raw: json!({ - "content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} - ] + "content": [{ + "type": "text", + "text": std::str::from_utf8(envelope).unwrap() + }] }), } } +fn theme_text_upstream_result(envelope: &[u8]) -> UpstreamResult { + text_upstream_result(envelope) +} + +/// Appends the `__DEVUP_SNAPSHOT_CURSOR__` marker node every fast snapshot +/// script emits, mirroring the shape `take_snapshot_cursor` parses, and +/// updates `integrity.nodeCount` to include it (matching real script output, +/// which always counts the marker in the same `nodes` array it serializes). +fn push_cursor_marker( + value: &mut Value, + offset: u64, + next_offset: u64, + complete: bool, + total_nodes: u64, +) { + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + let real_node_count = nodes.len() as u64; + nodes.push(json!({ + "id": "__DEVUP_SNAPSHOT_CURSOR__", + "type": "DEVUP_INTERNAL", + "fields": { + "offset": offset, + "nextOffset": next_offset, + "complete": complete, + "totalNodes": total_nodes + }, + "extra": {}, + "fieldErrors": {} + })); + value["integrity"]["nodeCount"] = json!(real_node_count + 1); +} + fn mutate_envelope(mutate: impl FnOnce(&mut Value)) -> Vec { let mut value: Value = serde_json::from_slice(&complete_envelope()).unwrap(); mutate(&mut value); finalize_envelope(value) } -fn finalize_envelope(mut value: Value) -> Vec { +fn mutate_theme_envelope(mutate: impl FnOnce(&mut Value)) -> Vec { + let mut value = theme_envelope_value(); + mutate(&mut value); + finalize_theme_envelope(value) +} + +fn finalize_envelope(value: Value) -> Vec { + finalize_utf8_bytes(value) +} + +fn finalize_theme_envelope(value: Value) -> Vec { + finalize_utf8_bytes(value) +} + +fn finalize_utf8_bytes(mut value: Value) -> Vec { for _ in 0..8 { let bytes = serde_json::to_vec(&value).unwrap(); let length = bytes.len() as u64; @@ -458,158 +583,3 @@ fn assert_category(result: UpstreamResult, target: &FigmaTarget, expected: &str) let error = decode_fast_snapshot(&result, target).expect_err(expected); assert_eq!(error.details["category"], expected); } - -fn upstream_result(envelope: Vec, chunk_count: usize) -> UpstreamResult { - let png = envelope_png(&envelope, chunk_count); - upstream_result_with_png(png, envelope.len(), chunk_count) -} - -fn upstream_result_with_png( - png: Vec, - envelope_length: usize, - chunk_count: usize, -) -> UpstreamResult { - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "1:1", - "nodeCount": 2, - "variableRefCount": 1, - "styleRefCount": 1, - "utf8Bytes": envelope_length, - "chunkCount": chunk_count - }); - UpstreamResult { - raw: json!({ - "content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} - ] - }), - } -} - -fn upstream_result_with_split_pngs(envelope: Vec, chunk_count: usize) -> UpstreamResult { - assert!(chunk_count > 0 && chunk_count <= envelope.len()); - let per_chunk = envelope.len().div_ceil(chunk_count); - let payloads = envelope.chunks(per_chunk).collect::>(); - assert_eq!(payloads.len(), chunk_count); - let mut content = vec![json!({ - "type": "text", - "text": json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "1:1", - "nodeCount": 2, - "variableRefCount": 1, - "styleRefCount": 1, - "utf8Bytes": envelope.len(), - "chunkCount": chunk_count - }).to_string() - })]; - for (sequence, payload) in payloads.into_iter().enumerate() { - let png = envelope_png_for_chunk(payload, sequence, chunk_count); - content.push(json!({ - "type": "image", - "data": STANDARD.encode(png), - "mimeType": "image/png" - })); - } - UpstreamResult { - raw: json!({"content": content}), - } -} - -fn envelope_png(envelope: &[u8], chunk_count: usize) -> Vec { - assert!(chunk_count > 0 && chunk_count <= envelope.len()); - let order = (0..chunk_count).collect::>(); - envelope_png_with_order(envelope, &order) -} - -fn envelope_png_with_ihdr(envelope: &[u8], ihdr: &[u8; 13]) -> Vec { - let mut png = PNG_SIGNATURE.to_vec(); - push_chunk(&mut png, b"IHDR", ihdr); - let mut data = Vec::with_capacity(envelope.len() + 8); - data.extend_from_slice(&0_u32.to_be_bytes()); - data.extend_from_slice(&1_u32.to_be_bytes()); - data.extend_from_slice(envelope); - push_chunk(&mut png, b"duVp", &data); - push_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_chunk(&mut png, b"IEND", &[]); - png -} - -fn envelope_png_for_chunk(payload: &[u8], sequence: usize, total: usize) -> Vec { - let mut png = PNG_SIGNATURE.to_vec(); - push_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut data = Vec::with_capacity(payload.len() + 8); - data.extend_from_slice(&(sequence as u32).to_be_bytes()); - data.extend_from_slice(&(total as u32).to_be_bytes()); - data.extend_from_slice(payload); - push_chunk(&mut png, b"duVp", &data); - push_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_chunk(&mut png, b"IEND", &[]); - png -} - -fn envelope_png_with_order(envelope: &[u8], order: &[usize]) -> Vec { - let chunk_count = order.len(); - assert!(chunk_count > 0 && chunk_count <= envelope.len()); - let mut png = PNG_SIGNATURE.to_vec(); - push_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - - let per_chunk = envelope.len().div_ceil(chunk_count); - let payloads = envelope.chunks(per_chunk).collect::>(); - assert_eq!(payloads.len(), chunk_count); - for &sequence in order { - let payload = payloads[sequence]; - let mut data = Vec::with_capacity(payload.len() + 8); - data.extend_from_slice(&(sequence as u32).to_be_bytes()); - data.extend_from_slice(&(chunk_count as u32).to_be_bytes()); - data.extend_from_slice(payload); - push_chunk(&mut png, b"duVp", &data); - } - - push_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_chunk(&mut png, b"IEND", &[]); - png -} - -fn push_chunk(output: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { - output.extend_from_slice(&(data.len() as u32).to_be_bytes()); - output.extend_from_slice(chunk_type); - output.extend_from_slice(data); - let mut crc_input = Vec::with_capacity(4 + data.len()); - crc_input.extend_from_slice(chunk_type); - crc_input.extend_from_slice(data); - output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); -} - -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} diff --git a/crates/devup-mcp-figma/tests/explore.rs b/crates/devup-mcp-figma/tests/explore.rs index f4606755..b55e924a 100644 --- a/crates/devup-mcp-figma/tests/explore.rs +++ b/crates/devup-mcp-figma/tests/explore.rs @@ -40,10 +40,10 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { raw_node( "1:1", "FRAME", - "[FR-026] 본연체", + "[FR-026] Essence", [0.0, 0.0, 1200.0, 80.0], 1, - "본연체", + "Essence", ), raw_node( "1:2", @@ -51,7 +51,7 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { "A : STORY-F-PROOFREAD", [0.0, 120.0, 360.0, 740.0], 12, - "이야기가 글로 정리되었어요", + "Your story has been written up", ), raw_node( "1:3", @@ -59,7 +59,7 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { "A : STORY-F-PROOFREAD", [400.0, 120.0, 360.0, 740.0], 13, - "공개 설정 나만 보기", + "Visibility: only me", ), raw_node( "1:4", @@ -67,15 +67,15 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { "Annotation", [800.0, 140.0, 180.0, 40.0], 0, - "개발 참고", + "Dev note", ), raw_node( "2:1", "FRAME", - "[FR-027] 다음 기능", + "[FR-027] Next feature", [0.0, 1000.0, 1200.0, 80.0], 1, - "다음 기능", + "Next feature", ), raw_node( "2:2", @@ -83,7 +83,7 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { "A : NEXT", [0.0, 1120.0, 360.0, 740.0], 8, - "다음 화면", + "Next screen", ), ]; if nodes_reversed { @@ -149,7 +149,7 @@ fn nested_wquw_section_projection() -> Snapshot { let mut section = raw_node( "4217:7743", "SECTION", - "[FR-026] 본연체", + "[FR-026] Essence", [0.0, 0.0, 4_400.0, 900.0], 11, "", @@ -163,10 +163,10 @@ fn nested_wquw_section_projection() -> Snapshot { let mut heading = raw_node( "3879:35481", "FRAME", - "[FR-026] 본연체", + "[FR-026] Essence", [0.0, 0.0, 1_200.0, 80.0], 1, - "본연체", + "Essence", ); heading .fields @@ -219,10 +219,10 @@ fn classification_distinguishes_heading_screen_annotation_and_container() { let heading = ExploreNode::try_from(&raw_node( "1:1", "FRAME", - "[FR-026] 본연체", + "[FR-026] Essence", [0.0, 0.0, 1200.0, 80.0], 1, - "본연체", + "Essence", )) .unwrap(); let screen = ExploreNode::try_from(&raw_node( @@ -231,7 +231,7 @@ fn classification_distinguishes_heading_screen_annotation_and_container() { "Screen", [0.0, 120.0, 360.0, 740.0], 12, - "화면", + "Screen", )) .unwrap(); let annotation = ExploreNode::try_from(&raw_node( @@ -240,7 +240,7 @@ fn classification_distinguishes_heading_screen_annotation_and_container() { "Note", [0.0, 120.0, 120.0, 30.0], 0, - "참고", + "Note", )) .unwrap(); let container = ExploreNode::try_from(&raw_node( @@ -269,7 +269,7 @@ fn heading_group_keeps_duplicate_states_and_stops_at_the_next_heading() { .unwrap(); assert_eq!(result.anchor.kind, ExploreKind::Heading); - assert_eq!(result.group.as_ref().unwrap().title, "[FR-026] 본연체"); + assert_eq!(result.group.as_ref().unwrap().title, "[FR-026] Essence"); assert_eq!( result .candidates diff --git a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs index 20f4c3f3..778e62af 100644 --- a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs +++ b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs @@ -110,7 +110,7 @@ test("a nested heading explores the same ten screens as its enclosing SECTION", const heading = sceneNode({ id: "3879:35481", type: "TEXT", - name: "[FR-026] 본연체", + name: "[FR-026] Essence", width: 320, height: 48, }); @@ -118,7 +118,7 @@ test("a nested heading explores the same ten screens as its enclosing SECTION", const section = sceneNode({ id: "4217:7743", type: "SECTION", - name: "[FR-026] 본연체", + name: "[FR-026] Essence", width: 4_400, height: 900, children: [heading, wrapper], @@ -160,7 +160,7 @@ test("a large SECTION without screens visits at most projectionLimit times eight }); test("oversized required nodes collapse to a bounded required-only projection", async () => { - const longName = "가".repeat(2_000); + const longName = "A".repeat(2_000); const anchor = sceneNode({ id: "anchor", type: "SECTION", name: longName }); let nested = anchor; for (let index = 0; index < 10; index += 1) { diff --git a/crates/devup-mcp-figma/tests/large_values.rs b/crates/devup-mcp-figma/tests/large_values.rs index f93bbc00..76235795 100644 --- a/crates/devup-mcp-figma/tests/large_values.rs +++ b/crates/devup-mcp-figma/tests/large_values.rs @@ -103,7 +103,7 @@ fn collector_resolves_every_descriptor_before_completing_the_snapshot() { "fileKey":"FileKey123","version":"v1","rootIds":["1:2"], "nodes":[ {"id":"1:2","type":"TEXT","fields":{"characters":{"$largeValue":descriptor()}},"extra":{},"fieldErrors":{}}, - {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} + {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"offset":0,"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} ],"diagnostics":[] }), }, @@ -171,7 +171,7 @@ fn collector_marks_large_value_as_unsupported_when_upstream_rejects_continuation "fileKey":"FileKey123","version":"v1","rootIds":["1:2"], "nodes":[ {"id":"1:2","type":"TEXT","fields":{"characters":{"$largeValue":descriptor()}},"extra":{},"fieldErrors":{}}, - {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} + {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"offset":0,"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} ],"diagnostics":[] }), }, diff --git a/crates/devup-mcp-figma/tests/manifest_covers_readers.rs b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs new file mode 100644 index 00000000..18360392 --- /dev/null +++ b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs @@ -0,0 +1,87 @@ +//! A field the code reads must be a field the collector asks Figma for. +//! +//! Twice now a rule has been written against a node field that was never +//! collected, so it read nothing and silently took the wrong branch: text +//! truncation defaulted to on because an absent value is not `DISABLED`, and a +//! component set could not find its default variant by name. Both looked +//! correct in the pinned corpus, whose captures carry the fields, and were only +//! wrong against a live file — which is exactly the gap a fixture cannot show. + +use std::{collections::BTreeSet, fs, path::Path}; + +/// Names that are read through the same accessors but never come from a Figma +/// node, so the manifest has nothing to say about them. +const NOT_NODE_FIELDS: &[&str] = &[ + // Written by our own scripts onto the node record. + "parentId", + "parentType", + "childrenIds", + "styledTextSegments", + // Envelope, pagination and probe records, not nodes. + "breadcrumb", + "childCount", + "complete", + "devupTokens", + "directChildCount", + "estimatedSerializedBytes", + "pageChildIndex", + "projectionTruncated", + "subtreeNodeCount", + "textPreview", + // Read only on the explore path, whose script reads the node directly + // rather than through the manifest. + "absoluteBoundingBox", + "annotations", +]; + +fn read_sources(directory: &Path, into: &mut String) { + for entry in fs::read_dir(directory).expect("source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + read_sources(&path, into); + } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { + into.push_str(&fs::read_to_string(&path).expect("source file")); + into.push('\n'); + } + } +} + +#[test] +fn every_field_the_code_reads_is_a_field_the_collector_requests() { + let crates = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let mut source = String::new(); + read_sources(&crates.join("devup-mcp-figma/src"), &mut source); + read_sources(&crates.join("devup-mcp-devup-ui/src"), &mut source); + + let manifest: BTreeSet = serde_json::from_str( + &fs::read_to_string(crates.join("devup-mcp-figma/src/plugin_api_manifest.json")) + .expect("manifest"), + ) + .expect("manifest is a list of field names"); + + // `view.string("x")` and friends are how a node field is read. + let mut missing = BTreeSet::new(); + for accessor in [".value(\"", ".string(\"", ".number(\"", ".bool(\""] { + let mut rest = source.as_str(); + while let Some(at) = rest.find(accessor) { + rest = &rest[at + accessor.len()..]; + let Some(end) = rest.find('"') else { break }; + let field = &rest[..end]; + if !field.is_empty() + && field.chars().all(|c| c.is_ascii_alphanumeric()) + && !manifest.contains(field) + && !NOT_NODE_FIELDS.contains(&field) + { + missing.insert(field.to_owned()); + } + } + } + + assert!( + missing.is_empty(), + "these node fields are read but never collected, so they are always \ + absent against a live file: {missing:?}. Add them to \ + plugin_api_manifest.json, or list them in NOT_NODE_FIELDS with the \ + reason they are not node fields." + ); +} diff --git a/crates/devup-mcp-figma/tests/oauth_flow.rs b/crates/devup-mcp-figma/tests/oauth_flow.rs index 522f0073..017dcdf1 100644 --- a/crates/devup-mcp-figma/tests/oauth_flow.rs +++ b/crates/devup-mcp-figma/tests/oauth_flow.rs @@ -3,10 +3,13 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use axum::{ Json, Router, extract::{Form, State}, + http::StatusCode, routing::{get, post}, }; use devup_mcp_figma::{ - AuthStatus, BrowserOpener, CredentialStore, MemoryCredentialStore, OAuthManager, + AuthStatus, BrowserOpener, ClientCredentialSource, ClientCredentials, CredentialStore, + DEFAULT_CLIENT_NAME, DirectPathSnapshot, ErrorCode, MemoryClientCredentialStore, + MemoryCredentialStore, OAuthManager, SecretString, TokenState, }; use serde_json::{Value, json}; use tokio::{net::TcpListener, sync::Mutex}; @@ -121,7 +124,7 @@ async fn login_discovers_registers_uses_pkce_and_stores_tokens() -> anyhow::Resu .await .clone() .expect("registration"); - assert_eq!(registration["client_name"], "devup-mcp"); + assert_eq!(registration["client_name"], DEFAULT_CLIENT_NAME); assert_eq!(registration["token_endpoint_auth_method"], "none"); assert!( registration["redirect_uris"][0] @@ -164,3 +167,420 @@ async fn logout_clears_persisted_authorization() -> anyhow::Result<()> { assert_eq!(manager.status().await?, AuthStatus::Disconnected); Ok(()) } + +/// Mirrors Figma's real Dynamic Client Registration: its authorization +/// server advertises only `client_secret_basic`/`client_secret_post`, so +/// registration issues a confidential client with a secret. +async fn register_confidential( + State(state): State, + Json(body): Json, +) -> Json { + *state.captured.registration.lock().await = Some(body); + Json(json!({"client_id": "dynamic-client", "client_secret": "dynamic-secret"})) +} + +/// Regression: a secret issued by Dynamic Client Registration must reach the +/// authorization-code exchange. Discarding it made every real Figma login +/// fail with a bare `400` from `/v1/oauth/token` — after registration and +/// browser consent had both already succeeded, which made the failure look +/// like a network fault rather than a missing credential. +#[tokio::test] +async fn a_dcr_issued_client_secret_is_used_for_the_token_exchange_and_refresh() +-> anyhow::Result<()> { + let (base, captured) = spawn_mock_oauth_server(post(register_confidential)).await?; + + let store = MemoryCredentialStore::default(); + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store.clone()) + .with_callback_timeout(Duration::from_secs(3)); + let authorization = manager.login(&CallbackOpener).await?; + + let form = captured + .token_form + .lock() + .await + .clone() + .expect("token form"); + assert_eq!( + form.get("client_secret").map(String::as_str), + Some("dynamic-secret"), + "the DCR-issued secret must be sent to the token endpoint" + ); + + // It is kept with the authorization it belongs to, so a later refresh — + // which has no operator-configured credential to fall back on — can send + // it too. The secret must never surface in Debug output. + assert_eq!( + authorization + .client_secret + .as_ref() + .map(SecretString::expose), + Some("dynamic-secret") + ); + assert!(!format!("{authorization:?}").contains("dynamic-secret")); + + // Force the stored token to look expired so `access_token` refreshes. + let mut expired = CredentialStore::load(&store).await?.expect("authorization"); + expired.expires_at = Some(0); + CredentialStore::save(&store, &expired).await?; + manager.access_token().await?; + let refresh_form = captured + .token_form + .lock() + .await + .clone() + .expect("refresh form"); + assert_eq!( + refresh_form.get("grant_type").map(String::as_str), + Some("refresh_token") + ); + assert_eq!( + refresh_form.get("client_secret").map(String::as_str), + Some("dynamic-secret"), + "refresh must carry the DCR-issued secret as well" + ); + Ok(()) +} + +async fn register_forbidden( + State(state): State, + Json(body): Json, +) -> (StatusCode, String) { + *state.captured.registration.lock().await = Some(body); + // Real Figma returns a *plain-text* 403 body, not JSON — this is the + // exact shape that broke naive OAuth clients (see README.md). The + // fixture reproduces it so tests exercise the real failure mode. + (StatusCode::FORBIDDEN, "Forbidden".to_owned()) +} + +async fn spawn_mock_oauth_server( + register: axum::routing::MethodRouter, +) -> anyhow::Result<(String, Captured)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let base = format!("http://{}", listener.local_addr()?); + let captured = Captured::default(); + let app = Router::new() + .route( + "/.well-known/oauth-protected-resource/mcp", + get(protected_resource), + ) + .route( + "/.well-known/oauth-authorization-server", + get(authorization_metadata), + ) + .route("/register", register) + .route("/token", post(token)) + .with_state(AppState { + base: base.clone(), + captured: captured.clone(), + }); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("mock OAuth server"); + }); + Ok((base, captured)) +} + +/// Core deliverable #1: when a pre-registered client credential is +/// resolvable (here via `with_static_client_credentials`, standing in for +/// `--figma-client-id`/`DEVUP_FIGMA_CLIENT_ID`), `login` must skip +/// Dynamic Client Registration entirely — the `/register` endpoint must +/// never be called — and use the given `client_id`/`client_secret` for the +/// PKCE authorization-code exchange. +#[tokio::test] +async fn static_client_credentials_skip_dynamic_client_registration() -> anyhow::Result<()> { + let (base, captured) = spawn_mock_oauth_server(post(register)).await?; + + let store = MemoryCredentialStore::default(); + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store) + .with_callback_timeout(Duration::from_secs(3)) + .with_static_client_credentials( + ClientCredentials { + client_id: "preregistered-client".to_owned(), + client_secret: Some(SecretString::new("preregistered-secret")), + }, + ClientCredentialSource::CliArg, + ); + let authorization = manager.login(&CallbackOpener).await?; + + assert!( + captured.registration.lock().await.is_none(), + "DCR must never be attempted once a client credential resolves" + ); + assert_eq!(authorization.client_id, "preregistered-client"); + + let form = captured + .token_form + .lock() + .await + .clone() + .expect("token form"); + assert_eq!( + form.get("client_id").map(String::as_str), + Some("preregistered-client") + ); + assert_eq!( + form.get("client_secret").map(String::as_str), + Some("preregistered-secret") + ); + Ok(()) +} + +/// Core deliverable #3: with no client credential resolvable and no +/// operator-supplied override, `login` performs DCR under +/// `DEFAULT_CLIENT_NAME`, and a +/// 403 rejection (Figma's real response shape: plain-text `Forbidden`, not +/// JSON) surfaces as a classified, actionable `DEVUP_FIGMA_CATALOG_REJECTED` +/// error — not a generic network failure — carrying the four documented +/// options without ever echoing the raw upstream body. +#[tokio::test] +async fn dcr_403_is_classified_as_catalog_rejected_with_actionable_options() -> anyhow::Result<()> { + let (base, captured) = spawn_mock_oauth_server(post(register_forbidden)).await?; + + let store = MemoryCredentialStore::default(); + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store) + .with_callback_timeout(Duration::from_secs(3)); + let error = manager + .login(&CallbackOpener) + .await + .expect_err("403 registration must fail login"); + + assert_eq!(error.code, ErrorCode::DevupFigmaCatalogRejected); + let options = error.details["options"] + .as_array() + .expect("catalog-rejected errors carry actionable options"); + // Three, not four: the local Dev Mode MCP was offered here and cannot + // serve devup-mcp at all, since it has no use_figma to run a collection + // with. An option that cannot work costs a turn to discover. + assert_eq!(options.len(), 3); + assert!( + options + .iter() + .any(|option| option.as_str().unwrap_or_default().contains("configure")) + ); + assert!( + options + .iter() + .any(|option| option.as_str().unwrap_or_default().contains("mcp-catalog")) + ); + let serialized = serde_json::to_string(&error)?; + assert!(!serialized.contains("Forbidden")); + + // Confirm the request that actually went out carried the compiled + // default, so a 403 here is attributable to the allowlist rather than + // to a stray per-process override. + let registration = captured + .registration + .lock() + .await + .clone() + .expect("registration attempt"); + assert_eq!(registration["client_name"], DEFAULT_CLIENT_NAME); + Ok(()) +} + +/// The compiled default is a deployment decision, not an implementation +/// detail: devup-mcp is distributed to be installed into Codex, and the +/// literal name `devup-mcp` is not on Figma's catalog allowlist, so +/// defaulting to it would make `direct` unreachable out of the box. Pin +/// the value so flipping it is a deliberate, reviewed edit rather than a +/// silent drift — and pin that the override still wins over it. +#[test] +fn default_client_name_is_codex_and_remains_overridable() { + assert_eq!(DEFAULT_CLIENT_NAME, "Codex"); +} + +/// Figma admits `/register` only for `client_name` values on its catalog +/// allowlist, so an operator whose client was admitted under a different +/// name must be able to supply it at launch +/// (`--figma-client-name`/`DEVUP_FIGMA_CLIENT_NAME`) without a rebuild. +/// The override must reach the registration body verbatim — and only the +/// name changes: PKCE, redirect_uri and the token exchange are untouched. +#[tokio::test] +async fn configured_client_name_is_sent_verbatim_to_dynamic_client_registration() +-> anyhow::Result<()> { + let (base, captured) = spawn_mock_oauth_server(post(register)).await?; + + let store = MemoryCredentialStore::default(); + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store) + .with_callback_timeout(Duration::from_secs(3)) + .with_client_name("Acme Registered Client"); + manager.login(&CallbackOpener).await?; + + let registration = captured + .registration + .lock() + .await + .clone() + .expect("registration attempt"); + assert_eq!(registration["client_name"], "Acme Registered Client"); + assert_eq!(registration["token_endpoint_auth_method"], "none"); + assert!( + registration["redirect_uris"][0] + .as_str() + .expect("redirect uri") + .starts_with("http://127.0.0.1:") + ); + + let snapshot = manager.direct_path_snapshot().await?; + assert_eq!(snapshot.client_name, "Acme Registered Client"); + Ok(()) +} + +/// A blank override is operator error (an unset env var expanding to an +/// empty string, say) and must never be sent as the client's identity — +/// it falls back to the honest default instead. +#[tokio::test] +async fn blank_client_name_override_falls_back_to_the_default() -> anyhow::Result<()> { + let manager = OAuthManager::with_endpoint( + "https://mcp.figma.com/mcp", + MemoryCredentialStore::default(), + ) + .with_client_name(" "); + + let snapshot = manager.direct_path_snapshot().await?; + assert_eq!(snapshot.client_name, DEFAULT_CLIENT_NAME); + Ok(()) +} + +/// Core deliverable #2: a *configured* callback port that is already +/// occupied must fail the bind attempt immediately with a specific, +/// actionable error — never silently wait for a connection that will +/// never arrive (the `MaEPSBroker.exe`-style trap documented in +/// README.md). +#[tokio::test] +async fn occupied_callback_port_fails_immediately_instead_of_waiting() -> anyhow::Result<()> { + let (base, _captured) = spawn_mock_oauth_server(post(register)).await?; + + // Bind a real listener to claim a genuinely free ephemeral port, then + // keep it alive so the manager's bind attempt on that exact port + // fails deterministically. + let occupier = TcpListener::bind("127.0.0.1:0").await?; + let occupied_port = occupier.local_addr()?.port(); + + let store = MemoryCredentialStore::default(); + // A generous timeout: if the implementation regressed to "wait for a + // connection", this test would hang for the full duration instead of + // returning within milliseconds. + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store) + .with_callback_timeout(Duration::from_secs(120)) + .with_callback_port(Some(occupied_port)); + + let started = std::time::Instant::now(); + let error = manager + .login(&CallbackOpener) + .await + .expect_err("bind on an occupied fixed port must fail"); + let elapsed = started.elapsed(); + + assert_eq!(error.code, ErrorCode::DevupFigmaCallbackPortInUse); + assert!( + !error.retryable, + "occupied fixed port is not a retry-me error" + ); + assert_eq!(error.details["port"], occupied_port); + assert!( + elapsed < Duration::from_secs(5), + "must fail immediately on bind, not wait for the callback timeout: took {elapsed:?}" + ); + + drop(occupier); + Ok(()) +} + +/// Core deliverable #5 (`doctor`): `direct_path_snapshot` must reflect the +/// real, measured state — which credential source is active, whether the +/// stored token is still fresh, and whether a configured callback port is +/// actually free right now — without ever exposing the secret itself. +#[tokio::test] +async fn direct_path_snapshot_reports_measured_credential_and_port_state() -> anyhow::Result<()> { + let credential_store = MemoryClientCredentialStore::default(); + let manager = OAuthManager::with_endpoint( + "https://mcp.figma.com/mcp", + MemoryCredentialStore::default(), + ) + .with_client_credential_store(Arc::new(credential_store)); + + // Nothing configured yet: no credential, no token, no fixed port. + let absent = manager.direct_path_snapshot().await?; + assert_eq!(absent.credential_source, ClientCredentialSource::None); + assert_eq!(absent.token_state, TokenState::Absent); + assert_eq!(absent.callback_port, None); + assert_eq!(absent.callback_port_free, None); + + // `configure` persists a client credential; its source must now read + // "credential-store" (not "cli-arg"/"env" — those are for + // process-launch overrides only). + manager + .configure_client_credentials( + "configured-client".to_owned(), + Some("configured-secret".to_owned()), + ) + .await?; + let configured = manager.direct_path_snapshot().await?; + assert_eq!( + configured.credential_source, + ClientCredentialSource::CredentialStore + ); + let serialized = serde_json::to_string(&configured)?; + assert!(!serialized.contains("configured-secret")); + + Ok(()) +} + +/// `doctor`'s callback-port probe must reflect the real bind state: free +/// when unoccupied, occupied when another listener holds the exact port. +#[tokio::test] +async fn direct_path_snapshot_probes_the_real_callback_port_state() -> anyhow::Result<()> { + let manager = OAuthManager::with_endpoint( + "https://mcp.figma.com/mcp", + MemoryCredentialStore::default(), + ); + + let probe_listener = TcpListener::bind("127.0.0.1:0").await?; + let free_port = probe_listener.local_addr()?.port(); + drop(probe_listener); + let free = manager + .clone() + .with_callback_port(Some(free_port)) + .direct_path_snapshot() + .await?; + assert_eq!(free.callback_port, Some(free_port)); + assert_eq!(free.callback_port_free, Some(true)); + + let occupier = TcpListener::bind("127.0.0.1:0").await?; + let occupied_port = occupier.local_addr()?.port(); + let occupied = manager + .with_callback_port(Some(occupied_port)) + .direct_path_snapshot() + .await?; + assert_eq!(occupied.callback_port_free, Some(false)); + drop(occupier); + + Ok(()) +} + +/// Security regression: a client secret configured via any path +/// (`with_static_client_credentials` here, standing in for +/// `--figma-client-secret`/`DEVUP_FIGMA_CLIENT_SECRET`) must never appear +/// in `Debug` output of the credential itself or in any snapshot derived +/// from it. `DirectPathSnapshot` structurally has no field capable of +/// carrying it — this test pins that guarantee at the value level too. +#[test] +fn client_secret_never_appears_in_debug_output() { + let credentials = ClientCredentials { + client_id: "preregistered-client".to_owned(), + client_secret: Some(SecretString::new("super-secret-value")), + }; + let debugged = format!("{credentials:?}"); + assert!(!debugged.contains("super-secret-value")); + assert!(debugged.contains("REDACTED")); + + let snapshot = DirectPathSnapshot { + credential_source: ClientCredentialSource::CliArg, + token_state: TokenState::Valid, + callback_port: Some(19876), + callback_port_free: Some(true), + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }; + let serialized = serde_json::to_string(&snapshot).expect("snapshot serializes"); + assert!(!serialized.contains("super-secret-value")); +} diff --git a/crates/devup-mcp-figma/tests/payload_contract.rs b/crates/devup-mcp-figma/tests/payload_contract.rs index c1e9d314..4a419375 100644 --- a/crates/devup-mcp-figma/tests/payload_contract.rs +++ b/crates/devup-mcp-figma/tests/payload_contract.rs @@ -43,6 +43,7 @@ fn synthetic_parts() -> CollectedParts { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), } } diff --git a/crates/devup-mcp-figma/tests/section.rs b/crates/devup-mcp-figma/tests/section.rs index c4b4a1aa..56c67f4f 100644 --- a/crates/devup-mcp-figma/tests/section.rs +++ b/crates/devup-mcp-figma/tests/section.rs @@ -7,7 +7,7 @@ use devup_mcp_figma::{ use serde_json::{Map, json}; #[test] -fn index_contains_only_top_level_visible_screens_in_visual_order() -> anyhow::Result<()> { +fn index_contains_top_level_visible_children_in_visual_order() -> anyhow::Result<()> { let target = FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=10-1")?; let snapshot = fixture_snapshot(); @@ -15,14 +15,19 @@ fn index_contains_only_top_level_visible_screens_in_visual_order() -> anyhow::Re let index: SectionIndex = build_section_index(&snapshot, &target)?; assert_eq!(index.section.node_id, "10:1"); - assert_eq!(index.candidates.len(), 3); + // The note at 10:5 is offered too. This index is a menu to choose from, and + // deciding for the caller which children are worth showing means being + // wrong in the one direction that cannot be seen: an offer too many costs a + // glance, an offer withheld hides the work entirely. Bare text is a case in + // its own right — a whole section of this file is nothing else. + assert_eq!(index.candidates.len(), 4); assert_eq!( index .candidates .iter() .map(|candidate| candidate.node_id.as_str()) .collect::>(), - ["10:3", "10:2", "10:4"] + ["10:3", "10:2", "10:5", "10:4"] ); let first = &index.candidates[0]; assert_eq!(first.name, "First"); @@ -39,22 +44,102 @@ fn index_contains_only_top_level_visible_screens_in_visual_order() -> anyhow::Re .contains(&"inside-section".to_owned()) ); assert!(first.canonical_url.ends_with("node-id=10-3")); + // Hidden, and nested inside a screen already offered. assert!( !index .candidates .iter() - .any(|candidate| { matches!(candidate.node_id.as_str(), "10:5" | "10:6" | "10:7") }) + .any(|candidate| { matches!(candidate.node_id.as_str(), "10:6" | "10:7") }) ); Ok(()) } +#[test] +fn index_offers_small_cases_standing_beside_screen_shaped_notes() -> anyhow::Result<()> { + // Screen shape is a guess for finding screens on an ungrouped page. A + // Section of cases annotated with tall notes turns that guess upside down: + // the notes measure like screens and the cases do not, so the index offered + // every note and hid every case — an answer that looked complete. + let target = + FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=20-1")?; + let nodes = [ + node( + "20:1", + "SECTION", + json!({ + "name": "Gradient", "parentId": "0:1", "visible": true, + "childrenIds": ["20:2", "20:3", "20:4"], + "absoluteBoundingBox": {"x": 0, "y": 0, "width": 1600, "height": 1600} + }), + ), + node( + "20:2", + "FRAME", + json!({ + "name": "Code", "parentId": "20:1", "visible": true, "childrenIds": [], + "absoluteBoundingBox": {"x": 0, "y": 300, "width": 600, "height": 391} + }), + ), + node( + "20:3", + "FRAME", + json!({ + "name": "Case", "parentId": "20:1", "visible": true, "childrenIds": [], + "absoluteBoundingBox": {"x": 0, "y": 0, "width": 150, "height": 150} + }), + ), + node( + "20:4", + "TEXT", + json!({ + "name": "Label", "parentId": "20:1", "visible": true, "childrenIds": [], + "characters": "Gradient", "absoluteBoundingBox": {"x": 0, "y": 700, "width": 90, "height": 24} + }), + ), + ] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect::>(); + let snapshot = Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["20:1".to_owned()], + nodes, + diagnostics: Vec::new(), + }; + + let index = build_section_index(&snapshot, &target)?; + + let offered = index + .candidates + .iter() + .map(|candidate| candidate.node_id.as_str()) + .collect::>(); + assert!( + offered.contains(&"20:2"), + "the note still stands: {offered:?}" + ); + assert!( + offered.contains(&"20:3"), + "the case is what was asked for: {offered:?}" + ); + // Text on a Section is often its label, but a whole section of this file is + // cases that are themselves bare text sitting straight on the Section, and + // the corpus converts them. Reading text as decoration hid every one. + assert!(offered.contains(&"20:4"), "text can be a case: {offered:?}"); + Ok(()) +} + #[test] fn selection_and_batches_are_strict_bounded_and_deterministic() -> anyhow::Result<()> { let target = FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=10-1")?; let index = build_section_index(&fixture_snapshot(), &target)?; - assert_eq!(index.select(&[], true)?, vec!["10:3", "10:2", "10:4"]); + assert_eq!( + index.select(&[], true)?, + vec!["10:3", "10:2", "10:5", "10:4"] + ); assert_eq!( index.select(&["10:4".to_owned(), "10:3".to_owned()], false)?, vec!["10:3", "10:4"] @@ -79,8 +164,8 @@ fn selection_and_batches_are_strict_bounded_and_deterministic() -> anyhow::Resul }, )?; assert_eq!(batches.len(), 2); - assert_eq!(batches[0].root_ids, ["10:3", "10:2"]); - assert_eq!(batches[1].root_ids, ["10:4"]); + assert_eq!(batches[0].root_ids, ["10:3", "10:5"]); + assert_eq!(batches[1].root_ids, ["10:2", "10:4"]); assert!(!batches.iter().any(|batch| batch.oversized)); let oversized = plan_batches( diff --git a/crates/devup-mcp-figma/tests/source_policy.rs b/crates/devup-mcp-figma/tests/source_policy.rs index 8faf7f13..90db82b8 100644 --- a/crates/devup-mcp-figma/tests/source_policy.rs +++ b/crates/devup-mcp-figma/tests/source_policy.rs @@ -1,32 +1,7 @@ use devup_mcp_figma::{ - ErrorCode, SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, - classify_upstream_failure, fallback_allowed, fallback_allowed_for_error, - upstream_failure_error, + ErrorCode, SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, classify_upstream_failure, }; -#[test] -fn auto_falls_back_only_for_identity_or_capability_failures() { - use UpstreamFailureKind::{ - AuthUnavailable, CapabilityUnavailable, CatalogRejected, NodeNotFound, PermissionDenied, - RateLimited, VersionChanged, - }; - - for kind in [ - CatalogRejected, - AuthUnavailable, - CapabilityUnavailable, - PermissionDenied, - ] { - assert!(fallback_allowed(SourcePolicy::Auto, kind), "{kind:?}"); - assert!(!fallback_allowed(SourcePolicy::Direct, kind), "{kind:?}"); - assert!(!fallback_allowed(SourcePolicy::Host, kind), "{kind:?}"); - } - - for kind in [RateLimited, NodeNotFound, VersionChanged] { - assert!(!fallback_allowed(SourcePolicy::Auto, kind), "{kind:?}"); - } -} - #[test] fn classifies_upstream_failures_from_boundary_metadata() { let cases = [ @@ -96,7 +71,6 @@ fn public_policy_and_error_codes_have_stable_json_values() { serde_json::to_value(SourcePolicy::Direct).unwrap(), "direct" ); - assert_eq!(serde_json::to_value(SourcePolicy::Host).unwrap(), "host"); let codes = [ ( ErrorCode::DevupFigmaDirectUnavailable, @@ -141,27 +115,3 @@ fn classified_errors_never_copy_the_raw_upstream_message() { assert!(!serialized.contains("figma-secret-token")); assert!(!serialized.contains("Authorization")); } - -#[test] -fn auto_can_decide_fallback_from_the_safe_public_error() { - let catalog = upstream_failure_error( - UpstreamFailureContext::Connect, - Some(403), - "Figma MCP Catalog rejected bearer-secret", - ); - assert!(fallback_allowed_for_error(SourcePolicy::Auto, &catalog)); - assert!(!fallback_allowed_for_error(SourcePolicy::Direct, &catalog)); - - let rate_limited = - upstream_failure_error(UpstreamFailureContext::CallTool, Some(429), "bearer-secret"); - assert!(!fallback_allowed_for_error( - SourcePolicy::Auto, - &rate_limited - )); - assert_eq!(rate_limited.code, ErrorCode::DevupFigmaRateLimited); - assert!( - !serde_json::to_string(&rate_limited) - .unwrap() - .contains("bearer-secret") - ); -} diff --git a/crates/devup-mcp-figma/tests/upstream_contract.rs b/crates/devup-mcp-figma/tests/upstream_contract.rs index c7b16659..6df9ce44 100644 --- a/crates/devup-mcp-figma/tests/upstream_contract.rs +++ b/crates/devup-mcp-figma/tests/upstream_contract.rs @@ -130,21 +130,30 @@ fn asset_export_uses_only_compiled_read_only_export_settings() { } #[test] -fn snapshot_manifest_covers_current_official_node_properties() { +fn snapshot_manifest_covers_fields_the_devup_ui_converter_actually_reads() { + // The manifest is scoped to devup-ui codegen consumption (verified + // against `crates/devup-mcp-devup-ui`), not the full official Plugin API + // surface — `maskType`, `detachedInfo`, `exposedInstances` and + // `isExposedInstance` were removed because nothing reads them. let call = ReadToolCall::snapshot("file-key", "1:2", BuiltinScript::NodeSnapshot); let code = call.arguments()["code"].as_str().unwrap().to_owned(); for property in [ - "\"maskType\"", - "\"overflowDirection\"", "\"primaryAxisAlignItems\"", "\"componentPropertyReferences\"", - "\"detachedInfo\"", - "\"exposedInstances\"", - "\"isExposedInstance\"", + "\"layoutSizingHorizontal\"", + "\"boundVariables\"", + "\"strokeStyleId\"", + "\"textStyleId\"", ] { assert!(code.contains(property), "manifest omitted {property}"); } + for property in ["\"maskType\"", "\"detachedInfo\"", "\"exposedInstances\""] { + assert!( + !code.contains(property), + "manifest still carries unused {property}" + ); + } } #[test] @@ -188,7 +197,7 @@ fn search_uses_a_compiled_read_only_page_projection() { "file-key", "0:1", SearchReadOptions { - query: "본연체".to_owned(), + query: "Essence".to_owned(), node_types: vec!["FRAME".to_owned()], match_kind: "normalized".to_owned(), limit: 20, @@ -199,7 +208,7 @@ fn search_uses_a_compiled_read_only_page_projection() { assert_eq!(call.tool_name(), "use_figma"); assert!(code.contains("figma.setCurrentPageAsync(page)")); assert!(code.contains("page.findAll")); - assert!(code.contains("본연체")); + assert!(code.contains("Essence")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); } @@ -254,12 +263,22 @@ fn multi_root_fast_snapshot_embeds_only_validated_root_ids() { let code = call.arguments()["code"].as_str().unwrap().to_owned(); assert_eq!(call.tool_name(), "use_figma"); - assert_eq!(call.arguments()["nodeId"], "4217:7743"); + // The official `use_figma` schema forbids a `nodeId` argument + // (`additionalProperties: false`); the target node is tracked outside + // `arguments` (`PlannedCall::expected_node_id` / `HandoffCall::node_id`). + assert!(!call.arguments().contains_key("nodeId")); + assert!( + call.arguments()["description"] + .as_str() + .unwrap() + .contains("4217:7743") + ); assert!(code.contains("[\"10:3\",\"10:2\"]")); assert!(code.contains("requestedRootIds")); assert!(code.contains("rootIds: roots.map")); assert!(code.contains("getStyledTextSegments(textSegmentManifest)")); - assert!(code.contains("devupFastSnapshotDescriptor")); + assert!(code.contains("devupFastSnapshotEnvelope")); + assert!(!code.contains("figma.io.write")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); } @@ -316,13 +335,44 @@ fn used_resources_use_exact_ids_without_file_catalog_or_consumers() { } #[test] -fn fast_snapshot_is_lossless_bounded_and_read_only() { +fn fast_snapshot_is_paginated_manifest_scoped_and_read_only() { let call = ReadToolCall::fast_snapshot("file-key", "1:2"); let code = call.arguments()["code"].as_str().unwrap().to_owned(); assert_eq!(call.tool_name(), "use_figma"); assert!(code.contains("figma.getNodeByIdAsync")); - assert!(code.contains("if (name in value) names.add(name)")); + // Node property collection no longer walks the prototype chain (that only + // remains for variable/style *resource* serialization, which has no + // manifest) and never buckets unlisted fields into "extra" — only the + // checked-in manifest is ever collected for a node. + assert!(code.contains("for (const name of manifest)")); + assert!(!code.contains("(manifestSet.has(name) ? fields : extra)")); + assert!(!code.contains("const manifestSet = new Set(manifest)")); + // Default-valued fields are dropped; the tables must stay in sync with + // `devup-mcp-devup-ui/tests/default_omission_golden.rs`. + assert!(code.contains("const SCALAR_DEFAULTS = new Map([")); + assert!(code.contains(r#"const NULL_SENSITIVE_FIELDS = new Set(["maxWidth", "maxHeight"]);"#)); + // Presence-sensitive fields must never appear in the omission table. + for presence_sensitive in [ + "[\"opacity\"", + "[\"visible\"", + "[\"layoutPositioning\"", + "[\"topLeftRadius\"", + "[\"strokeWeight\"", + ] { + assert!( + !code.contains(presence_sensitive), + "{presence_sensitive} must not be omittable" + ); + } + // One serializer now covers both node fields and resources. + assert!(!code.contains("function serializeResource(")); + assert!(!code.contains("function resourcePropertyNames(")); + // Byte length is measured without building a throwaway byte array. + assert!(!code.contains("function utf8Encode(")); + assert!(code.contains("utf8ByteLength(JSON.stringify(envelope))")); + // The cursor marker is the only page-state carrier; no `pagination` mirror. + assert!(!code.contains("pagination:")); assert!(code.contains("getStyledTextSegments(textSegmentManifest)")); for field in [ "strokeTopWeight", @@ -335,34 +385,69 @@ fn fast_snapshot_is_lossless_bounded_and_read_only() { assert!(code.contains("getVariableByIdAsync")); assert!(code.contains("getVariableCollectionByIdAsync")); assert!(code.contains("getStyleByIdAsync")); - assert!(code.contains("Promise.all([...variableJobs, ...styleJobs])")); + assert!(code.contains("async function collectResources(nodes)")); assert!(code.contains("usedVariableIds")); assert!(code.contains("usedStyleIds")); - assert!(code.contains("duVp")); - assert!(code.contains("figma.io.write")); - assert!(code.contains("devup-fast-snapshot-${sequence + 1}-of-${chunkCount}.png")); - assert!(code.contains("devupFastSnapshotDescriptor")); - assert!(code.contains("MAX_ENVELOPE_BYTES")); - assert!(code.contains("0xfffd")); - assert!(!code.contains("maxPayloadBytes")); - assert!(!code.contains("maxFieldBytes")); + // A page carries the resources its nodes reference, so the envelope is + // only bounded once both are built - the script must shrink the page by + // the overshoot and retry rather than emit an oversized envelope. + assert!(code.contains("packedBytes - overshoot")); + assert!(!code.contains("Math.floor(nodeBudget / 2)")); + // The Figma MCP cuts a text result at 20,480 UTF-8 bytes; the page is + // packed to 1 KiB under that, measured as the bytes that are cut. + assert!(code.contains("const MAX_TEXT_ENVELOPE_BYTES = 19 * 1024;")); + assert!(code.contains("utf8ByteLength(JSON.stringify(envelope))")); + // Item B: PNG-chunked binary transport is gone entirely — text only, + // dynamically byte-budgeted and cursor-paginated like the legacy path. + assert!(!code.contains("duVp")); + assert!(!code.contains("figma.io.write")); + assert!(!code.contains("devup-fast-snapshot")); + assert!(!code.contains("devupFastSnapshotDescriptor")); + assert!(!code.contains("pngChunk")); + assert!(!code.contains("crc32")); + assert!(code.contains("maxPayloadBytes")); + assert!(code.contains("__DEVUP_SNAPSHOT_CURSOR__")); + // Every field the Rust decoder reads off the cursor marker must actually + // be emitted. `offset` in particular is what distinguishes a first page + // from a continuation page in `envelope.rs::peek_page_cursor`; omitting + // it silently downgraded the whole fast path to legacy collection. + for cursor_field in [ + " offset,", + " nextOffset,", + " complete: nextOffset >= allNodes.length,", + " totalNodes: allNodes.length,", + ] { + assert!( + code.contains(cursor_field), + "cursor marker must emit {cursor_field}" + ); + } + // The 19 KiB text limit is the only envelope ceiling left; the old 1MB + // companion check could never fire ahead of it. + assert!(code.contains("MAX_TEXT_ENVELOPE_BYTES")); + assert!(!code.contains("MAX_ENVELOPE_BYTES")); + assert!(code.contains("devupFastSnapshotEnvelope")); + assert!(code.contains("DEVUP_TARGET_IS_SECTION")); assert!(!code.contains("DEVUP_FIELD_VALUE_TRUNCATED")); assert!(!code.contains("MAX_INLINE_FIELD_BYTES")); assert!(!code.contains("devupLargeValueDescriptor")); assert!(!code.contains("$largeValue")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); - assert_eq!(code.matches("figma.io.write(").count(), 1); } #[test] -fn fast_snapshot_resolves_every_compiled_placeholder_after_inserting_the_section_probe() { +fn fast_snapshot_resolves_every_compiled_placeholder_for_the_requested_root() { let call = ReadToolCall::fast_snapshot("file-key", "3879:35518"); let code = call.arguments()["code"].as_str().unwrap().to_owned(); - assert!(code.contains("figma.getNodeByIdAsync(\"3879:35518\")")); + assert!(code.contains("const requestedRootIds = [\"3879:35518\"]")); + // `__DEVUP_SNAPSHOT_CURSOR__` is a real runtime node-ID sentinel (same + // one the legacy cursor snapshot uses), not a template placeholder — it + // is never meant to be substituted, so it is excluded from this check. + let without_cursor_sentinel = code.replace("__DEVUP_SNAPSHOT_CURSOR__", ""); assert!( - !code.contains("__DEVUP_"), + !without_cursor_sentinel.contains("__DEVUP_"), "compiled fast snapshot leaked an unresolved template placeholder" ); } @@ -394,6 +479,17 @@ fn fast_theme_collects_complete_local_theme_and_used_remote_resources_read_only( assert_eq!(call.tool_name(), "use_figma"); assert_eq!(arguments["fileKey"], "file-key"); assert!(arguments.get("nodeId").is_none()); + // The theme is paged: the script is told where to start, packs a run of + // the resources under the text cap, and says where the next page starts. + assert!(code.contains("{\"offset\":0}")); + assert!(code.contains("nextOffset")); + assert!(code.contains("totalItems")); + assert!( + ReadToolCall::fast_theme_page("file-key", 7).arguments()["code"] + .as_str() + .unwrap() + .contains("{\"offset\":7}") + ); for read in [ "getLocalVariableCollectionsAsync", "getLocalVariablesAsync", @@ -407,10 +503,16 @@ fn fast_theme_collects_complete_local_theme_and_used_remote_resources_read_only( assert!(code.contains(read), "missing theme read {read}"); } assert!(code.contains("usedRemoteVariables")); - assert!(code.contains("devupFastThemeDescriptor")); - assert!(code.contains("devup-fast-theme-${sequence + 1}-of-${chunkCount}.png")); - assert!(code.contains("duVp")); + // No binary transport exists any more — a theme that doesn't fit as text + // throws and the caller falls back to the legacy per-resource path. + assert!(!code.contains("devupFastThemeDescriptor")); + assert!(!code.contains("devup-fast-theme")); + assert!(!code.contains("duVp")); + assert!(!code.contains("figma.io.write")); + assert!(!code.contains("pngChunk")); assert!(code.contains("MAX_ENVELOPE_BYTES")); + assert!(code.contains("MAX_TEXT_ENVELOPE_BYTES")); + assert!(code.contains("devupFastThemeEnvelope")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); for mutation in [ diff --git a/crates/devup-mcp-figma/tests/used_resources.rs b/crates/devup-mcp-figma/tests/used_resources.rs index 0afc2854..a92aa739 100644 --- a/crates/devup-mcp-figma/tests/used_resources.rs +++ b/crates/devup-mcp-figma/tests/used_resources.rs @@ -64,7 +64,7 @@ fn scanner_collects_bound_variables_and_every_supported_style_field() { "gridStyleId": "S:grid", "backgroundStyleId": "S:background", "styledTextSegments": [{ - "characters": "[1. 이름]", + "characters": "[1. Name]", "textStyleId": "S:text-emphasis", "boundVariables": { "fills": [{"type": "VARIABLE_ALIAS", "id": "VariableID:90:12"}] diff --git a/crates/devup-mcp-visual/src/lib.rs b/crates/devup-mcp-visual/src/lib.rs index a806dbe1..3d057f00 100644 --- a/crates/devup-mcp-visual/src/lib.rs +++ b/crates/devup-mcp-visual/src/lib.rs @@ -63,9 +63,9 @@ pub enum VisualError { impl std::fmt::Display for VisualError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Image(error) => write!(formatter, "PNG를 읽거나 쓸 수 없습니다: {error}"), + Self::Image(error) => write!(formatter, "Could not read or write the PNG: {error}"), Self::InvalidThreshold => { - formatter.write_str("max_changed_ratio는 0 이상 1 이하여야 합니다.") + formatter.write_str("max_changed_ratio must be between 0 and 1 inclusive.") } } } diff --git a/crates/devup-mcp-visual/src/main.rs b/crates/devup-mcp-visual/src/main.rs index 067a8b79..cfd24c1b 100644 --- a/crates/devup-mcp-visual/src/main.rs +++ b/crates/devup-mcp-visual/src/main.rs @@ -30,7 +30,7 @@ fn run(arguments: Vec) -> Result { let option = arguments[index].as_str(); let value = arguments .get(index + 1) - .ok_or_else(|| format!("{option} 값이 필요합니다."))?; + .ok_or_else(|| format!("{option} requires a value."))?; match option { "--reference" => reference = Some(PathBuf::from(value)), "--actual" => actual = Some(PathBuf::from(value)), @@ -38,20 +38,20 @@ fn run(arguments: Vec) -> Result { "--channel-tolerance" => { options.channel_tolerance = value .parse() - .map_err(|_| "channel tolerance가 올바르지 않습니다.".to_owned())?; + .map_err(|_| "channel tolerance is not a valid value.".to_owned())?; } "--max-changed-ratio" => { options.max_changed_ratio = value .parse() - .map_err(|_| "max changed ratio가 올바르지 않습니다.".to_owned())?; + .map_err(|_| "max changed ratio is not a valid value.".to_owned())?; } - _ => return Err(format!("알 수 없는 option입니다: {option}")), + _ => return Err(format!("Unsupported option: {option}")), } index += 2; } let report = compare_png( - reference.ok_or_else(|| "--reference가 필요합니다.".to_owned())?, - actual.ok_or_else(|| "--actual이 필요합니다.".to_owned())?, + reference.ok_or_else(|| "--reference is required.".to_owned())?, + actual.ok_or_else(|| "--actual is required.".to_owned())?, &options, ) .map_err(|error| error.to_string())?; diff --git a/crates/devup-mcp-visual/tests/compare.rs b/crates/devup-mcp-visual/tests/compare.rs index ed6911f7..e01d636e 100644 --- a/crates/devup-mcp-visual/tests/compare.rs +++ b/crates/devup-mcp-visual/tests/compare.rs @@ -3,9 +3,13 @@ use std::{path::PathBuf, time::SystemTime}; use devup_mcp_visual::{CompareOptions, VisualStatus, compare_png}; use image::{ImageBuffer, Rgba}; -fn temp_dir() -> anyhow::Result { +/// One directory per test. The two tests here run on two threads of one +/// process, and a clock read on each is the same value often enough that +/// they shared a directory and each other's PNGs - a 4x2 reference next to +/// a 3x2 actual, reported as `InvalidDimensions` by whichever test lost. +fn temp_dir(test: &str) -> anyhow::Result { let path = std::env::temp_dir().join(format!( - "devup-mcp-visual-{}-{}", + "devup-mcp-visual-{test}-{}-{}", std::process::id(), SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH)? @@ -29,7 +33,7 @@ fn write_png(path: &std::path::Path, pixels: &[[u8; 4]], width: u32) -> anyhow:: #[test] fn exact_and_changed_pngs_report_deterministic_metrics_and_diff() -> anyhow::Result<()> { - let root = temp_dir()?; + let root = temp_dir("exact-and-changed")?; let reference = root.join("reference.png"); let actual = root.join("actual.png"); let diff = root.join("diff.png"); @@ -63,7 +67,7 @@ fn exact_and_changed_pngs_report_deterministic_metrics_and_diff() -> anyhow::Res #[test] fn tolerance_and_dimension_mismatch_are_explicit() -> anyhow::Result<()> { - let root = temp_dir()?; + let root = temp_dir("tolerance-and-dimensions")?; let reference = root.join("reference.png"); let actual = root.join("actual.png"); write_png(&reference, &[[100, 100, 100, 255]; 4], 2)?; diff --git a/crates/devup-mcp/Cargo.toml b/crates/devup-mcp/Cargo.toml index 121b5af7..f3aa2034 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -26,4 +26,11 @@ tracing-subscriber.workspace = true [dev-dependencies] axum.workspace = true +# Also a normal dependency. Tests need it too, to canonicalise an expected path +# the same way OutputPolicy does — std::fs::canonicalize would return a `\\?\` +# UNC path on Windows and never match. +dunce.workspace = true reqwest.workspace = true +# `start_paused` lets a test watch the retry waits elapse without spending the +# minute they describe. +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/devup-mcp/src/lib.rs b/crates/devup-mcp/src/lib.rs index 0c905577..3d8911fd 100644 --- a/crates/devup-mcp/src/lib.rs +++ b/crates/devup-mcp/src/lib.rs @@ -4,9 +4,99 @@ use std::{ffi::OsString, path::PathBuf}; use serde::Serialize; +pub use devup_mcp_figma::ClientCredentialSource; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServerConfig { pub allowed_write_roots: Vec, + /// From `--figma-client-id`. `None` unless the flag was passed. + pub figma_client_id: Option, + /// From `--figma-client-secret`. `None` unless the flag was passed. + pub figma_client_secret: Option, + /// From `--figma-callback-port`. `None` preserves the pre-existing + /// OS-assigned-port behavior. + pub figma_callback_port: Option, + /// From `--figma-client-name`. `None` keeps devup-mcp's own literal + /// name for Dynamic Client Registration. + pub figma_client_name: Option, +} + +/// Fully resolved Figma direct-connection configuration: cli-arg values +/// (if any) win over environment variables, which win over "nothing +/// configured here" (the persisted `configure` store, if any, is resolved +/// later inside `OAuthManager`, not here). Built by +/// [`resolve_figma_direct_config`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FigmaDirectConfig { + pub client_id: Option, + pub client_secret: Option, + pub credential_source: ClientCredentialSource, + pub callback_port: Option, + /// `client_name` for Dynamic Client Registration. `None` keeps + /// [`devup_mcp_figma::DEFAULT_CLIENT_NAME`]. Resolved independently of + /// the client-id/secret pair: a pre-registered credential skips DCR + /// entirely, so the two settings are never both in play. + pub client_name: Option, +} + +/// Resolves the effective Figma direct-connection client credential from +/// (in priority order) cli-arg flags, then environment variables. Takes +/// the environment values as explicit parameters — rather than reading +/// `std::env::var` internally — so this stays a pure, deterministically +/// testable function; callers pass real env values at the process +/// boundary (see `run_stdio_with_config`, `self_check`). +pub fn resolve_figma_direct_config( + cli_client_id: Option, + cli_client_secret: Option, + cli_callback_port: Option, + cli_client_name: Option, + env_client_id: Option, + env_client_secret: Option, + env_client_name: Option, +) -> FigmaDirectConfig { + // Resolved independently of the credential pair below: a client name + // only matters on the Dynamic Client Registration path, which a + // pre-registered client_id skips outright. + let client_name = cli_client_name.or(env_client_name); + if let Some(client_id) = cli_client_id { + return FigmaDirectConfig { + client_id: Some(client_id), + client_secret: cli_client_secret, + credential_source: ClientCredentialSource::CliArg, + callback_port: cli_callback_port, + client_name, + }; + } + if let Some(client_id) = env_client_id { + return FigmaDirectConfig { + client_id: Some(client_id), + client_secret: env_client_secret, + credential_source: ClientCredentialSource::Env, + callback_port: cli_callback_port, + client_name, + }; + } + FigmaDirectConfig { + callback_port: cli_callback_port, + client_name, + ..FigmaDirectConfig::default() + } +} + +/// Reads `DEVUP_FIGMA_CLIENT_ID`/`DEVUP_FIGMA_CLIENT_SECRET`/ +/// `DEVUP_FIGMA_CLIENT_NAME`, treating an empty value the same as an +/// unset one. +fn env_figma_client_credentials() -> (Option, Option, Option) { + let read = |key: &str| { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) + }; + ( + read("DEVUP_FIGMA_CLIENT_ID"), + read("DEVUP_FIGMA_CLIENT_SECRET"), + read("DEVUP_FIGMA_CLIENT_NAME"), + ) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -38,26 +128,88 @@ where { let mut arguments = arguments.into_iter().map(Into::into).peekable(); let mut roots = Vec::new(); + let mut figma_client_id: Option = None; + let mut figma_client_secret: Option = None; + let mut figma_callback_port: Option = None; + let mut figma_client_name: Option = None; while let Some(argument) = arguments.next() { + let no_other_options_yet = roots.is_empty() + && figma_client_id.is_none() + && figma_client_secret.is_none() + && figma_callback_port.is_none() + && figma_client_name.is_none(); match argument.to_str() { - Some("--version" | "-V") if roots.is_empty() && arguments.peek().is_none() => { + Some("--version" | "-V") if no_other_options_yet && arguments.peek().is_none() => { return Ok(CliAction::Version); } - Some("--self-check") if roots.is_empty() && arguments.peek().is_none() => { + Some("--self-check") if no_other_options_yet && arguments.peek().is_none() => { return Ok(CliAction::SelfCheck); } Some("--allow-write-root") => { let root = arguments.next().ok_or_else(|| { - anyhow::anyhow!("--allow-write-root에는 폴더 경로가 필요합니다.") + anyhow::anyhow!("--allow-write-root requires a directory path.") })?; let root = PathBuf::from(root); if !root.is_dir() { - anyhow::bail!("--allow-write-root는 존재하는 폴더여야 합니다."); + anyhow::bail!("--allow-write-root must be an existing directory."); } roots.push(root); } - Some(flag) => anyhow::bail!("지원하지 않는 devup-mcp 인자입니다: {flag}"), - None => anyhow::bail!("devup-mcp 인자는 UTF-8 flag여야 합니다."), + Some("--figma-client-id") => { + let value = arguments + .next() + .ok_or_else(|| anyhow::anyhow!("--figma-client-id requires a value."))?; + let value = value + .to_str() + .ok_or_else(|| anyhow::anyhow!("--figma-client-id must be a UTF-8 string."))? + .to_owned(); + if value.is_empty() { + anyhow::bail!("--figma-client-id must not be empty."); + } + figma_client_id = Some(value); + } + Some("--figma-client-secret") => { + let value = arguments + .next() + .ok_or_else(|| anyhow::anyhow!("--figma-client-secret requires a value."))?; + let value = value + .to_str() + .ok_or_else(|| { + anyhow::anyhow!("--figma-client-secret must be a UTF-8 string.") + })? + .to_owned(); + if value.is_empty() { + anyhow::bail!("--figma-client-secret must not be empty."); + } + figma_client_secret = Some(value); + } + Some("--figma-client-name") => { + let value = arguments + .next() + .ok_or_else(|| anyhow::anyhow!("--figma-client-name requires a value."))?; + let value = value + .to_str() + .ok_or_else(|| anyhow::anyhow!("--figma-client-name must be a UTF-8 string."))? + .trim() + .to_owned(); + if value.is_empty() { + anyhow::bail!("--figma-client-name must not be empty."); + } + figma_client_name = Some(value); + } + Some("--figma-callback-port") => { + let value = arguments.next().ok_or_else(|| { + anyhow::anyhow!("--figma-callback-port requires a port number.") + })?; + let value = value.to_str().ok_or_else(|| { + anyhow::anyhow!("--figma-callback-port must be a UTF-8 string.") + })?; + figma_callback_port = Some(value.parse::().map_err(|_| { + anyhow::anyhow!("--figma-callback-port must be a number between 1 and 65535.") + })?); + } + Some(flag) => anyhow::bail!("Unsupported devup-mcp argument: {flag}"), + None => anyhow::bail!("devup-mcp arguments must be UTF-8 flags."), } } if roots.is_empty() { @@ -65,14 +217,28 @@ where } Ok(CliAction::Serve(ServerConfig { allowed_write_roots: roots, + figma_client_id, + figma_client_secret, + figma_callback_port, + figma_client_name, })) } pub fn self_check() -> SelfCheckReport { let credential_ok = devup_mcp_figma::KeyringCredentialStore::probe().is_ok(); + let (env_client_id, env_client_secret, env_client_name) = env_figma_client_credentials(); + let figma_direct = resolve_figma_direct_config( + None, + None, + None, + None, + env_client_id, + env_client_secret, + env_client_name, + ); let server_ok = std::env::current_dir() .ok() - .and_then(|root| server::DevupServer::production_with_output_roots(vec![root]).ok()) + .and_then(|root| server::DevupServer::production_with_config(vec![root], figma_direct).ok()) .is_some(); SelfCheckReport { status: if credential_ok && server_ok { @@ -98,9 +264,20 @@ pub async fn run_stdio() -> anyhow::Result<()> { pub async fn run_stdio_with_config(config: ServerConfig) -> anyhow::Result<()> { use rmcp::ServiceExt; - let service = server::DevupServer::production_with_output_roots(config.allowed_write_roots)? - .serve((tokio::io::stdin(), tokio::io::stdout())) - .await?; + let (env_client_id, env_client_secret, env_client_name) = env_figma_client_credentials(); + let figma_direct = resolve_figma_direct_config( + config.figma_client_id.clone(), + config.figma_client_secret.clone(), + config.figma_callback_port, + config.figma_client_name.clone(), + env_client_id, + env_client_secret, + env_client_name, + ); + let service = + server::DevupServer::production_with_config(config.allowed_write_roots, figma_direct)? + .serve((tokio::io::stdin(), tokio::io::stdout())) + .await?; service.waiting().await?; Ok(()) } diff --git a/crates/devup-mcp/src/server/artifacts.rs b/crates/devup-mcp/src/server/artifacts.rs index b221d8ad..e159108e 100644 --- a/crates/devup-mcp/src/server/artifacts.rs +++ b/crates/devup-mcp/src/server/artifacts.rs @@ -655,7 +655,7 @@ impl ArtifactStore { { return Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "resource output 이름 또는 MIME 형식이 올바르지 않습니다.", + "The resource output name or MIME type is invalid.", false, )); } @@ -669,7 +669,7 @@ impl ArtifactStore { { return Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "resource output ID가 중복되었거나 올바르지 않습니다.", + "The resource output ID is duplicated or invalid.", true, )); } @@ -711,7 +711,7 @@ impl ArtifactStore { .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "resource allocation 크기가 안전한 범위를 초과했습니다.", + "The resource allocation size exceeded the safe range.", false, ) })?; @@ -725,7 +725,7 @@ impl ArtifactStore { { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "resource output이 artifact 메모리 한도를 초과했습니다.", + "The resource output exceeded the artifact memory limit.", false, )); } @@ -737,7 +737,7 @@ impl ArtifactStore { if retained_bytes.saturating_add(allocation) > self.limits.max_total_bytes { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "resource output이 전체 메모리 한도를 초과했습니다.", + "The resource output exceeded the total memory limit.", false, )); } @@ -843,7 +843,7 @@ impl ArtifactStore { let bytes = serde_json::to_vec(&payload).map_err(|error| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("Figma artifact를 직렬화할 수 없습니다: {error}"), + format!("Cannot serialize the Figma artifact: {error}"), false, ) })?; @@ -853,7 +853,7 @@ impl ArtifactStore { { return Err(DevupError::with_details( ErrorCode::DevupFigmaResponseTooLarge, - "Figma artifact가 메모리 캐시 한도를 초과했습니다.", + "The Figma artifact exceeded the memory cache limit.", false, json!({"artifactBytes": bytes.len()}), )); @@ -1004,7 +1004,7 @@ fn output_chunk_ranges(bytes: &[u8], is_binary: bool) -> Result Result String { fn acquisition_cancelled() -> DevupError { DevupError::new( ErrorCode::DevupFigmaDirectUnavailable, - "동일 Figma artifact 수집이 완료되기 전에 취소되었습니다.", + "Collection of the same Figma artifact was cancelled before it completed.", true, ) } @@ -1051,7 +1051,7 @@ fn acquisition_cancelled() -> DevupError { fn resource_expired() -> DevupError { DevupError::new( ErrorCode::DevupFigmaHandoffExpired, - "resource artifact가 없거나 만료되었습니다.", + "The resource artifact is missing or expired.", true, ) } diff --git a/crates/devup-mcp/src/server/call_cache.rs b/crates/devup-mcp/src/server/call_cache.rs new file mode 100644 index 00000000..b4697edc --- /dev/null +++ b/crates/devup-mcp/src/server/call_cache.rs @@ -0,0 +1,226 @@ +//! Keeping the calls a failed collection already paid for. +//! +//! A collection is all-or-nothing: one refusal ends it, and every read it had +//! already made is discarded. Under a metered allowance that is unrecoverable +//! rather than merely wasteful. Capturing one page-height screen was measured +//! at over a hundred and ten reads against a seat allowed two hundred a day, +//! and three attempts on three different days would each start from nothing +//! and each end in the same place — the allowance spends down, the work never +//! accumulates, and the screen is never captured at all. +//! +//! Banking each read as it succeeds turns those attempts into progress. The +//! next one replays what is already held and spends its allowance only on what +//! is still missing, so a capture too large for one day's allowance completes +//! across several. +//! +//! Off unless asked for. A cached read is a claim about a file as it was, and +//! serving one silently would let a fixture disagree with the design it is +//! supposed to be a record of. Naming a directory is the caller saying they +//! are capturing rather than reading, and want the calls kept. + +use std::{ + path::PathBuf, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use devup_mcp_figma::ReadToolCall; + +/// Names the directory to keep calls in, and switches the cache on. +const DIRECTORY_VARIABLE: &str = "DEVUP_FIGMA_CALL_CACHE"; + +/// How long a kept call may still be replayed. A capture that spans days is +/// the case this exists for, so the window has to outlast a night; a design +/// that changes underneath it is the risk that stops it being longer. +const DEFAULT_TTL: Duration = Duration::from_secs(48 * 60 * 60); + +pub struct CallCache { + directory: Option, + ttl: Duration, +} + +impl CallCache { + pub fn from_env() -> Self { + let directory = std::env::var(DIRECTORY_VARIABLE) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + Self { + directory, + ttl: DEFAULT_TTL, + } + } + + pub fn new(directory: Option, ttl: Duration) -> Self { + Self { directory, ttl } + } + + /// The response held for this call, if one is and it is still fresh. + pub fn get(&self, call: &ReadToolCall) -> Option { + let path = self.path_for(call)?; + let raw = std::fs::read_to_string(&path).ok()?; + let held: Held = serde_json::from_str(&raw).ok()?; + (now_epoch_seconds().saturating_sub(held.stored_at) <= self.ttl.as_secs()) + .then_some(held.response) + } + + /// Keeps this call's response for a later attempt. Best effort: a capture + /// that cannot write its cache should still return its result. + pub fn put(&self, call: &ReadToolCall, response: &Value) { + let Some(path) = self.path_for(call) else { + return; + }; + let Some(parent) = path.parent() else { + return; + }; + if std::fs::create_dir_all(parent).is_err() { + return; + } + let held = Held { + stored_at: now_epoch_seconds(), + response: response.clone(), + }; + let Ok(encoded) = serde_json::to_vec(&held) else { + return; + }; + // Written beside the target and renamed, so an interrupted capture + // cannot leave a half-written response to be replayed as a whole one. + let temporary = path.with_extension("partial"); + if std::fs::write(&temporary, &encoded).is_ok() { + let _ = std::fs::rename(&temporary, &path); + } + } + + fn path_for(&self, call: &ReadToolCall) -> Option { + self.directory + .as_ref() + .map(|directory| directory.join(format!("{}.json", key_for(call)))) + } +} + +/// What a call is, reduced to something stable enough to look up by. +/// +/// The tool name and its arguments are the whole of what upstream is asked, +/// so two calls agreeing on both must have the same answer. The arguments are +/// walked in sorted order rather than serialised as they arrive, because a map +/// that preserves insertion order would otherwise key the same call two ways. +fn key_for(call: &ReadToolCall) -> String { + let mut hasher = Sha256::new(); + hasher.update(call.tool_name().as_bytes()); + hasher.update([0]); + let arguments = call.arguments(); + let mut names = arguments.keys().collect::>(); + names.sort(); + for name in names { + hasher.update(name.as_bytes()); + hasher.update([0]); + if let Some(value) = arguments.get(name) { + hasher.update(value.to_string().as_bytes()); + } + hasher.update([0]); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct Held { + stored_at: u64, + response: Value, +} + +fn now_epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(label: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("devup-call-cache-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("scratch directory"); + path + } + + fn call() -> ReadToolCall { + ReadToolCall::metadata("FileKey123", Some("1:2")) + } + + /// Nothing is kept and nothing is replayed unless a directory was named. + #[test] + fn the_cache_is_off_until_a_directory_is_named() { + let cache = CallCache::new(None, DEFAULT_TTL); + cache.put(&call(), &serde_json::json!({"kept": true})); + assert!(cache.get(&call()).is_none()); + } + + /// What was banked comes back. + #[test] + fn a_kept_call_is_replayed() { + let directory = scratch("replay"); + let cache = CallCache::new(Some(directory.clone()), DEFAULT_TTL); + let response = serde_json::json!({"content": [{"text": "answered"}]}); + + assert!(cache.get(&call()).is_none(), "nothing is held yet"); + cache.put(&call(), &response); + assert_eq!(cache.get(&call()), Some(response)); + + let _ = std::fs::remove_dir_all(&directory); + } + + /// A different call is a different answer, and must not collide. + #[test] + fn a_different_call_is_kept_apart() { + let directory = scratch("apart"); + let cache = CallCache::new(Some(directory.clone()), DEFAULT_TTL); + cache.put(&call(), &serde_json::json!({"for": "1:2"})); + + let other = ReadToolCall::metadata("FileKey123", Some("9:9")); + assert!( + cache.get(&other).is_none(), + "another node must not read the first one's answer" + ); + + let _ = std::fs::remove_dir_all(&directory); + } + + /// A design moves on, so a call kept longer than the window stops being an + /// answer. Aged on disk rather than waited out, so the test costs nothing. + #[test] + fn a_stale_call_is_not_replayed() { + let directory = scratch("stale"); + let cache = CallCache::new(Some(directory.clone()), Duration::from_secs(60)); + let response = serde_json::json!({"old": true}); + cache.put(&call(), &response); + assert!( + cache.get(&call()).is_some(), + "what was just stored is still an answer" + ); + + let path = cache.path_for(&call()).expect("a path once enabled"); + let aged = Held { + stored_at: now_epoch_seconds().saturating_sub(600), + response, + }; + std::fs::write(&path, serde_json::to_vec(&aged).expect("aged entry")).expect("age it"); + + assert!( + cache.get(&call()).is_none(), + "ten minutes on, a one-minute window has passed" + ); + + let _ = std::fs::remove_dir_all(&directory); + } +} diff --git a/crates/devup-mcp/src/server/delivery.rs b/crates/devup-mcp/src/server/delivery.rs index 3e89ba8f..d8e2b553 100644 --- a/crates/devup-mcp/src/server/delivery.rs +++ b/crates/devup-mcp/src/server/delivery.rs @@ -33,7 +33,7 @@ impl FromStr for DeliveryMode { "resource" => Ok(Self::Resource), _ => Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "delivery는 auto, inline 또는 resource여야 합니다.", + "delivery must be auto, inline, or resource.", false, )), } @@ -110,7 +110,7 @@ pub fn choose_delivery( total.checked_add(output.bytes.len()).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "생성 output 크기가 안전한 범위를 초과했습니다.", + "The generated output size exceeded the safe range.", false, ) }) @@ -125,7 +125,7 @@ pub fn choose_delivery( }), DeliveryMode::Inline if total_bytes > MAX_INLINE_TOTAL_BYTES => Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "inline output이 1 MiB 상한을 초과했습니다. delivery=auto 또는 resource를 사용하세요.", + "The inline output exceeded the 1 MiB limit. Use delivery=auto or resource.", false, )), DeliveryMode::Inline => Ok(DeliveryDecision { inline: true }), @@ -145,7 +145,7 @@ fn projected_output_wire_bytes(output: &ProjectedOutput) -> Result Result MAX_INLINE_TOTAL_BYTES => Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "직렬화된 inline MCP response가 1 MiB 상한을 초과했습니다. delivery=auto 또는 resource를 사용하세요.", + "The serialized inline MCP response exceeded the 1 MiB limit. Use delivery=auto or resource.", false, )), DeliveryMode::Inline => Ok(DeliveryDecision { inline: true }), diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index a5c6b4bd..515a6d98 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -1,20 +1,12 @@ -//! Self-diagnosis for the "host has no Figma MCP registered" failure mode. +//! Self-diagnosis for the "the direct connection will not authenticate" failure mode. //! -//! `devup-mcp` never talks to Figma directly unless `direct` credentials are -//! stored (see `oauth.rs`). Everything else depends on the *host* exposing -//! an already-authenticated official Figma MCP for the `host` handoff path. -//! When that assumption is false, the agent driving `devup-mcp` used to get -//! a bare `needs_figma` envelope with no indication of what to do next, or a -//! one-line `{"status":"disconnected"}` from `devup_figma_auth status` that -//! gave no actionable next step. This module turns both responses into -//! structured, factual guidance: +//! `devup-mcp` talks to Figma over the direct connection, which needs stored +//! credentials (see `oauth.rs`). Without them `devup_figma_auth status` used to +//! answer a one-line `{"status":"disconnected"}` and no next step. This module +//! turns that into structured, factual guidance: //! -//! - [`host_requirement`] is attached to every `needs_figma` handoff step -//! and tells the agent exactly which tool to call, what not to touch, and -//! to stop and report rather than guess when no Figma MCP is reachable. //! - [`doctor_report`] backs the `devup_figma_auth {"action":"doctor"}` -//! action and reports which of the three connection paths (direct OAuth, -//! local Dev Mode MCP, host handoff) are actually usable right now, plus +//! action and reports whether the direct connection is usable right now, plus //! client-specific setup data for the constraints that were verified by //! hand (client_name allowlist, redirect_uri shape, the silent callback //! port collision, PAT rejection). @@ -22,162 +14,132 @@ //! All facts embedded here (allowlist behavior, redirect_uri constraints, //! the callback-port trap) were measured against the real Figma Remote MCP //! registration endpoint; see `README.md`'s "Figma 연결 설정" section for -//! the same data in prose form. `doctor_report` performs exactly one -//! network-free-adjacent probe (a bounded local TCP connect) and no -//! external HTTP calls, so it stays cheap enough to call on every -//! diagnosis. - -use std::time::Duration; +//! the same data in prose form. `doctor_report` makes no network call at +//! all, so it stays cheap enough to call on every diagnosis. +//! +//! The Figma desktop app's local Dev Mode MCP was reported here as a third +//! path, probed for and described as usable without OAuth. It is not one: +//! it serves six read tools and `use_figma` is not among them, so every +//! collection devup-mcp performs — snapshot, explore, section index, theme — +//! has no tool to run. Its tools also take only a node id, addressing +//! whatever the desktop app currently has open rather than a file key. +//! Naming it as a path sent agents to a dead end, so it is named nowhere. -use devup_mcp_figma::AuthStatus; +use devup_mcp_figma::{ + AuthStatus, ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, +}; use serde_json::{Value, json}; -/// Loopback address the Figma desktop app's local Dev Mode MCP server binds -/// when enabled. OAuth-free; reachable regardless of which MCP client host -/// is in use. -pub const LOCAL_DEV_MODE_ADDR: &str = "127.0.0.1:3845"; -/// The MCP endpoint URL for the local Dev Mode server (same host/port as -/// [`LOCAL_DEV_MODE_ADDR`], with the `/mcp` path Figma serves it on). -pub const LOCAL_DEV_MODE_ENDPOINT: &str = "http://127.0.0.1:3845/mcp"; - -/// Upper bound on how long a local reachability probe may block a tool -/// call. Deliberately short: this is a same-host TCP connect, not a network -/// round trip, so anything slower than a few hundred milliseconds means the -/// port simply is not listening. -const PROBE_TIMEOUT: Duration = Duration::from_millis(300); - -/// Best-effort, error-swallowing TCP reachability probe. A refused -/// connection, a timeout, or any other I/O failure is reported as `false` -/// rather than propagated: a diagnostic probe must never fail the request -/// it is trying to help diagnose. -async fn probe_reachable(addr: &str, timeout: Duration) -> bool { - tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr)) - .await - .is_ok_and(|connection| connection.is_ok()) -} - -/// Probes [`LOCAL_DEV_MODE_ADDR`] with a short timeout. Never errors. -pub async fn local_dev_mode_reachable() -> bool { - probe_reachable(LOCAL_DEV_MODE_ADDR, PROBE_TIMEOUT).await -} - -fn local_dev_mode_hint(reachable: bool) -> String { - if reachable { - format!( - "{LOCAL_DEV_MODE_ENDPOINT}가 응답하고 있습니다. 호스트에 이 로컬 Dev Mode MCP가 등록되어 있다면 OAuth 없이 그 도구를 바로 사용할 수 있습니다." - ) - } else { - format!( - "{LOCAL_DEV_MODE_ENDPOINT}가 응답하지 않습니다. Figma 데스크톱 앱 → Preferences → Dev Mode MCP 서버를 켜면 OAuth 없이 사용할 수 있습니다 (Dev 또는 Full 시트가 있는 유료 플랜 필요)." - ) - } -} - -/// Builds the `hostRequirement` block attached to every `needs_figma` -/// handoff step. This is the single most important payload in this module: -/// without it, an agent has to infer from a bare `calls` array that it must -/// find and invoke a *different*, host-registered MCP tool, verbatim, and -/// feed the raw result back — and has no signal that guessing the design -/// instead of stopping is unacceptable. `ifUnavailable.action` is always -/// the literal string `"stop-and-report"`; do not remove or soften it. -/// -/// Performs exactly one bounded local TCP probe -/// ([`local_dev_mode_reachable`]); never makes an external network call and -/// never fails the handoff it is attached to. -pub async fn host_requirement() -> Value { - let reachable = local_dev_mode_reachable().await; - json!({ - "reason": "devup-mcp는 Figma에 직접 접속하지 않습니다. 호스트에 등록된 공식 Figma MCP가 이 read-only 호출을 대신 실행해야 합니다.", - "steps": [ - "이 세션에 등록된 공식 Figma MCP를 찾으세요. 흔한 이름: figma, figma-desktop, figma-local, figma-remote-mcp.", - "calls[].tool 이름의 도구를 calls[].arguments 그대로 호출하세요. arguments의 code 필드를 절대 수정하지 마세요.", - "받은 원본 결과를 가공 없이 devup_figma_continue { sessionId, callId, result } 로 넘기세요.", - "status가 needs_figma면 만료(expiresAt) 전까지 반복하세요." - ], - "localDevMode": { - "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "reachable": reachable, - "hint": local_dev_mode_hint(reachable) - }, - "ifUnavailable": { - "action": "stop-and-report", - "message": "Figma MCP에 접근할 수 없으면 즉시 멈추고 보고하세요. 디자인 수치를 추측해서 구현하지 마세요.", - "setupHint": "devup_figma_auth { action: \"doctor\" } 를 호출하면 사용 가능한 경로와 클라이언트별 설정 방법을 얻을 수 있습니다." - } - }) -} - /// Builds the response for `devup_figma_auth {"action":"doctor"}`. /// /// `status` mirrors the existing `status` action's value so a caller that /// only reads `status` sees no behavior change. Everything under `paths` /// and `clientSetup` is new: `paths` reports what was actually measured /// (stored-credential presence, a live local-TCP probe, and the structural -/// fact that host handoff availability cannot be observed from inside this /// process), and `clientSetup` is static, verified reference data — never /// an instruction to register under a specific product name. Registration /// is allowlisted by Figma outside devup-mcp's control; this only reports /// the constraint and points at the public waitlist. -pub async fn doctor_report(status: AuthStatus) -> Value { - let reachable = local_dev_mode_reachable().await; +/// +/// `direct` supplies the richer, measured detail behind `paths.direct`: +/// which credential source is in play (never the secret itself), whether +/// the stored token is fresh, and — when a fixed callback port is +/// configured — whether it is actually free right now. +pub async fn doctor_report(status: AuthStatus, direct: DirectPathSnapshot) -> Value { let direct_available = status == AuthStatus::Connected; json!({ "status": status, "paths": { "direct": { "available": direct_available, - "reason": if direct_available { - "저장된 자격증명이 있습니다." - } else { - "저장된 자격증명 없음. Figma는 allowlist된 client_name으로 등록한 client에만 Dynamic Client Registration을 허용합니다." - } - }, - "localDevMode": { - "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "reachable": reachable, - "hint": "Figma 데스크톱 → Preferences → Dev Mode MCP 서버 활성화 (Dev/Full 시트 필요)" - }, - "hostHandoff": { - "expectedTool": "use_figma", - "note": "devup-mcp 내부에서는 확인 불가합니다. 호스트가 공식 Figma MCP를 노출해야 합니다." + "credentialSource": direct.credential_source, + "tokenState": direct.token_state, + "callbackPort": { + "port": direct.callback_port, + "free": direct.callback_port_free + }, + "registrationClientName": { + "value": direct.client_name, + "isDefault": direct.client_name == DEFAULT_CLIENT_NAME, + "note": "client_name Dynamic Client Registration will send. Figma matches it against its catalog allowlist exactly. The default is Codex, which the allowlist admits, so login works from a Codex install with no extra flags; Figma attributes that registration to Codex, not to devup-mcp. Once your own client is admitted through https://www.figma.com/mcp-catalog/, pass its name via --figma-client-name or DEVUP_FIGMA_CLIENT_NAME." + }, + "reason": direct_reason(direct_available, direct.credential_source) } }, "clientSetup": client_setup() }) } +/// `direct.available` only reflects whether *some* token is stored (see +/// `AuthStatus`), so this fills in *why* it isn't yet, using the measured +/// `credentialSource` rather than assuming DCR is the only path — a +/// pre-registered client just needs `login`, not `configure` or the +/// waitlist. +fn direct_reason( + direct_available: bool, + credential_source: ClientCredentialSource, +) -> &'static str { + if direct_available { + return "A stored credential is present."; + } + match credential_source { + ClientCredentialSource::None => { + "No stored credential. Run devup_figma_auth { action: \"login\" }: with no \ + pre-registered credential it falls back to Dynamic Client Registration under the \ + default allowlisted client_name (see registrationClientName). If that returns 403, \ + the allowlist rejected the name — register a client credential you obtained yourself \ + via devup_figma_auth { action: \"configure\", clientId, clientSecret }, join the \ + Figma MCP Catalog waitlist (https://www.figma.com/mcp-catalog/)." + } + ClientCredentialSource::CliArg + | ClientCredentialSource::Env + | ClientCredentialSource::CredentialStore => { + "A pre-registered client credential is present. Authenticate with devup_figma_auth \ + { action: \"login\" } to use the direct path." + } + } +} + fn client_setup() -> Value { json!({ "constraints": { "registerEndpoint": "POST https://api.figma.com/v1/oauth/mcp/register", - "clientNameAllowlist": "Figma는 등록 요청의 client_name을 정확히 일치하는 allowlist로만 승인합니다(예: Codex, Claude Code는 200; OpenCode, opencode, Cursor, VS Code는 403). 승인되지 않은 이름은 JSON이 아닌 평문 'Forbidden' 본문과 함께 403을 반환하므로 여러 클라이언트의 OAuth 오류 파싱까지 함께 깨집니다. 신규 client 등록은 waitlist를 통해서만 가능합니다: https://www.figma.com/mcp-catalog/", - "redirectUri": "redirect_uri는 경로가 정확히 /callback이어야 하고 호스트는 127.0.0.1이어야 합니다(200). localhost 호스트나 /mcp/oauth/callback 같은 다른 경로는 400으로 거절됩니다.", - "callbackPortCaution": "OS나 보안 소프트웨어가 로컬 OAuth 콜백 포트를 이미 점유하고 있으면 브라우저는 리다이렉트에 성공한 것처럼 보이지만, 그 요청은 다른 프로세스로 전달되어 클라이언트는 에러 없이 'Waiting for authorization...' 상태로 무한 대기합니다. 콜백 포트를 다른 프로세스가 쓰고 있지 않은지 먼저 확인하세요.", - "personalAccessToken": "Figma PAT(figd_...)는 Authorization: Bearer, X-Figma-Token 어느 방식으로도 원격 MCP에서 지원되지 않습니다." + "clientNameAllowlist": "Figma approves a registration request's client_name only against an exact-match allowlist (e.g. Codex and Claude Code get 200; OpenCode, opencode, Cursor, and VS Code get 403). A non-approved name returns 403 with a plain-text 'Forbidden' body instead of JSON, which also breaks OAuth error parsing in several clients. Registering a new client is only possible through the waitlist: https://www.figma.com/mcp-catalog/", + "redirectUri": "redirect_uri must use exactly the path /callback and the host 127.0.0.1 (200). A localhost host, or another path such as /mcp/oauth/callback, is rejected with 400.", + "callbackPortCaution": "If the OS or security software already occupies the local OAuth callback port, the browser looks like it redirected successfully, but that request goes to the other process and the client waits forever at 'Waiting for authorization...' with no error. Check first that no other process is using the callback port.", + "personalAccessToken": "A Figma PAT (figd_...) is not supported by the remote MCP through either Authorization: Bearer or X-Figma-Token." }, - "opencode": { - "hint": "mcp..oauth에 clientId/clientSecret/scope/callbackPort/redirectUri를 직접 지정하면 Dynamic Client Registration을 건너뜁니다. clientId/clientSecret은 allowlist된 client_name으로 직접 등록해 발급받아야 합니다.", - "example": { - "mcp": { - "figma": { - "type": "remote", - "url": "https://mcp.figma.com/mcp", - "oauth": { - "clientId": "", - "clientSecret": "", - "scope": "mcp:connect", - "callbackPort": 19876, - "redirectUri": "http://127.0.0.1:19876/callback" + "codex": { + "primary": true, + "hint": "The intended host. devup-mcp registers under client_name Codex by default, so devup_figma_auth { action: \"login\" } completes from a Codex install with no extra flags and no client_id/client_secret. Add --figma-client-name only once your own client is admitted to the Figma MCP catalog.", + "installDevupMcp": { + "file": "~/.codex/config.toml", + "toml": "[mcp_servers.devup-mcp]\ncommand = \"devup-mcp\"\nargs = [\"--allow-write-root\", \"\"]", + "then": "Restart Codex, then call devup_figma_auth { action: \"login\" } once to store the token." + }, + "officialFigmaMcp": "codex mcp add figma --url https://mcp.figma.com/mcp" + }, + "otherHosts": { + "note": "Reference only — devup-mcp targets Codex.", + "claudeCode": "claude mcp add --transport http figma https://mcp.figma.com/mcp", + "opencode": { + "hint": "Setting clientId/clientSecret/scope/callbackPort/redirectUri directly under mcp..oauth skips Dynamic Client Registration. clientId/clientSecret must be issued to you by registering yourself under an allowlisted client_name.", + "example": { + "mcp": { + "figma": { + "type": "remote", + "url": "https://mcp.figma.com/mcp", + "oauth": { + "clientId": "", + "clientSecret": "", + "scope": "mcp:connect", + "callbackPort": 19876, + "redirectUri": "http://127.0.0.1:19876/callback" + } } } } } - }, - "claudeCode": "claude mcp add --transport http figma https://mcp.figma.com/mcp", - "codex": "codex mcp add figma --url https://mcp.figma.com/mcp", - "localDevMode": { - "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "hint": "OAuth가 필요 없습니다. Figma 데스크톱 앱에서 Dev Mode MCP 서버를 켜면 어떤 MCP 클라이언트에서도 동일하게 동작합니다. Dev 또는 Full 시트가 있는 유료 플랜이 필요합니다." } }) } @@ -186,53 +148,123 @@ fn client_setup() -> Value { mod tests { use super::*; + fn absent_direct_snapshot() -> DirectPathSnapshot { + DirectPathSnapshot { + credential_source: ClientCredentialSource::None, + token_state: devup_mcp_figma::TokenState::Absent, + callback_port: None, + callback_port_free: None, + client_name: DEFAULT_CLIENT_NAME.to_owned(), + } + } + + #[tokio::test] + async fn doctor_report_reflects_measured_auth_status_without_changing_status_shape() { + let connected = doctor_report(AuthStatus::Connected, absent_direct_snapshot()).await; + assert_eq!(connected["status"], "connected"); + assert_eq!(connected["paths"]["direct"]["available"], true); + + let disconnected = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot()).await; + assert_eq!(disconnected["status"], "disconnected"); + assert_eq!(disconnected["paths"]["direct"]["available"], false); + assert!(disconnected["clientSetup"]["constraints"]["clientNameAllowlist"].is_string()); + assert!(disconnected["clientSetup"]["otherHosts"]["opencode"]["example"].is_object()); + } + + /// Codex is the host devup-mcp is installed into, so `clientSetup` + /// must lead with a self-contained Codex install path — the other + /// hosts stay available but demoted, so they cannot be mistaken for + /// the primary route. #[tokio::test] - async fn reports_reachable_when_a_listener_is_bound() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap().to_string(); - assert!(probe_reachable(&addr, PROBE_TIMEOUT).await); + async fn client_setup_leads_with_codex_and_demotes_the_other_hosts() { + let report = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot()).await; + let setup = &report["clientSetup"]; + + assert_eq!(setup["codex"]["primary"], true); + let toml = setup["codex"]["installDevupMcp"]["toml"] + .as_str() + .expect("codex install snippet"); + assert!(toml.contains("[mcp_servers.devup-mcp]")); + assert!(setup["codex"]["hint"].as_str().unwrap().contains("Codex")); + + // Demoted, not deleted: still the reference for installing elsewhere. + assert!(setup["otherHosts"]["claudeCode"].is_string()); + assert!(setup["otherHosts"]["opencode"]["example"].is_object()); + assert!(setup["claudeCode"].is_null()); + assert!(setup["opencode"].is_null()); } + /// The `client_name` DCR will actually send is the single fact that + /// decides whether `/register` returns 200 or a plain-text 403, so + /// `doctor` must report it — and must say plainly when it is still the + /// (non-allowlisted) default rather than an operator-supplied name. #[tokio::test] - async fn reports_unreachable_without_erroring_when_the_port_is_closed() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap().to_string(); - drop(listener); - assert!(!probe_reachable(&addr, PROBE_TIMEOUT).await); + async fn doctor_report_surfaces_the_registration_client_name_and_whether_it_is_default() { + let default_report = + doctor_report(AuthStatus::Disconnected, absent_direct_snapshot()).await; + let default_name = &default_report["paths"]["direct"]["registrationClientName"]; + assert_eq!(default_name["value"], DEFAULT_CLIENT_NAME); + assert_eq!(default_name["isDefault"], true); + + let overridden = doctor_report( + AuthStatus::Disconnected, + DirectPathSnapshot { + client_name: "Acme Registered Client".to_owned(), + ..absent_direct_snapshot() + }, + ) + .await; + let overridden_name = &overridden["paths"]["direct"]["registrationClientName"]; + assert_eq!(overridden_name["value"], "Acme Registered Client"); + assert_eq!(overridden_name["isDefault"], false); } #[tokio::test] - async fn host_requirement_always_instructs_stop_and_report_when_unavailable() { - let value = host_requirement().await; - assert_eq!(value["ifUnavailable"]["action"], "stop-and-report"); + async fn doctor_report_surfaces_credential_source_token_state_and_callback_port() { + let snapshot = DirectPathSnapshot { + credential_source: ClientCredentialSource::CliArg, + token_state: devup_mcp_figma::TokenState::Expired, + callback_port: Some(19876), + callback_port_free: Some(false), + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }; + let report = doctor_report(AuthStatus::Disconnected, snapshot).await; + assert_eq!(report["paths"]["direct"]["credentialSource"], "cli-arg"); + assert_eq!(report["paths"]["direct"]["tokenState"], "expired"); + assert_eq!(report["paths"]["direct"]["callbackPort"]["port"], 19876); + assert_eq!(report["paths"]["direct"]["callbackPort"]["free"], false); + // Even with a client credential configured, the reason must not + // point back at the DCR-blocked/waitlist guidance meant for the + // "no credential at all" case. assert!( - !value["ifUnavailable"]["message"] + !report["paths"]["direct"]["reason"] .as_str() .unwrap() - .is_empty() + .contains("waitlist") ); - assert!(value["steps"].as_array().unwrap().len() >= 4); - assert!(value["localDevMode"]["reachable"].is_boolean()); } + /// `DirectPathSnapshot` structurally cannot carry a client secret (it + /// has no such field — see `oauth.rs`), so `doctor_report` cannot leak + /// one regardless of which credential source is reported. This test + /// pins that invariant at the JSON boundary: the only permitted + /// occurrence of the substring "secret" is the static `clientSetup` + /// reference text that documents *where* a secret goes (field names, + /// not values) — never a real value. #[tokio::test] - async fn doctor_report_reflects_measured_auth_status_without_changing_status_shape() { - let connected = doctor_report(AuthStatus::Connected).await; - assert_eq!(connected["status"], "connected"); - assert_eq!(connected["paths"]["direct"]["available"], true); - - let disconnected = doctor_report(AuthStatus::Disconnected).await; - assert_eq!(disconnected["status"], "disconnected"); - assert_eq!(disconnected["paths"]["direct"]["available"], false); - assert_eq!( - disconnected["paths"]["localDevMode"]["endpoint"], - LOCAL_DEV_MODE_ENDPOINT - ); - assert_eq!( - disconnected["paths"]["hostHandoff"]["expectedTool"], - "use_figma" - ); - assert!(disconnected["clientSetup"]["constraints"]["clientNameAllowlist"].is_string()); - assert!(disconnected["clientSetup"]["opencode"]["example"].is_object()); + async fn doctor_report_only_mentions_secret_as_a_field_name_never_a_value() { + let snapshot = DirectPathSnapshot { + credential_source: ClientCredentialSource::Env, + token_state: devup_mcp_figma::TokenState::Valid, + callback_port: Some(19876), + callback_port_free: Some(true), + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }; + let report = doctor_report(AuthStatus::Connected, snapshot).await; + assert!(report["paths"]["direct"].get("clientSecret").is_none()); + assert!(report["paths"]["direct"].get("secret").is_none()); + let serialized = report.to_string(); + assert!(!serialized.contains("access_token")); + assert!(!serialized.contains("refresh_token")); } } diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs deleted file mode 100644 index 1cc8b953..00000000 --- a/crates/devup-mcp/src/server/handoff.rs +++ /dev/null @@ -1,455 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use devup_mcp_devup_ui::codegen::RootLayout; -use devup_mcp_figma::{ - CollectedParts, CollectionStats, CollectorSession, CollectorStep, DevupError, ErrorCode, - UpstreamResult, -}; -use rand::Rng; -use serde::Serialize; -use serde_json::{Value, json}; -use tokio::sync::Mutex; - -use super::{artifacts::ArtifactRequestKey, delivery::DeliveryMode}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PendingOperation { - Collect, - Artifact { - operation: Box, - artifact_key: ArtifactRequestKey, - }, - ToUi { - component_name: Option, - include_diagnostics: bool, - root_layout: RootLayout, - output_path: Option, - delivery: DeliveryMode, - }, - ToJson { - scope: String, - include_diagnostics: bool, - output_path: Option, - delivery: DeliveryMode, - }, - Export { - outputs: Vec, - component_name: Option, - include_diagnostics: bool, - root_layout: RootLayout, - scope: String, - strict: bool, - output_paths: BTreeMap, - frame_ids: Vec, - all_screens: bool, - asset_captures: Vec, - asset_output_paths: BTreeMap, - delivery: DeliveryMode, - }, - Search { - query: String, - node_types: Vec, - match_kind: String, - limit: usize, - }, - Explore { - limit: usize, - target: devup_mcp_figma::FigmaTarget, - }, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct HandoffCall { - pub call_id: String, - pub server: &'static str, - pub tool: &'static str, - pub arguments: Value, -} - -#[derive(Debug)] -pub enum HandoffStep { - NeedsFigma { - session_id: String, - expires_at_epoch_seconds: u64, - calls: Vec, - collection: CollectionStats, - }, - Complete { - operation: PendingOperation, - parts: Box, - }, -} - -#[derive(Debug, Clone, Copy)] -pub struct HandoffLimits { - pub ttl: Duration, - pub max_sessions: usize, - pub max_result_bytes: usize, - pub max_total_bytes: usize, -} - -impl Default for HandoffLimits { - fn default() -> Self { - Self { - ttl: Duration::from_secs(10 * 60), - max_sessions: 8, - max_result_bytes: 16 * 1024 * 1024, - max_total_bytes: 64 * 1024 * 1024, - } - } -} - -pub trait Clock: Send + Sync { - fn now_epoch_seconds(&self) -> u64; -} - -#[derive(Debug)] -struct SystemClock; - -impl Clock for SystemClock { - fn now_epoch_seconds(&self) -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } -} - -struct Session { - operation: PendingOperation, - collector: CollectorSession, - expires_at: u64, - result_bytes: usize, - pending: BTreeMap, - consumed: BTreeSet, -} - -#[derive(Default)] -struct StoreState { - sessions: BTreeMap, - tombstones: BTreeMap, - total_result_bytes: usize, -} - -#[derive(Debug, Clone, Copy)] -struct SessionTombstone { - expires_at: u64, -} - -const MAX_TOMBSTONES: usize = 64; - -#[derive(Clone)] -pub struct HandoffStore { - state: Arc>, - clock: Arc, - limits: HandoffLimits, -} - -impl Default for HandoffStore { - fn default() -> Self { - Self::with_limits(HandoffLimits::default()) - } -} - -impl HandoffStore { - pub fn with_limits(limits: HandoffLimits) -> Self { - Self::with_clock(Arc::new(SystemClock), limits) - } - - pub fn with_clock(clock: Arc, limits: HandoffLimits) -> Self { - Self { - state: Arc::new(Mutex::new(StoreState::default())), - clock, - limits, - } - } - - pub async fn begin( - &self, - operation: PendingOperation, - collector: CollectorSession, - ) -> Result { - self.begin_with_artifact(operation, collector, None).await - } - - pub async fn begin_with_artifact( - &self, - operation: PendingOperation, - collector: CollectorSession, - artifact_key: Option, - ) -> Result { - let operation = artifact_key.map_or(operation.clone(), |artifact_key| { - PendingOperation::Artifact { - operation: Box::new(operation), - artifact_key, - } - }); - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - prune_expired(&mut state, now, self.limits.ttl.as_secs()); - if state.sessions.len() >= self.limits.max_sessions { - return Err(too_large( - "동시에 유지할 수 있는 Figma handoff session 수를 초과했습니다.", - )); - } - let session_id = unique_id(&state.sessions, &state.tombstones); - state.sessions.insert( - session_id.clone(), - Session { - operation, - collector, - expires_at: now.saturating_add(self.limits.ttl.as_secs()), - result_bytes: 0, - pending: BTreeMap::new(), - consumed: BTreeSet::new(), - }, - ); - Ok(session_id) - } - - pub async fn next(&self, session_id: &str) -> Result { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - let mut session = take_session(&mut state, session_id, now, self.limits.ttl.as_secs())?; - - loop { - match session.collector.advance() { - Ok(CollectorStep::Call(planned)) => { - let call_id = random_id(); - let handoff_call = HandoffCall { - call_id: call_id.clone(), - server: "figma", - tool: planned.call.tool_name(), - arguments: Value::Object(planned.call.arguments()), - }; - session.pending.insert(call_id, (planned.id, handoff_call)); - } - Ok(CollectorStep::AwaitingResults) => { - let calls = session - .pending - .values() - .map(|(_, call)| call.clone()) - .collect(); - let expires_at_epoch_seconds = session.expires_at; - let collection = session.collector.stats().clone(); - put_session(&mut state, session_id.to_owned(), session); - return Ok(HandoffStep::NeedsFigma { - session_id: session_id.to_owned(), - expires_at_epoch_seconds, - calls, - collection, - }); - } - Ok(CollectorStep::Complete(parts)) => { - return Ok(HandoffStep::Complete { - operation: session.operation, - parts, - }); - } - Err(error) => return Err(error), - } - } - } - - pub async fn accept( - &self, - session_id: &str, - call_id: &str, - result: Value, - ) -> Result<(), DevupError> { - let encoded_len = serde_json::to_vec(&result) - .map_err(|_| invalid("Figma handoff result를 JSON으로 읽을 수 없습니다."))? - .len(); - if encoded_len > self.limits.max_result_bytes { - self.remove(session_id).await; - return Err(too_large( - "Figma handoff result의 허용 크기를 초과했습니다.", - )); - } - - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - if state.total_result_bytes.saturating_add(encoded_len) > self.limits.max_total_bytes { - if let Some(session) = state.sessions.remove(session_id) { - state.total_result_bytes = state - .total_result_bytes - .saturating_sub(session.result_bytes); - } - return Err(too_large( - "Figma handoff result의 전체 메모리 한도를 초과했습니다.", - )); - } - let mut session = take_session(&mut state, session_id, now, self.limits.ttl.as_secs())?; - let Some((collector_call_id, _)) = session.pending.get(call_id) else { - let reason = if session.consumed.contains(call_id) { - "consumed" - } else { - "unknown_call" - }; - put_session(&mut state, session_id.to_owned(), session); - return Err(invalid_reason( - "알 수 없거나 이미 처리한 Figma handoff call ID입니다.", - reason, - )); - }; - let collector_call_id = collector_call_id.clone(); - let mut accepted_collector = session.collector.clone(); - if let Err(error) = - accepted_collector.accept(&collector_call_id, UpstreamResult { raw: result }) - { - put_session(&mut state, session_id.to_owned(), session); - return Err(error); - } - session.collector = accepted_collector; - session.pending.remove(call_id); - session.consumed.insert(call_id.to_owned()); - session.result_bytes = session.result_bytes.saturating_add(encoded_len); - session.expires_at = now.saturating_add(self.limits.ttl.as_secs()); - put_session(&mut state, session_id.to_owned(), session); - Ok(()) - } - - pub async fn remove(&self, session_id: &str) { - let mut state = self.state.lock().await; - if let Some(session) = state.sessions.remove(session_id) { - state.total_result_bytes = state - .total_result_bytes - .saturating_sub(session.result_bytes); - } - } -} - -fn take_session( - state: &mut StoreState, - session_id: &str, - now: u64, - tombstone_ttl: u64, -) -> Result { - prune_tombstones(state, now); - let Some(session) = state.sessions.remove(session_id) else { - return if state.tombstones.contains_key(session_id) { - Err(expired()) - } else { - Err(invalid_reason( - "존재하지 않는 Figma handoff session입니다.", - "unknown_session", - )) - }; - }; - state.total_result_bytes = state - .total_result_bytes - .saturating_sub(session.result_bytes); - if session.expires_at <= now { - remember_expired(state, session_id.to_owned(), now, tombstone_ttl); - return Err(expired()); - } - Ok(session) -} - -fn put_session(state: &mut StoreState, session_id: String, session: Session) { - state.total_result_bytes = state - .total_result_bytes - .saturating_add(session.result_bytes); - state.sessions.insert(session_id, session); -} - -fn prune_expired(state: &mut StoreState, now: u64, tombstone_ttl: u64) { - prune_tombstones(state, now); - let expired = state - .sessions - .iter() - .filter_map(|(id, session)| (session.expires_at <= now).then_some(id.clone())) - .collect::>(); - for id in expired { - if let Some(session) = state.sessions.remove(&id) { - state.total_result_bytes = state - .total_result_bytes - .saturating_sub(session.result_bytes); - remember_expired(state, id, now, tombstone_ttl); - } - } -} - -fn remember_expired(state: &mut StoreState, id: String, now: u64, ttl: u64) { - if state.tombstones.len() >= MAX_TOMBSTONES - && let Some(oldest) = state - .tombstones - .iter() - .min_by_key(|(_, tombstone)| tombstone.expires_at) - .map(|(id, _)| id.clone()) - { - state.tombstones.remove(&oldest); - } - state.tombstones.insert( - id, - SessionTombstone { - expires_at: now.saturating_add(ttl), - }, - ); -} - -fn prune_tombstones(state: &mut StoreState, now: u64) { - state - .tombstones - .retain(|_, tombstone| tombstone.expires_at > now); -} - -fn unique_id( - sessions: &BTreeMap, - tombstones: &BTreeMap, -) -> String { - loop { - let id = random_id(); - if !sessions.contains_key(&id) && !tombstones.contains_key(&id) { - return id; - } - } -} - -fn random_id() -> String { - let mut bytes = [0_u8; 32]; - rand::rng().fill_bytes(&mut bytes); - URL_SAFE_NO_PAD.encode(bytes) -} - -fn invalid(message: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - message, - false, - json!({"source": "host"}), - ) -} - -fn invalid_reason(message: &str, reason: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - message, - false, - json!({"source": "host", "reason": reason}), - ) -} - -fn expired() -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffExpired, - "Figma handoff session이 만료되었습니다.", - true, - json!({"source": "host", "reason": "expired"}), - ) -} - -fn too_large(message: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaResponseTooLarge, - message, - false, - json!({"source": "host"}), - ) -} diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index cb498a0d..734cb273 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -1,11 +1,16 @@ pub mod artifacts; +mod call_cache; pub mod delivery; mod diagnostics; -pub mod handoff; +pub mod operation; pub mod output; +mod pacing; +mod project_context; +mod project_root; mod projection; mod quality; pub mod resources; +mod stack_diff; mod tools; mod validation; @@ -26,18 +31,21 @@ use serde_json::{Value, json}; use devup_mcp_devup_ui::theme::ThemeScope; use devup_mcp_figma::{ - AuthStatus, CollectedParts, CollectedPayload, CollectionRequest, CollectionScope, - CollectorSession, CollectorStep, CredentialStore, DevupError, ErrorCode, ExploreCandidate, - ExploreKind, ExploreNode, ExploreReadOptions, FigmaTarget, FigmaUpstream, - KeyringCredentialStore, OAuthManager, RemoteFigmaClient, ResourceScope, SearchReadOptions, - SectionCandidate, SectionIndex, SectionReadOptions, SourcePolicy, SystemBrowser, - fallback_allowed_for_error, + AuthStatus, ClientCredentialSource, ClientCredentials, CollectedParts, CollectedPayload, + CollectionRequest, CollectionScope, CollectorSession, CollectorStep, CredentialStore, + DEFAULT_CLIENT_NAME, DevupError, DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, + ExploreNode, ExploreReadOptions, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, + KeyringCredentialStore, OAuthManager, ReadToolCall, RemoteFigmaClient, ResourceScope, + SearchReadOptions, SecretString, SectionCandidate, SectionIndex, SectionReadOptions, + SourcePolicy, SystemBrowser, TokenState, UpstreamResult, }; use artifacts::{ArtifactKind, ArtifactRequestKey, ArtifactStore}; +use call_cache::CallCache; use delivery::{DeliveryMode, tool_result}; -use handoff::{HandoffStep, HandoffStore, PendingOperation}; +use operation::PendingOperation; use output::OutputPolicy; +use pacing::CallPacer; use projection::complete_operation; use validation::{ parse_asset_requests, parse_collection_scope, parse_root_layout, parse_source_policy, @@ -45,8 +53,8 @@ use validation::{ }; pub use tools::{ - AuthInput, ContinueInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, - FigmaSearchInput, FigmaToJsonInput, FigmaToUiInput, + AuthInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, FigmaSearchInput, + FigmaToJsonInput, FigmaToUiInput, ProjectContextInput, StackDiffInput, UiValidateInput, }; const FIGMA_ENDPOINT: &str = "https://mcp.figma.com/mcp"; @@ -56,6 +64,64 @@ pub trait DevupAuth: Send + Sync { async fn status(&self) -> Result; async fn login(&self) -> Result; async fn logout(&self) -> Result; + + /// Backs `devup_figma_auth {"action":"doctor"}`'s `paths.direct` + /// block. Default implementation derives a best-effort snapshot from + /// `status()` alone so existing `DevupAuth` test doubles keep + /// compiling without changes; `OAuthManager` overrides this with the + /// real credential-source/token-freshness/callback-port measurement. + async fn direct_path_snapshot(&self) -> Result { + let status = self.status().await?; + Ok(DirectPathSnapshot { + credential_source: ClientCredentialSource::default(), + token_state: if status == AuthStatus::Connected { + TokenState::Valid + } else { + TokenState::Absent + }, + callback_port: None, + callback_port_free: None, + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }) + } + + /// Backs `devup_figma_auth {"action":"configure"}`. Default + /// implementation rejects: only auth backends that actually persist a + /// client credential (namely `OAuthManager`) support this. + async fn configure_client_credentials( + &self, + _client_id: String, + _client_secret: Option, + ) -> Result<(), DevupError> { + Err(DevupError::new( + ErrorCode::DevupAuthRequired, + "This auth backend does not support configuring client credentials.", + false, + )) + } +} + +/// The system browser, with the URL said aloud first. +/// +/// Two logins in a row timed out waiting for a callback that never came, and +/// nothing said whether the browser had opened at all. `webbrowser::open` +/// reports failure to launch but not a launch into a window nobody is +/// looking at, and the URL it was handed was kept nowhere. So it is logged +/// before the browser is asked: if the tab does not appear, the URL is in +/// stderr to be opened by hand. Nothing in it is a secret — the verifier +/// stays in the process, and the challenge, state and client_id are what +/// the address bar shows anyway. +struct SpokenBrowser; + +impl devup_mcp_figma::BrowserOpener for SpokenBrowser { + fn open(&self, authorization_url: &str) -> Result<(), DevupError> { + // stderr is where this binary's traces go; stdout is MCP frames only. + eprintln!( + "devup-mcp: opening the browser for Figma authorization. \ + If no tab appears, open this URL by hand:\n{authorization_url}" + ); + SystemBrowser.open(authorization_url) + } } #[async_trait] @@ -65,7 +131,7 @@ impl DevupAuth for OAuthManager { } async fn login(&self) -> Result { - OAuthManager::login(self, &SystemBrowser).await?; + OAuthManager::login(self, &SpokenBrowser).await?; Ok(AuthStatus::Connected) } @@ -73,21 +139,92 @@ impl DevupAuth for OAuthManager { OAuthManager::logout(self).await?; Ok(AuthStatus::Disconnected) } + + async fn direct_path_snapshot(&self) -> Result { + OAuthManager::direct_path_snapshot(self).await + } + + async fn configure_client_credentials( + &self, + client_id: String, + client_secret: Option, + ) -> Result<(), DevupError> { + OAuthManager::configure_client_credentials(self, client_id, client_secret).await + } } #[derive(Clone)] pub struct Services { auth: Arc, upstream: Arc, + /// Shared, so that concurrent collections meter against one ceiling rather + /// than one each and together exceed it. + pacer: Arc, + /// Off unless a directory was named, in which case each read that succeeds + /// is kept so a later attempt need not pay for it again. + call_cache: Arc, } impl Services { pub fn new(auth: Arc, upstream: Arc) -> Self { - Self { auth, upstream } + Self { + auth, + upstream, + pacer: Arc::new(CallPacer::from_env()), + call_cache: Arc::new(CallCache::from_env()), + } + } + + /// Names the directory to bank calls in, rather than reading it from the + /// environment. A caller running two collections against one bank needs to + /// say which bank without setting a variable the whole process shares. + pub fn with_call_cache_dir( + auth: Arc, + upstream: Arc, + directory: Option, + ) -> Self { + Self { + auth, + upstream, + pacer: Arc::new(CallPacer::from_env()), + call_cache: Arc::new(CallCache::new( + directory, + std::time::Duration::from_secs(48 * 60 * 60), + )), + } } - fn production() -> Self { - let oauth = OAuthManager::with_endpoint(FIGMA_ENDPOINT, KeyringCredentialStore); + fn production(figma_direct: crate::FigmaDirectConfig) -> Self { + let mut oauth = OAuthManager::with_endpoint(FIGMA_ENDPOINT, KeyringCredentialStore) + .with_client_credential_store(Arc::new(KeyringClientCredentialStore)); + if figma_direct.callback_port.is_some() { + oauth = oauth.with_callback_port(figma_direct.callback_port); + } + if let Some(client_name) = figma_direct.client_name { + oauth = oauth.with_client_name(client_name); + } + // Three minutes is enough when the browser opens itself and the person + // is already looking at it. It is not enough when the URL has to be + // carried to them by hand — read from a log, pasted into a chat, opened + // a few minutes later — which is how a login came back "timed out" + // after being approved: the approval arrived at a listener that had + // already closed. Whoever is carrying the URL sets this. + if let Some(seconds) = std::env::var("DEVUP_FIGMA_CALLBACK_TIMEOUT_SECONDS") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + { + oauth = oauth.with_callback_timeout(std::time::Duration::from_secs(seconds)); + } + if let Some(client_id) = figma_direct.client_id { + oauth = oauth.with_static_client_credentials( + ClientCredentials { + client_id, + client_secret: figma_direct.client_secret.map(SecretString::new), + }, + figma_direct.credential_source, + ); + } let upstream = RemoteFigmaClient::new(oauth.clone()); Self::new(Arc::new(oauth), Arc::new(upstream)) } @@ -97,7 +234,6 @@ impl Services { pub struct DevupServer { tool_router: ToolRouter, services: Services, - handoffs: HandoffStore, artifacts: ArtifactStore, output_policy: OutputPolicy, } @@ -118,22 +254,28 @@ impl DevupServer { Ok(Self { tool_router: Self::tool_router(), services, - handoffs: HandoffStore::default(), artifacts: ArtifactStore::default(), output_policy: OutputPolicy::from_roots(roots)?, }) } + pub fn production_with_config( + roots: Vec, + figma_direct: crate::FigmaDirectConfig, + ) -> Result { + Self::with_output_roots(Services::production(figma_direct), roots) + } + pub fn production_with_output_roots( roots: Vec, ) -> Result { - Self::with_output_roots(Services::production(), roots) + Self::production_with_config(roots, crate::FigmaDirectConfig::default()) } } impl Default for DevupServer { fn default() -> Self { - Self::new(Services::production()) + Self::new(Services::production(crate::FigmaDirectConfig::default())) } } @@ -170,18 +312,11 @@ impl DevupServer { ) .await; } - if policy == SourcePolicy::Host { - return self.begin_handoff(operation, request, artifact_key).await; - } - let auth_status = self.services.auth.status().await?; if auth_status == AuthStatus::Disconnected { - if policy == SourcePolicy::Auto { - return self.begin_handoff(operation, request, artifact_key).await; - } return Err(DevupError::with_details( ErrorCode::DevupAuthRequired, - "Figma direct 연결을 사용하려면 devup_figma_auth login이 필요합니다.", + "Using the Figma direct connection requires devup_figma_auth login.", false, json!({"source": "direct"}), )); @@ -205,21 +340,119 @@ impl DevupServer { ) .await } - Err(error) if fallback_allowed_for_error(policy, &error) => { - self.begin_handoff(operation, request, artifact_key).await - } Err(error) => Err(error), } } + /// A collection is a burst: a Section of any size spends five to seventeen + /// calls back to back, and Figma meters by the minute. So a large enough + /// target outruns its own allowance partway through, and the refusal used + /// to end the whole collection — discarding every call already spent and + /// returning nothing, which is the worst of both: the allowance is gone and + /// there is no result to show for it. Waiting is what the refusal asks for. + /// It is marked retryable and often carries the exact number of seconds. + /// + /// Bounded, because an allowance that is genuinely exhausted must still be + /// reported rather than waited on forever: three attempts, each waiting + /// what upstream asked for, or a widening guess when it did not say. + async fn call_waiting_out_a_spent_allowance( + &self, + call: ReadToolCall, + ) -> Result { + const ATTEMPTS: u32 = 3; + const LONGEST_WAIT: u64 = 90; + + // A call already banked costs no allowance, so it is answered ahead of + // the pacer rather than queued behind it. This is what lets a capture + // too large for one day's allowance finish across several: the reads + // an earlier attempt paid for are replayed, and only what is still + // missing is spent on. + if let Some(raw) = self.services.call_cache.get(&call) { + return Ok(UpstreamResult { raw }); + } + + let mut attempt = 1; + loop { + // Before the call, not after the refusal: a collection that paces + // itself under the ceiling rarely has to be waited out at all. + self.services.pacer.acquire().await; + let error = match self.services.upstream.call_read_tool(call.clone()).await { + Ok(result) => { + // Only an answer is banked. A refusal arrives as a + // *successful* MCP call — `isError` in the body, or the + // sentence alone — and banking one meant every later + // attempt replayed it at once, from disk, without reaching + // Figma: twenty-eight refusals overnight and a fresh token + // refused, all one cached refusal. The classification + // that decides retries decides this too, so the two cannot + // disagree. + if operation::upstream_error(&result.raw).is_none() { + self.services.call_cache.put(&call, &result.raw); + } + return Ok(result); + } + Err(error) => error, + }; + if error.code != ErrorCode::DevupFigmaRateLimited { + return Err(error); + } + // The ceiling was reached at a rate the pacer thought was safe, so + // its picture is what is wrong. Correct it before anything else — + // including before giving up, because whatever runs next inherits + // the same window and would otherwise walk into the same refusal. + self.services.pacer.penalise(); + if attempt >= ATTEMPTS { + return Err(error); + } + let asked_for = error + .details + .get("retryAfterSeconds") + .and_then(serde_json::Value::as_u64); + let wait = asked_for + .unwrap_or(u64::from(attempt) * 20) + .min(LONGEST_WAIT); + tokio::time::sleep(std::time::Duration::from_secs(wait)).await; + attempt += 1; + } + } + async fn run_direct(&self, request: CollectionRequest) -> Result { let mut collector = CollectorSession::new(request); loop { match collector.advance()? { CollectorStep::Call(planned) => { let call_id = planned.id.clone(); - match self.services.upstream.call_read_tool(planned.call).await { - Ok(result) => collector.accept(&call_id, result)?, + match self.call_waiting_out_a_spent_allowance(planned.call).await { + // A Section target is not a failed call — the script + // throws, and MCP delivers that as a successful result + // carrying `isError`. Handing it to `accept` made the + // collector look for snapshot data that was never + // there and report "snapshot data not found", hiding + // the one thing the caller needed to know. Rejecting + // it lets the collector switch to the section index + // and answer with the screens inside, which is what + // the collector has always done. + Ok(result) if operation::is_section_error_result(&result.raw) => { + let error = DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "DEVUP_TARGET_IS_SECTION", + false, + ); + if !collector.reject(&call_id, &error)? { + return Err(error); + } + } + // Every other upstream refusal arrives the same way. + // Report what upstream said instead of letting the + // collector misread the response as missing data. + Ok(result) => match operation::upstream_error(&result.raw) { + Some(error) => { + if !collector.reject(&call_id, &error)? { + return Err(error); + } + } + None => collector.accept(&call_id, result)?, + }, Err(error) if collector.reject(&call_id, &error)? => continue, Err(error) => return Err(error), } @@ -229,74 +462,6 @@ impl DevupServer { } } } - - async fn begin_handoff( - &self, - operation: PendingOperation, - request: CollectionRequest, - artifact_key: ArtifactRequestKey, - ) -> Result { - let session_id = self - .handoffs - .begin_with_artifact( - operation, - CollectorSession::new(request), - Some(artifact_key), - ) - .await?; - let step = self.handoffs.next(&session_id).await?; - self.handoff_step_to_value(step, "host").await - } - - async fn handoff_step_to_value( - &self, - step: HandoffStep, - source: &str, - ) -> Result { - match step { - HandoffStep::NeedsFigma { - session_id, - expires_at_epoch_seconds, - calls, - collection, - } => { - let host_requirement = diagnostics::host_requirement().await; - Ok(json!({ - "status": "needs_figma", - "sessionId": session_id, - "expiresAt": format_epoch_rfc3339(expires_at_epoch_seconds), - "calls": calls, - "collection": collection, - "resumeTool": "devup_figma_continue", - "hostRequirement": host_requirement - })) - } - HandoffStep::Complete { operation, parts } => { - let PendingOperation::Artifact { - operation, - artifact_key, - } = operation - else { - return Err(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "Figma handoff artifact key가 없습니다.", - false, - )); - }; - let payload = CollectedPayload::try_from(*parts)?; - let artifact = self.artifacts.insert(artifact_key, payload).await?; - complete_operation( - *operation, - &artifact.payload, - source, - &artifact, - &self.output_policy, - &self.artifacts, - ) - .await - } - } - } } /// Every `devup_figma_*` tool response is a JSON object whose exact shape @@ -320,7 +485,7 @@ fn permissive_object_output_schema() -> Arc { #[tool_router] impl DevupServer { #[tool( - description = "Check, start, or clear Figma Remote MCP OAuth (action: status | login | logout | doctor)", + description = "Check, start, or clear Figma Remote MCP OAuth, or inject a pre-registered client credential to skip Dynamic Client Registration (action: status | login | logout | configure | doctor)", output_schema = permissive_object_output_schema() )] async fn devup_figma_auth( @@ -329,7 +494,30 @@ impl DevupServer { ) -> Result { if input.action == "doctor" { let status = self.services.auth.status().await.map_err(to_mcp_error)?; - return Ok(tool_result(diagnostics::doctor_report(status).await)); + let direct = self + .services + .auth + .direct_path_snapshot() + .await + .map_err(to_mcp_error)?; + return Ok(tool_result( + diagnostics::doctor_report(status, direct).await, + )); + } + if input.action == "configure" { + let client_id = input.client_id.ok_or_else(|| { + to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "configure requires clientId.", + false, + )) + })?; + self.services + .auth + .configure_client_credentials(client_id, input.client_secret) + .await + .map_err(to_mcp_error)?; + return Ok(tool_result(json!({ "status": "configured" }))); } let status = match input.action.as_str() { "status" => self.services.auth.status().await, @@ -338,7 +526,7 @@ impl DevupServer { _ => { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupAuthRequired, - "action은 status, login, logout 또는 doctor여야 합니다.", + "action must be status, login, logout, configure, or doctor.", false, ))); } @@ -348,7 +536,7 @@ impl DevupServer { } #[tool( - description = "Convert a Figma design link to deterministic DevupUI TypeScript", + description = "Convert a Figma design link to deterministic DevupUI TypeScript only; use devup_figma_export when tokens or a source map are also needed, and never hand-interpret a handoff node tree", output_schema = permissive_object_output_schema() )] async fn devup_figma_to_ui( @@ -359,7 +547,7 @@ impl DevupServer { target.node_id.as_ref().ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "UI 변환 링크에는 node-id가 필요합니다.", + "A UI conversion link requires a node-id.", false, )) })?; @@ -431,7 +619,7 @@ impl DevupServer { } #[tool( - description = "Search Figma pages, sections, frames, and components by name", + description = "Search Figma pages, sections, frames, and components by name to locate the target before devup_figma_export", output_schema = permissive_object_output_schema() )] async fn devup_figma_search( @@ -465,7 +653,7 @@ impl DevupServer { } #[tool( - description = "Explore screen candidates spatially related to a linked Figma node", + description = "Explore screen candidates spatially related to a linked Figma node to locate the right screen before devup_figma_export", output_schema = permissive_object_output_schema() )] async fn devup_figma_explore( @@ -476,14 +664,14 @@ impl DevupServer { target.node_id.as_ref().ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma 주변 화면 탐색에는 node-id가 필요합니다.", + "Exploring neighboring Figma screens requires a node-id.", false, )) })?; if !(1..=100).contains(&input.limit) { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "탐색 limit은 1 이상 100 이하여야 합니다.", + "The explore limit must be between 1 and 100 inclusive.", false, ))); } @@ -511,31 +699,7 @@ impl DevupServer { } #[tool( - description = "Continue a read-only Figma host handoff with an official MCP result", - output_schema = permissive_object_output_schema() - )] - async fn devup_figma_continue( - &self, - Parameters(input): Parameters, - ) -> Result { - self.handoffs - .accept(&input.session_id, &input.call_id, input.result) - .await - .map_err(to_mcp_error)?; - let step = self - .handoffs - .next(&input.session_id) - .await - .map_err(to_mcp_error)?; - Ok(tool_result( - self.handoff_step_to_value(step, "host") - .await - .map_err(to_mcp_error)?, - )) - } - - #[tool( - description = "Acquire a Figma design once and project multiple DevupUI artifacts", + description = "Acquire a Figma design once and project tsx/componentTsx/devupJson/sourceMap/rawSnapshot together in one collection; the primary Figma-to-code entry point, preferred over devup_figma_to_ui for implementation. Request tsx and componentTsx together to get the same screen twice: tsx expands every instance into primitives, componentTsx keeps them as references with their imports, so the difference between them is each component's body. responsiveTsx is the whole screen at every width it is drawn at, merged into one module whose differing values are devup-ui responsive arrays; it is produced whenever the capture carries more than one width, and names anything the widths asked for that one tree cannot say", output_schema = permissive_object_output_schema() )] async fn devup_figma_export( @@ -548,7 +712,7 @@ impl DevupServer { { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "assetRequests를 사용하려면 outputs에 assetManifest가 필요합니다.", + "Using assetRequests requires assetManifest in outputs.", false, ))); } @@ -556,7 +720,7 @@ impl DevupServer { if reference_png_requested && (!input.frame_ids.is_empty() || input.all_screens) { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "referencePng는 단일 Figma 링크 대상에서만 수집할 수 있습니다.", + "referencePng can only be collected for a single Figma link target.", false, ))); } @@ -571,14 +735,14 @@ impl DevupServer { if input.url.is_some() || input.refresh { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "artifactId는 url 또는 refresh와 함께 사용할 수 없습니다.", + "artifactId cannot be used together with url or refresh.", false, ))); } let artifact = self.artifacts.get(artifact_id).await.ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaHandoffExpired, - "Figma artifact가 없거나 만료되었습니다.", + "The Figma artifact is missing or expired.", true, )) })?; @@ -590,7 +754,7 @@ impl DevupServer { let index = section_index_from_payload(&artifact.payload).ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "Section index artifact payload가 올바르지 않습니다.", + "The Section index artifact payload is invalid.", false, )) })?; @@ -600,7 +764,7 @@ impl DevupServer { if collection_scope != CollectionScope::Node { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "Section Frame 수집 scope는 node여야 합니다.", + "The Section Frame collection scope must be node.", false, ))); } @@ -621,6 +785,7 @@ impl DevupServer { component_name: input.component_name, include_diagnostics: input.include_diagnostics, root_layout, + asset_names_per_node: input.asset_names_per_node, scope: input.scope, strict: input.strict, output_paths: input.output_paths, @@ -651,6 +816,7 @@ impl DevupServer { component_name: input.component_name, include_diagnostics: input.include_diagnostics, root_layout, + asset_names_per_node: input.asset_names_per_node, scope: input.scope, strict: input.strict, output_paths: input.output_paths, @@ -674,7 +840,7 @@ impl DevupServer { let url = input.url.as_deref().ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "url 또는 artifactId 중 하나가 필요합니다.", + "Either url or artifactId is required.", false, )) })?; @@ -682,7 +848,7 @@ impl DevupServer { if input.outputs.iter().any(|output| output == "tsx") && target.node_id.is_none() { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "TSX export 링크에는 node-id가 필요합니다.", + "A TSX export link requires a node-id.", false, ))); } @@ -714,6 +880,7 @@ impl DevupServer { component_name: input.component_name, include_diagnostics: input.include_diagnostics, root_layout, + asset_names_per_node: input.asset_names_per_node, scope: input.scope, strict: input.strict, output_paths: input.output_paths, @@ -731,6 +898,63 @@ impl DevupServer { .map_err(to_mcp_error)?; Ok(tool_result(result)) } + + #[tool( + description = "Read a project's real devup.json theme tokens, openapi.json endpoints/schemas, or Vespertide models/*.json tables/columns (scope: theme | api | db | all) — read-only, no session cache, never guesses", + output_schema = permissive_object_output_schema() + )] + async fn devup_project_context( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = project_context::run( + &input.scope, + input.project_root.as_deref(), + input.filter.as_deref(), + ) + .await + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } + + #[tool( + description = "Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths with a matching token, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls", + output_schema = permissive_object_output_schema() + )] + async fn devup_ui_validate( + &self, + Parameters(input): Parameters, + ) -> Result { + let theme_lookup = project_context::theme_for_validation(input.project_root.as_deref()) + .map_err(to_mcp_error)?; + let report = devup_mcp_devup_ui::ui_validate::validate_devup_ui_tsx( + &input.tsx, + theme_lookup.theme.as_ref(), + input.strict, + ); + Ok(tool_result(json!({ + "ok": report.ok, + "violations": report.violations, + "checkedTokens": report.checked_tokens, + "availableTokenCount": report.available_token_count, + "themeAvailable": theme_lookup.theme.is_some(), + "themeGuardrail": theme_lookup.guardrail, + }))) + } + + #[tool( + description = "Detect drift across the devup stack (vespertide model -> sea-orm entity -> vespera route -> openapi.json -> devup-api client); layers: db-entity | entity-route | route-openapi | openapi-client, omit for all. Text/JSON-based heuristics, not a compiler — every finding carries an explicit confidence", + output_schema = permissive_object_output_schema() + )] + async fn devup_stack_diff( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = stack_diff::run(input.project_root.as_deref(), &input.layers) + .await + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } } fn section_index_from_payload(payload: &CollectedPayload) -> Option { @@ -790,7 +1014,7 @@ fn parse_scope(scope: &str) -> Result { "file" => Ok(ThemeScope::File), _ => Err(DevupError::new( ErrorCode::DevupThemeConflict, - "scope는 node, page 또는 file이어야 합니다.", + "scope must be node, page, or file.", false, )), } @@ -814,7 +1038,19 @@ impl ServerHandler for DevupServer { .build(), ) .with_server_info(Implementation::new("devup-mcp", env!("CARGO_PKG_VERSION"))) - .with_instructions("Read Figma designs and generate DevupUI artifacts") + .with_instructions( + "1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n\ + 2. When the goal is implementation, call devup_figma_export first and take tsx, rawSnapshot, and sourceMap together.\n\ + 3. get_design_context, screenshots, and visual reasoning are verification aids only. Do not overwrite devup-mcp output.\n\ + 4. Do not hand-interpret a node tree to write devup-ui code. Do not infer layout from coordinates.\n\ + 5. If a devup-mcp call fails, record it explicitly. Do not silently route around it.\n\ + 6. Do not guess UI values such as color, spacing, radius, or typography. If you could not obtain them, stop and report.\n\ + 7. Do not implement a Section link as one whole subtree. Check the selection_required candidates and continue with per-screen export via frameIds or allScreens.\n\ + 8. The generated component name comes from the Figma layer name and is a starting point, not a contract. Rename it to fit the codebase, and rename a name that is meaningless or not a valid identifier.\n\ + 10. An asset path in the output, such as a maskImage or Image src, is a placeholder built from the layer name. Rename the file to fit the project. If the asset varies per usage, lift it into a prop instead of hardcoding it.\n\ + 11. A fixed asset such as an icon must actually be exported, never referenced by a path that does not exist yet. Read assetManifest for the asset IDs, then call devup_figma_export again with assetRequests, giving each entry an outputPath under an allowed write root, and make the path in the code match the path you wrote.\n\ + 12. Prefer delivery: \"resource\" for assets and large outputs. devup-mcp then returns devup://artifact/... resource links to read on demand instead of inlining bytes in every response.", + ) } async fn list_resources( diff --git a/crates/devup-mcp/src/server/operation.rs b/crates/devup-mcp/src/server/operation.rs new file mode 100644 index 00000000..fcfc84f1 --- /dev/null +++ b/crates/devup-mcp/src/server/operation.rs @@ -0,0 +1,186 @@ +//! What a caller asked for, and how to read a refusal that arrived dressed as +//! success. +//! +//! [`PendingOperation`] carries the request's own shape — which outputs, which +//! paths, which delivery — from the tool boundary through collection to +//! projection, so a completed collection can be answered in the terms it was +//! asked in. +//! +//! The rest reads upstream results. MCP reports a thrown script error as a +//! *successful* call whose result carries `isError`, so a refusal cannot be +//! found by matching on `Err`; it has to be read out of the body. +use std::collections::BTreeMap; + +use devup_mcp_devup_ui::codegen::RootLayout; +use devup_mcp_figma::{DevupError, ErrorCode}; +use serde_json::{Value, json}; + +use super::{artifacts::ArtifactRequestKey, delivery::DeliveryMode}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingOperation { + Collect, + Artifact { + operation: Box, + artifact_key: ArtifactRequestKey, + }, + ToUi { + component_name: Option, + include_diagnostics: bool, + root_layout: RootLayout, + output_path: Option, + delivery: DeliveryMode, + }, + ToJson { + scope: String, + include_diagnostics: bool, + output_path: Option, + delivery: DeliveryMode, + }, + Export { + outputs: Vec, + component_name: Option, + include_diagnostics: bool, + root_layout: RootLayout, + asset_names_per_node: bool, + scope: String, + strict: bool, + output_paths: BTreeMap, + frame_ids: Vec, + all_screens: bool, + asset_captures: Vec, + asset_output_paths: BTreeMap, + delivery: DeliveryMode, + }, + Search { + query: String, + node_types: Vec, + match_kind: String, + limit: usize, + }, + Explore { + limit: usize, + target: devup_mcp_figma::FigmaTarget, + }, +} + +/// Whether an upstream result is the fast snapshot script reporting that its +/// target is a Section. +/// +/// MCP reports a thrown script error as a *successful* tool call whose result +/// carries `isError`, so this cannot be spotted by matching on `Err`. A Section +/// has no single screen to convert, and the collector answers it by +/// switching to the section index and offering selectable screens instead. +pub(crate) fn is_section_error_result(value: &Value) -> bool { + value.get("isError").and_then(Value::as_bool) == Some(true) + && value.to_string().contains("DEVUP_TARGET_IS_SECTION") +} + +/// The message carried by an upstream result that reports a failure. +/// +/// A Section target was only the first error delivered this way. Anything +/// upstream refuses — a tool-call rate limit above all — arrives as a +/// *successful* MCP call carrying `isError`, and handing that to the +/// collector made it hunt for data the response never contained. It then +/// blamed the parser: "metadata not found in the Figma MCP response", or +/// the same for snapshot data, variable batches and asset descriptors, +/// depending only on which step happened to receive it. The real reason +/// was in the response the whole time, so return it and let the caller +/// read it. +/// The wait Figma asked for, in seconds, wherever it appears. +/// +/// Figma's REST API answers a 429 with `Retry-After`. The MCP relay does +/// not forward response headers today, so this usually finds nothing — but +/// reading it costs nothing and is the only authoritative answer to "when +/// can I retry", which otherwise has to be guessed. +fn retry_after_seconds(value: &Value) -> Option { + match value { + Value::Object(object) => object + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("retry-after") || *key == "retryAfter") + .and_then(|(_, found)| { + found + .as_u64() + .or_else(|| found.as_str().and_then(|text| text.parse().ok())) + }) + .or_else(|| object.values().find_map(retry_after_seconds)), + Value::Array(values) => values.iter().find_map(retry_after_seconds), + _ => None, + } +} + +pub(crate) fn upstream_error(value: &Value) -> Option { + fn first_text(value: &Value) -> Option { + match value { + Value::Object(object) => object + .get("text") + .and_then(Value::as_str) + .filter(|text| text.len() > 16) + .map(str::to_owned) + .or_else(|| object.values().find_map(first_text)), + Value::Array(values) => values.iter().find_map(first_text), + _ => None, + } + } + let flagged = value.get("isError").and_then(Value::as_bool) == Some(true); + let message = first_text(value).unwrap_or_else(|| "Figma reported an error.".to_owned()); + let lowered = message.to_lowercase(); + + // A quota refusal is the one upstream failure that clears on its own, + // so it must not be reported as a permanent one. + // + // It also need not be flagged. One arrived carrying no `isError` at all + // and so was handed to the collector, which looked for the data it did not + // contain and reported "variable/style batch not found in the Figma MCP + // response" — the parser that happened to be next, named as the cause, + // after twenty-four minutes of collecting. So the refusal is recognised by + // what it says as well as by how it is flagged. + // + // Only when it is short, though. A healthy response carries its payload in + // the same field, and a design is free to contain a layer named "rate + // limit"; reading that as a refusal would fail a collection that worked. A + // refusal is a sentence, a payload is a document, and the two are never + // close in length. + const LONGEST_REFUSAL: usize = 2000; + let refuses = (lowered.contains("tool call limit") || lowered.contains("rate limit")) + && (flagged || message.len() <= LONGEST_REFUSAL); + if !flagged && !refuses { + return None; + } + if refuses { + let mut details = json!({ + // Figma meters reads with a leaky bucket, so there is no reset + // hour to wait for: capacity drains back continuously. Saying + // an allowance "resets tomorrow" would invite waiting for a + // rollover that never happens, and it explains why small + // requests slip through while a large one still fails. + "recovery": "Figma meters reads with a leaky bucket, so capacity returns gradually rather than resetting at a fixed time. Retry after a short wait; a small request may succeed while a large one is still refused.", + "costHint": "A refreshed export spends about 15 Figma tool calls, so prefer a cached artifact over refresh.", + }); + // The REST API states the exact wait in `Retry-After`, and names + // the ceiling in `X-Figma-Rate-Limit-Type`. The MCP relay does not + // forward either today, so read them when present rather than + // guessing, and say plainly when they are absent. + match retry_after_seconds(value) { + Some(seconds) => { + details["retryAfterSeconds"] = json!(seconds); + } + None => { + details["whichLimit"] = json!( + "Not stated. Figma applies a per-minute ceiling alongside a daily or monthly allowance, and the MCP response does not say which was reached." + ); + } + } + return Some(DevupError::with_details( + ErrorCode::DevupFigmaRateLimited, + message, + true, + details, + )); + } + Some(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + message, + false, + )) +} diff --git a/crates/devup-mcp/src/server/output.rs b/crates/devup-mcp/src/server/output.rs index 537c128f..0e4af6b3 100644 --- a/crates/devup-mcp/src/server/output.rs +++ b/crates/devup-mcp/src/server/output.rs @@ -22,7 +22,23 @@ pub struct OutputPolicy { struct OutputRoot { dir: Dir, + /// The canonical location. Every path devup-mcp reports back is built from + /// this, so a caller always learns where a file actually landed. display_path: PathBuf, + /// The spelling this root was configured with, which may reach + /// `display_path` through a symlink. + /// + /// On macOS that is the normal case rather than an edge case: `/tmp` and + /// `/var` are symlinks into `/private`, and `std::env::temp_dir()` returns + /// a path under `/var/folders`. A client then passes an `outputPath` under + /// the same unresolved prefix it was given, which no longer shares a + /// prefix with the canonicalised root. Keeping both spellings lets + /// [`OutputPolicy::resolve`] accept either without loosening a single + /// check: whatever remains after the prefix is stripped still goes through + /// `normalize_relative_file`, which rejects `..`, absolute components and + /// unsafe names, and symlinked ancestors inside the root are still + /// refused by `reject_existing_symlink_ancestors`. + requested_path: PathBuf, } #[derive(Clone)] @@ -82,19 +98,27 @@ impl CommitHook for NoopCommitHook {} impl OutputPolicy { pub fn from_roots(roots: Vec) -> Result { if roots.is_empty() { - return Err(invalid_path("허용할 output root가 하나 이상 필요합니다.")); + return Err(invalid_path( + "At least one allowed output root is required.", + )); } let mut opened = Vec::with_capacity(roots.len()); for root in roots { let display_path = dunce::canonicalize(&root).map_err(|error| { - invalid_path(format!("output root를 확인할 수 없습니다: {error}")) + invalid_path(format!("Cannot resolve the output root: {error}")) })?; if !display_path.is_dir() { - return Err(invalid_path("output root는 존재하는 폴더여야 합니다.")); + return Err(invalid_path( + "The output root must be an existing directory.", + )); } let dir = Dir::open_ambient_dir(&display_path, ambient_authority()) - .map_err(|error| invalid_path(format!("output root를 열 수 없습니다: {error}")))?; - opened.push(Arc::new(OutputRoot { dir, display_path })); + .map_err(|error| invalid_path(format!("Cannot open the output root: {error}")))?; + opened.push(Arc::new(OutputRoot { + dir, + display_path, + requested_path: root, + })); } Ok(Self { roots: Arc::new(opened), @@ -104,18 +128,22 @@ impl OutputPolicy { pub fn resolve(&self, requested: &str) -> Result { let path = Path::new(requested); if requested.trim().is_empty() { - return Err(invalid_path("outputPath는 파일 경로여야 합니다.")); + return Err(invalid_path("outputPath must be a file path.")); } let (root, relative_path) = if path.is_absolute() { self.roots .iter() .find_map(|root| { + // Either spelling of the root is accepted: the resolved + // one, and the one it was configured with. See + // `OutputRoot::requested_path` for why the two differ. path.strip_prefix(&root.display_path) + .or_else(|_| path.strip_prefix(&root.requested_path)) .ok() .map(|relative| (root.clone(), relative.to_path_buf())) }) - .ok_or_else(|| invalid_path("outputPath가 허용된 root 밖에 있습니다."))? + .ok_or_else(|| invalid_path("outputPath is outside the allowed root."))? } else { (self.roots[0].clone(), path.to_path_buf()) }; @@ -146,7 +174,7 @@ impl OutputTransaction { ) -> Result<(), DevupError> { if !self.targets.insert(target.display_path.clone()) { return Err(invalid_path( - "둘 이상의 output이 같은 파일 경로를 사용할 수 없습니다.", + "Two or more outputs cannot use the same file path.", )); } let parent = target @@ -154,7 +182,9 @@ impl OutputTransaction { .parent() .unwrap_or_else(|| Path::new("")); target.root.dir.create_dir_all(parent).map_err(|error| { - transaction_error(format!("output 상위 폴더를 만들 수 없습니다: {error}")) + transaction_error(format!( + "Cannot create the output parent directory: {error}" + )) })?; reject_existing_symlink_ancestors(&target.root, &target.relative_path)?; let temp_path = unique_sibling(&target.relative_path, "tmp"); @@ -163,13 +193,13 @@ impl OutputTransaction { .dir .open_with(&temp_path, OpenOptions::new().write(true).create_new(true)) .map_err(|error| { - transaction_error(format!("output staging 파일을 만들 수 없습니다: {error}")) + transaction_error(format!("Cannot create the output staging file: {error}")) })?; if let Err(error) = file.write_all(contents).and_then(|()| file.sync_all()) { drop(file); let _ = target.root.dir.remove_file(&temp_path); return Err(transaction_error(format!( - "output staging 파일을 기록할 수 없습니다: {error}" + "Cannot write the output staging file: {error}" ))); } drop(file); @@ -202,14 +232,14 @@ impl OutputTransaction { { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err(transaction_error( - "output target은 일반 파일이거나 아직 존재하지 않아야 합니다.", + "The output target must be a regular file or not exist yet.", )); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(transaction_error(format!( - "output target을 확인할 수 없습니다: {error}" + "Cannot inspect the output target: {error}" ))); } } @@ -220,7 +250,7 @@ impl OutputTransaction { let rollback = self.rollback(hook); return if rollback.failures.is_empty() { Err(transaction_error(format!( - "output transaction commit에 실패했습니다: {error}" + "The output transaction commit failed: {error}" ))) } else { Err(transaction_rollback_error(error, rollback)) @@ -325,7 +355,7 @@ impl OutputTransaction { }) } else { Err(std::io::Error::other( - "replacement target를 제거하지 못해 backup을 복원하지 않았습니다.", + "Did not restore the backup because the replacement target could not be removed.", )) }; if let Err(error) = restore { @@ -389,17 +419,15 @@ fn normalize_relative_file(path: &Path) -> Result { Component::Normal(value) if safe_component(value) => normalized.push(value), Component::CurDir => {} Component::Normal(_) => { - return Err(invalid_path( - "outputPath에 안전하지 않은 파일명이 있습니다.", - )); + return Err(invalid_path("outputPath contains an unsafe file name.")); } Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err(invalid_path("outputPath는 허용 root를 벗어날 수 없습니다.")); + return Err(invalid_path("outputPath cannot escape the allowed root.")); } } } if normalized.as_os_str().is_empty() || normalized.file_name().is_none() { - return Err(invalid_path("outputPath는 파일 경로여야 합니다.")); + return Err(invalid_path("outputPath must be a file path.")); } Ok(normalized) } @@ -420,17 +448,17 @@ fn reject_existing_symlink_ancestors( match root.dir.symlink_metadata(¤t) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(invalid_path( - "outputPath 상위 경로의 symlink 또는 junction은 허용하지 않습니다.", + "A symlink or junction in an outputPath ancestor is not allowed.", )); } Ok(metadata) if !metadata.is_dir() => { - return Err(invalid_path("outputPath 상위 경로가 폴더가 아닙니다.")); + return Err(invalid_path("An outputPath ancestor is not a directory.")); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, Err(error) => { return Err(invalid_path(format!( - "outputPath 상위 경로를 확인할 수 없습니다: {error}" + "Cannot inspect an outputPath ancestor: {error}" ))); } } @@ -464,7 +492,7 @@ fn transaction_rollback_error( .collect::>(); DevupError::with_details( ErrorCode::DevupCodegenFailed, - format!("output transaction commit과 rollback에 실패했습니다: {commit_error}"), + format!("The output transaction commit and rollback both failed: {commit_error}"), false, json!({ "phase": "rollback", @@ -506,7 +534,7 @@ fn verify_fingerprint( Ok(()) } else { Err(std::io::Error::other( - "복원된 output의 길이 또는 hash가 원본 backup과 일치하지 않습니다.", + "The restored output's length or hash does not match the original backup.", )) } } diff --git a/crates/devup-mcp/src/server/pacing.rs b/crates/devup-mcp/src/server/pacing.rs new file mode 100644 index 00000000..4cc1b27a --- /dev/null +++ b/crates/devup-mcp/src/server/pacing.rs @@ -0,0 +1,221 @@ +//! Spending Figma's read allowance at the rate it refills. +//! +//! Figma meters reads by the minute — ten of them on a Full seat of a +//! Professional plan — while a collection is a burst: one Section of three +//! page-height widths asks for some eight hundred nodes, dozens of calls fired +//! back to back. The burst crosses the ceiling within seconds of starting, and +//! because nothing slows it down, every wait afterwards is spent re-crossing +//! it. Idling first does not help, which is the observation that named this: +//! after five minutes of no calls at all the allowance is full, a two-call +//! request goes through, and a twenty-call one is refused exactly as before. +//! The allowance was never the scarce thing. The rate was. +//! +//! So the wait belongs before the call rather than after the refusal. Holding +//! each call until it fits under the ceiling turns a collection that could not +//! finish into one that merely takes longer — and for a snapshot being kept as +//! a test case, longer is the better trade. +//! +//! This does not replace waiting out a refusal. Other clients share the same +//! allowance, so the ceiling can be reached by calls this process never made, +//! and the retry above it still answers for that. + +use std::{collections::VecDeque, sync::Mutex, time::Duration}; + +use tokio::time::{Instant, sleep}; + +/// Under the documented ceiling, not at it. A Full seat on a Professional plan +/// is allowed ten reads a minute, and pacing to exactly ten failed: a capture +/// was refused after twenty-one calls because eight probe calls made moments +/// earlier were still inside the same window. The allowance is the account's, +/// not this collection's — an editor, another agent, or a second export draws +/// on the same ten — and Figma need not count a relayed call the way this side +/// counts it. Leaving two of the ten unspent buys room for both. +/// +/// Organization allows fifteen and Enterprise twenty, so the ceiling is read +/// from the environment; the default is the lowest one, less its headroom. +const DEFAULT_CALLS_PER_MINUTE: usize = 8; + +/// The period Figma meters over. +const WINDOW: Duration = Duration::from_secs(60); + +/// Environment override, for a seat whose ceiling is higher than the default. +const LIMIT_VARIABLE: &str = "DEVUP_FIGMA_CALLS_PER_MINUTE"; + +/// Holds each read until it fits under a rolling per-minute ceiling. +pub struct CallPacer { + limit: usize, + window: Duration, + /// When each call still inside the window was let through, oldest first. + spent: Mutex>, +} + +impl CallPacer { + pub fn from_env() -> Self { + let limit = std::env::var(LIMIT_VARIABLE) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|limit| *limit > 0) + .unwrap_or(DEFAULT_CALLS_PER_MINUTE); + Self::new(limit, WINDOW) + } + + pub fn new(limit: usize, window: Duration) -> Self { + Self { + limit, + window, + spent: Mutex::new(VecDeque::new()), + } + } + + /// Returns once this call fits under the ceiling, counting it as spent. + pub async fn acquire(&self) { + while let Some(wait) = self.reserve() { + sleep(wait).await; + } + } + + /// Records a refusal by filling the window, so the next call waits it out + /// in full. + /// + /// A refusal means the ceiling was reached at a rate this pacer thought + /// was safe, so the pacer's picture is the thing that is wrong and paying + /// it back at the same rate only repeats the mistake. The first capture + /// tried that: it retried into a full window for twenty-four minutes and + /// spent some two hundred and forty calls to arrive at no result at all. + /// Standing down for a whole window costs a minute and leaves the + /// allowance to refill. + pub fn penalise(&self) { + let now = Instant::now(); + let mut spent = self.spent.lock().expect("call pacer"); + spent.clear(); + for _ in 0..self.limit { + spent.push_back(now); + } + } + + /// `None` when the call was recorded and may go ahead; otherwise how long + /// until the oldest call leaves the window and a slot opens. + /// + /// Split out so the lock is released before the caller waits: holding it + /// across the sleep would pace the calls one behind another rather than + /// against the clock. + fn reserve(&self) -> Option { + let now = Instant::now(); + let mut spent = self.spent.lock().expect("call pacer"); + while spent + .front() + .is_some_and(|at| now.duration_since(*at) >= self.window) + { + spent.pop_front(); + } + match spent.front() { + Some(oldest) if spent.len() >= self.limit => { + Some(self.window.saturating_sub(now.duration_since(*oldest))) + } + _ => { + spent.push_back(now); + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Up to the ceiling, nothing waits. + #[tokio::test(start_paused = true)] + async fn a_burst_under_the_ceiling_is_not_slowed() { + let pacer = CallPacer::new(10, WINDOW); + let start = Instant::now(); + for _ in 0..10 { + pacer.acquire().await; + } + assert_eq!(Instant::now().duration_since(start), Duration::ZERO); + } + + /// The eleventh has to wait for the first to leave the window, which is + /// the whole window when the first ten went out together. + #[tokio::test(start_paused = true)] + async fn the_call_past_the_ceiling_waits_for_a_slot() { + let pacer = CallPacer::new(10, WINDOW); + let start = Instant::now(); + for _ in 0..11 { + pacer.acquire().await; + } + assert_eq!(Instant::now().duration_since(start), WINDOW); + } + + /// A collection the size of the one that prompted this — sixty calls at + /// ten a minute — is spread across the windows it needs instead of being + /// refused partway through. + #[tokio::test(start_paused = true)] + async fn a_collection_larger_than_the_allowance_is_spread_rather_than_refused() { + let pacer = CallPacer::new(10, WINDOW); + let start = Instant::now(); + for _ in 0..60 { + pacer.acquire().await; + } + // Ten go at once and each further ten waits out a window, so the + // sixtieth leaves five windows after the first. + assert_eq!(Instant::now().duration_since(start), 5 * WINDOW); + } + + /// A seat with a higher ceiling should not be paced to the lowest one. + #[tokio::test(start_paused = true)] + async fn a_raised_ceiling_lets_more_through_before_waiting() { + let pacer = CallPacer::new(20, WINDOW); + let start = Instant::now(); + for _ in 0..20 { + pacer.acquire().await; + } + assert_eq!(Instant::now().duration_since(start), Duration::ZERO); + } + + /// The allowance belongs to the account rather than to one collection, so + /// the default has to leave room for calls this pacer never saw. Pacing to + /// exactly the documented ten is what let a capture be refused after + /// twenty-one calls, so ten at the default rate must not all go out inside + /// one window. + #[tokio::test(start_paused = true)] + async fn the_default_stays_under_the_ceiling_it_paces_against() { + let pacer = CallPacer::new(DEFAULT_CALLS_PER_MINUTE, WINDOW); + let start = Instant::now(); + for _ in 0..10 { + pacer.acquire().await; + } + assert!( + Instant::now().duration_since(start) > Duration::ZERO, + "pacing at the ceiling leaves nothing for calls made elsewhere" + ); + } + + /// A refusal means the picture was wrong, so the pacer stands down for a + /// whole window rather than paying it back at the rate that was refused. + #[tokio::test(start_paused = true)] + async fn a_refusal_makes_the_next_call_wait_out_a_whole_window() { + let pacer = CallPacer::new(10, WINDOW); + pacer.acquire().await; + pacer.penalise(); + + let start = Instant::now(); + pacer.acquire().await; + assert_eq!(Instant::now().duration_since(start), WINDOW); + } + + /// And it is a pause, not a shutdown: once the window has passed the pacer + /// goes back to full speed. + #[tokio::test(start_paused = true)] + async fn the_pacer_recovers_its_full_rate_after_standing_down() { + let pacer = CallPacer::new(10, WINDOW); + pacer.penalise(); + pacer.acquire().await; + + let start = Instant::now(); + for _ in 0..9 { + pacer.acquire().await; + } + assert_eq!(Instant::now().duration_since(start), Duration::ZERO); + } +} diff --git a/crates/devup-mcp/src/server/project_context.rs b/crates/devup-mcp/src/server/project_context.rs new file mode 100644 index 00000000..945ff1b3 --- /dev/null +++ b/crates/devup-mcp/src/server/project_context.rs @@ -0,0 +1,641 @@ +//! `devup_project_context` — the ground-truth reader. Reads a project's +//! real `devup.json` (theme tokens), `openapi.json` (endpoints/schemas), +//! and Vespertide `models/*.json` (database tables/columns) so an agent +//! never has to guess what identifiers a project actually has. +//! +//! Every scope reads its target file(s) fresh on every call (no session +//! cache — see `project_root.rs`'s module docs) and, when a target file is +//! missing, returns the shared `{"found":false,"guardrail":{...}}` +//! envelope rather than an empty/ambiguous success. + +use std::path::{Path, PathBuf}; + +use devup_mcp_devup_ui::theme::parse_project_theme; +use devup_mcp_figma::{DevupError, ErrorCode}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +use super::project_root::{ + PROJECT_ROOT_NOT_FOUND_MESSAGE, display_path, find_dirs_named, find_files_named, + find_project_root, guardrail_object, json_files_in, not_found_response, +}; + +/// A project's `devup.json` theme, resolved for `devup_ui_validate` — or, +/// when unavailable, the same `{"found":false,"guardrail":{...}}` shape +/// `devup_project_context` would have returned, surfaced under a distinct +/// key so callers can tell "no theme was available, token checks were +/// skipped" apart from "every $token check passed". +pub struct ThemeLookup { + pub theme: Option, + pub guardrail: Option, +} + +/// Resolves the theme `devup_ui_validate` should check `$token` references +/// against: the project root's own `devup.json` if present, otherwise the +/// first `devup.json` found within the project (bounded search), otherwise +/// `None` with an explanatory guardrail. Never caches: reads fresh on every +/// call, per this module's no-session-cache requirement. +pub fn theme_for_validation(project_root: Option<&str>) -> Result { + let start = match project_root { + Some(root) => PathBuf::from(root), + None => std::env::current_dir().map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "Could not determine the current directory.", + false, + json!({ "ioError": error.to_string() }), + ) + })?, + }; + let Some(root) = find_project_root(&start) else { + return Ok(ThemeLookup { + theme: None, + guardrail: Some(guardrail_object( + PROJECT_ROOT_NOT_FOUND_MESSAGE, + vec![display_path(&start)], + )), + }); + }; + let root_level = root.join("devup.json"); + let file = if root_level.is_file() { + Some(root_level) + } else { + find_files_named(&root, "devup.json", 4).into_iter().next() + }; + let Some(file) = file else { + return Ok(ThemeLookup { + theme: None, + guardrail: Some(guardrail_object( + "No devup.json found. $token references cannot be verified, so the unknown-token check is skipped. Do not guess and use tokens that do not exist.", + vec![display_path(&root.join("devup.json"))], + )), + }); + }; + let source = std::fs::read_to_string(&file).map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "Could not read devup.json.", + false, + json!({ "path": display_path(&file), "ioError": error.to_string() }), + ) + })?; + let theme = parse_project_theme(&source)?; + Ok(ThemeLookup { + theme: Some(theme), + guardrail: None, + }) +} + +pub async fn run( + scope: &str, + project_root: Option<&str>, + filter: Option<&str>, +) -> Result { + if !["theme", "api", "db", "all"].contains(&scope) { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "scope must be theme, api, db, or all.", + false, + )); + } + let start = match project_root { + Some(root) => PathBuf::from(root), + None => std::env::current_dir().map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "Could not determine the current directory.", + false, + json!({ "ioError": error.to_string() }), + ) + })?, + }; + let Some(root) = find_project_root(&start) else { + return Ok(not_found_response( + PROJECT_ROOT_NOT_FOUND_MESSAGE, + vec![display_path(&start)], + )); + }; + + match scope { + "theme" => Ok(theme_scope(&root, filter)), + "api" => Ok(api_scope(&root, filter)), + "db" => Ok(db_scope(&root, filter)), + "all" => { + let mut all = Map::new(); + all.insert("found".to_owned(), Value::Bool(true)); + all.insert("projectRoot".to_owned(), json!(display_path(&root))); + all.insert("theme".to_owned(), theme_scope(&root, filter)); + all.insert("api".to_owned(), api_scope(&root, filter)); + all.insert("db".to_owned(), db_scope(&root, filter)); + Ok(Value::Object(all)) + } + _ => unreachable!("scope validated above"), + } +} + +// --------------------------------------------------------------------- +// theme scope +// --------------------------------------------------------------------- + +fn theme_scope(root: &Path, filter: Option<&str>) -> Value { + let mut files = find_files_named(root, "devup.json", 4); + if !root.join("devup.json").is_file() { + // find_files_named already includes root/devup.json if present via + // the breadth-first walk starting at root itself; this branch only + // guards against a root walk that (by construction) never omits + // depth-0 files, kept as a defensive no-op. + } + files.sort(); + files.dedup(); + if files.is_empty() { + return not_found_response( + "No devup.json found. Do not write code by guessing color, typography, length, or shadow token names.", + vec![display_path(&root.join("devup.json"))], + ); + } + let mut projects = Vec::new(); + for file in &files { + let relative = relative_display(root, file); + let source = match std::fs::read_to_string(file) { + Ok(source) => source, + Err(error) => { + projects.push(json!({ + "path": relative, + "readError": error.to_string() + })); + continue; + } + }; + let theme = match parse_project_theme(&source) { + Ok(theme) => theme, + Err(error) => { + projects.push(json!({ + "path": relative, + "parseError": error.message + })); + continue; + } + }; + let modes = theme.modes(); + let matches_filter = |name: &str| filter.is_none_or(|needle| name.contains(needle)); + let colors = filtered_mode_map(&theme.colors, matches_filter); + let length = filtered_mode_map(&theme.length, matches_filter); + let shadow = filtered_mode_map(&theme.shadow, matches_filter); + let typography = theme + .typography + .iter() + .filter(|(name, _)| matches_filter(name)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>(); + projects.push(json!({ + "path": relative, + "modes": modes, + "tokenCount": theme.token_count(), + "colors": colors, + "typography": typography, + "length": length, + "shadow": shadow, + })); + } + json!({ + "found": true, + "scope": "theme", + "projectRoot": display_path(root), + "files": projects, + }) +} + +fn filtered_mode_map( + map: &std::collections::BTreeMap>, + matches_filter: impl Fn(&str) -> bool, +) -> Map { + map.iter() + .map(|(mode, tokens)| { + let tokens = tokens + .iter() + .filter(|(name, _)| matches_filter(name)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>(); + (mode.clone(), Value::Object(tokens)) + }) + .collect() +} + +// --------------------------------------------------------------------- +// api scope +// --------------------------------------------------------------------- + +const HTTP_METHODS: &[&str] = &[ + "get", "post", "put", "patch", "delete", "head", "options", "trace", +]; + +fn api_scope(root: &Path, filter: Option<&str>) -> Value { + let files = find_files_named(root, "openapi.json", 4); + if files.is_empty() { + return not_found_response( + "No openapi.json found. Do not write code by guessing API endpoint or schema names.", + vec![format!("{} (up to depth 4)", display_path(root))], + ); + } + let mut specs = Vec::new(); + for file in &files { + let relative = relative_display(root, file); + let source = match std::fs::read_to_string(file) { + Ok(source) => source, + Err(error) => { + specs.push(json!({ "path": relative, "readError": error.to_string() })); + continue; + } + }; + let parsed: Value = match serde_json::from_str(&source) { + Ok(value) => value, + Err(error) => { + specs.push(json!({ "path": relative, "parseError": error.to_string() })); + continue; + } + }; + specs.push(project_openapi_spec(&relative, &parsed, filter)); + } + json!({ + "found": true, + "scope": "api", + "projectRoot": display_path(root), + "specs": specs, + }) +} + +fn project_openapi_spec(relative_path: &str, spec: &Value, filter: Option<&str>) -> Value { + let matches_filter = |haystack: &str| filter.is_none_or(|needle| haystack.contains(needle)); + let mut endpoints = Vec::new(); + if let Some(paths) = spec.get("paths").and_then(Value::as_object) { + for (path, methods) in paths { + let Some(methods) = methods.as_object() else { + continue; + }; + for method in HTTP_METHODS { + let Some(operation) = methods.get(*method) else { + continue; + }; + let operation_id = operation.get("operationId").and_then(Value::as_str); + let haystack = format!("{path} {} {}", method, operation_id.unwrap_or("")); + if !matches_filter(&haystack) { + continue; + } + endpoints.push(json!({ + "method": method.to_ascii_uppercase(), + "path": path, + "operationId": operation_id, + })); + } + } + } + let mut schemas = Vec::new(); + let schema_container = spec + .get("components") + .and_then(|components| components.get("schemas")) + .or_else(|| spec.get("definitions")); // OpenAPI 2 / Swagger fallback + if let Some(Value::Object(schema_map)) = schema_container { + for (name, schema) in schema_map { + if !matches_filter(name) { + continue; + } + let required = schema + .get("required") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .map(|props| props.keys().cloned().collect::>()) + .unwrap_or_default(); + schemas.push(json!({ + "name": name, + "requiredFields": required, + "properties": properties, + })); + } + } + json!({ + "path": relative_path, + "endpointCount": endpoints.len(), + "schemaCount": schemas.len(), + "endpoints": endpoints, + "schemas": schemas, + }) +} + +// --------------------------------------------------------------------- +// db scope (Vespertide models) +// --------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct VespertideModel { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + columns: Vec, +} + +#[derive(Debug, Deserialize)] +struct VespertideColumn { + name: String, + #[serde(rename = "type")] + column_type: Value, + #[serde(default)] + nullable: bool, + #[serde(default)] + primary_key: Option, + #[serde(default)] + unique: Option, + #[serde(default)] + foreign_key: Option, + #[serde(default)] + index: Option, + #[serde(default)] + comment: Option, +} + +fn db_scope(root: &Path, filter: Option<&str>) -> Value { + let model_dirs = find_dirs_named(root, "models", 4); + let mut model_files = Vec::new(); + for dir in &model_dirs { + model_files.extend(json_files_in(dir)); + } + model_files.sort(); + model_files.dedup(); + if model_files.is_empty() { + return not_found_response( + "No Vespertide models (models/*.json) found. Do not write code by guessing table or column names or types.", + vec![format!( + "{} (models/*.json, up to depth 4)", + display_path(root) + )], + ); + } + let matches_filter = |haystack: &str| filter.is_none_or(|needle| haystack.contains(needle)); + let mut tables = Vec::new(); + for file in &model_files { + let relative = relative_display(root, file); + let source = match std::fs::read_to_string(file) { + Ok(source) => source, + Err(error) => { + tables.push(json!({ "path": relative, "readError": error.to_string() })); + continue; + } + }; + let model: VespertideModel = match serde_json::from_str(&source) { + Ok(model) => model, + Err(error) => { + // Not every *.json in a `models/` directory is necessarily a + // Vespertide model (e.g. `vespertide.json` config sitting + // one level up would not match this dir name, but a stray + // non-model JSON inside `models/` itself would land here). + // Report the parse failure rather than silently skipping, + // so the caller can see exactly why a file didn't surface. + tables.push(json!({ "path": relative, "parseError": error.to_string() })); + continue; + } + }; + if !matches_filter(&model.name) { + continue; + } + let columns = model.columns.iter().map(column_to_json).collect::>(); + let enums = model + .columns + .iter() + .filter_map(enum_definition) + .collect::>(); + tables.push(json!({ + "path": relative, + "table": model.name, + "description": model.description, + "columns": columns, + "enums": enums, + })); + } + json!({ + "found": true, + "scope": "db", + "projectRoot": display_path(root), + "tables": tables, + }) +} + +fn column_to_json(column: &VespertideColumn) -> Value { + let (type_name, enum_values) = describe_column_type(&column.column_type); + json!({ + "name": column.name, + "type": type_name, + "nullable": column.nullable, + "primaryKey": column.primary_key.is_some(), + "unique": column.unique.is_some(), + "indexed": column.index.is_some(), + "foreignKey": column.foreign_key, + "enumValues": enum_values, + "comment": column.comment, + }) +} + +fn enum_definition(column: &VespertideColumn) -> Option { + let object = column.column_type.as_object()?; + if object.get("kind").and_then(Value::as_str) != Some("enum") { + return None; + } + Some(json!({ + "column": column.name, + "name": object.get("name"), + "values": object.get("values").cloned().unwrap_or(Value::Null), + })) +} + +/// Returns `(type_name, enum_values)`: for simple string types, the string +/// itself with no enum values; for complex `{"kind": ..., ...}` types, the +/// `kind` string, and — for `kind: "enum"` — the raw `values` array. +fn describe_column_type(column_type: &Value) -> (String, Option) { + match column_type { + Value::String(simple) => (simple.clone(), None), + Value::Object(object) => { + let kind = object + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + let enum_values = if kind == "enum" { + object.get("values").cloned() + } else { + None + }; + (kind, enum_values) + } + other => (other.to_string(), None), + } +} + +fn relative_display(root: &Path, file: &Path) -> String { + file.strip_prefix(root) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| display_path(file)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct ScopedTempDir(PathBuf); + + impl ScopedTempDir { + fn new(label: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "devup-mcp-context-test-{label}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create scoped temp dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for ScopedTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[tokio::test] + async fn theme_scope_reads_real_devup_json_tokens() { + let temp = ScopedTempDir::new("theme-ok"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::write( + temp.path().join("devup.json"), + r##"{ "theme": { "colors": { "default": { "captionLight": "#999999" } } } }"##, + ) + .unwrap(); + let result = run("theme", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], true); + assert_eq!( + result["files"][0]["colors"]["default"]["captionLight"], + "#999999" + ); + } + + #[tokio::test] + async fn theme_scope_reports_not_found_guardrail_without_devup_json() { + let temp = ScopedTempDir::new("theme-missing"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let result = run("theme", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], false); + assert_eq!(result["guardrail"]["action"], "stop-and-report"); + } + + #[tokio::test] + async fn missing_project_root_reports_guardrail() { + let temp = ScopedTempDir::new("no-root"); + // No package.json/devup.json/Cargo.toml/.git anywhere under temp. + let nested = temp.path().join("deep").join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + let result = run("theme", Some(&nested.to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], false); + assert_eq!(result["guardrail"]["action"], "stop-and-report"); + } + + #[tokio::test] + async fn api_scope_extracts_endpoints_and_required_fields() { + let temp = ScopedTempDir::new("api-ok"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::write( + temp.path().join("openapi.json"), + r##"{ + "paths": { + "/users/{id}": { + "get": { "operationId": "getUser" } + } + }, + "components": { + "schemas": { + "User": { "required": ["id", "email"], "properties": { "id": {}, "email": {}, "name": {} } } + } + } + }"##, + ) + .unwrap(); + let result = run("api", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], true); + assert_eq!(result["specs"][0]["endpoints"][0]["operationId"], "getUser"); + assert_eq!(result["specs"][0]["endpoints"][0]["method"], "GET"); + assert_eq!(result["specs"][0]["schemas"][0]["requiredFields"][0], "id"); + } + + #[tokio::test] + async fn db_scope_extracts_columns_and_enum_values() { + let temp = ScopedTempDir::new("db-ok"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let models = temp.path().join("apis").join("api").join("models"); + std::fs::create_dir_all(&models).unwrap(); + std::fs::write( + models.join("user.json"), + r##"{ + "name": "user", + "columns": [ + { "name": "id", "type": "uuid", "nullable": false, "primary_key": true }, + { "name": "status", "type": { "kind": "enum", "name": "user_status", "values": ["pending", "active"] }, "nullable": false } + ] + }"##, + ) + .unwrap(); + let result = run("db", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], true); + let table = &result["tables"][0]; + assert_eq!(table["table"], "user"); + assert_eq!(table["columns"][0]["name"], "id"); + assert_eq!(table["columns"][0]["primaryKey"], true); + assert_eq!(table["enums"][0]["values"][0], "pending"); + } + + #[tokio::test] + async fn invalid_scope_is_rejected() { + let temp = ScopedTempDir::new("bad-scope"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let error = run("bogus", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + } + + #[tokio::test] + async fn all_scope_combines_every_axis() { + let temp = ScopedTempDir::new("all-scope"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::write(temp.path().join("devup.json"), r##"{"theme":{}}"##).unwrap(); + let result = run("all", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], true); + assert!(result.get("theme").is_some()); + assert!(result.get("api").is_some()); + assert!(result.get("db").is_some()); + } +} diff --git a/crates/devup-mcp/src/server/project_root.rs b/crates/devup-mcp/src/server/project_root.rs new file mode 100644 index 00000000..2d7c78cb --- /dev/null +++ b/crates/devup-mcp/src/server/project_root.rs @@ -0,0 +1,248 @@ +//! Shared project-root discovery and the "stop-and-report" guardrail +//! response shape used by all three ground-truth tools +//! (`devup_project_context`, `devup_ui_validate`, `devup_stack_diff`). +//! +//! This generalizes the exact pattern verified in `diagnostics::host_requirement` +//! for the `needs_figma` handoff: when a tool cannot ground its answer in a +//! real file, it must say so explicitly and instruct the caller to stop +//! rather than guess, instead of silently returning nothing or (worse) +//! inventing a plausible-looking answer. See `README.md`'s brief for the +//! `$gray100` incident this exists to prevent. +//! +//! Every function here only reads the filesystem; nothing is written or +//! cached across calls, per the brief's "호출 시점에 파일을 읽는다. 세션 +//! 간 캐시 금지" requirement — a project file can change between two +//! tool calls in the same session, and treating a stale in-memory copy as +//! current fact would be exactly the kind of confident-but-wrong answer +//! this tool exists to prevent. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +/// Filenames whose presence in a directory marks it as a project root. +const ROOT_MARKERS: &[&str] = &["devup.json", "package.json", "Cargo.toml", ".git"]; + +/// Directory names never descended into during a bounded project search: +/// dependency/build output that is large, irrelevant, and would otherwise +/// dominate search time and result noise. +const SKIP_DIRS: &[&str] = &[ + "node_modules", + "target", + "dist", + "build", + ".git", + ".next", + ".turbo", + ".nuxt", + "out", + ".venv", + "venv", + "__pycache__", + ".cache", + "coverage", +]; + +/// Searches `start` and each ancestor directory for one of [`ROOT_MARKERS`], +/// returning the first (nearest) directory that has one. Returns `None` if +/// no ancestor (up to the filesystem root) has any marker. +pub fn find_project_root(start: &Path) -> Option { + let mut current = Some(start.to_path_buf()); + while let Some(dir) = current { + if ROOT_MARKERS.iter().any(|marker| dir.join(marker).exists()) { + return Some(dir); + } + current = dir.parent().map(Path::to_path_buf); + } + None +} + +/// Breadth-first search from `root` down to `max_depth` directories for +/// every file whose name is exactly `filename`, skipping [`SKIP_DIRS`]. +/// Returns paths sorted for deterministic output. +pub fn find_files_named(root: &Path, filename: &str, max_depth: usize) -> Vec { + let mut found = Vec::new(); + let mut queue = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_file() && name == filename { + found.push(path); + } else if file_type.is_dir() && depth < max_depth && !SKIP_DIRS.contains(&name.as_ref()) + { + queue.push((path, depth + 1)); + } + } + } + found.sort(); + found +} + +/// Breadth-first search for every directory named exactly `dirname` (e.g. +/// vespertide's conventional `models/` directory), skipping [`SKIP_DIRS`]. +pub fn find_dirs_named(root: &Path, dirname: &str, max_depth: usize) -> Vec { + let mut found = Vec::new(); + let mut queue = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == dirname { + found.push(path.clone()); + } + if depth < max_depth && !SKIP_DIRS.contains(&name.as_ref()) { + queue.push((path, depth + 1)); + } + } + } + found.sort(); + found +} + +/// Every `*.json` file directly inside `dir` (non-recursive), sorted. +pub fn json_files_in(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut files = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json")) + .collect::>(); + files.sort(); + files +} + +/// Just the `guardrail` object (`{"action": "stop-and-report", ...}`), +/// without the `found` wrapper — for tools that need to embed it as a +/// nested field (e.g. `devup_ui_validate`'s `themeGuardrail`) rather than +/// as the whole top-level response. `action` is always the literal string +/// `"stop-and-report"`, the same contract +/// [`crate::server::host_requirement`]-style responses use. +pub fn guardrail_object(message: impl Into, searched_paths: Vec) -> Value { + json!({ + "action": "stop-and-report", + "message": message.into(), + "searchedPaths": searched_paths + }) +} + +/// The `{"found": false, "guardrail": {...}}` envelope every ground-truth +/// tool returns as its top-level response instead of guessing when it +/// cannot locate the file(s) it needs. +pub fn not_found_response(message: impl Into, searched_paths: Vec) -> Value { + json!({ + "found": false, + "guardrail": guardrail_object(message, searched_paths) + }) +} + +/// The standard message for "could not even determine a project root" — +/// distinct from "found a project root but the target file is missing" +/// ([`not_found_response`] with a scope-specific message), since the two +/// failures call for different next steps from the caller. +pub const PROJECT_ROOT_NOT_FOUND_MESSAGE: &str = "No project root found. No directory containing devup.json, package.json, Cargo.toml, or .git was found. Do not write code by guessing token, endpoint, or column names."; + +/// Path displayed as-is (already OS-native), used consistently across the +/// three tools so `searchedPaths` entries are directly copy-pasteable. +pub fn display_path(path: &Path) -> String { + path.display().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Minimal scoped-temp-directory helper (no `tempfile` dependency): + /// creates a uniquely-named directory under the OS temp dir and removes + /// it (and everything under it) on drop. + struct ScopedTempDir(PathBuf); + + impl ScopedTempDir { + fn new(label: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "devup-mcp-test-{label}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create scoped temp dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for ScopedTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn finds_root_by_walking_up_to_a_marker() { + let temp = ScopedTempDir::new("root-marker"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let nested = temp.path().join("apps").join("front"); + std::fs::create_dir_all(&nested).unwrap(); + let root = find_project_root(&nested).expect("root found"); + assert_eq!(root, temp.path()); + } + + #[test] + fn returns_none_when_no_marker_exists_up_to_a_bare_temp_dir() { + let temp = ScopedTempDir::new("no-marker"); + let isolated = temp.path().join("isolated"); + std::fs::create_dir_all(&isolated).unwrap(); + // A bare scoped temp dir has no devup.json/package.json/Cargo.toml/.git + // in the isolated subtree itself, which is what we control + // deterministically here. + assert!( + !ROOT_MARKERS + .iter() + .any(|marker| isolated.join(marker).exists()) + ); + } + + #[test] + fn find_files_named_skips_node_modules() { + let temp = ScopedTempDir::new("skip-node-modules"); + let nm = temp.path().join("node_modules").join("pkg"); + std::fs::create_dir_all(&nm).unwrap(); + std::fs::write(nm.join("devup.json"), "{}").unwrap(); + let real = temp.path().join("apps").join("front"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("devup.json"), "{}").unwrap(); + let found = find_files_named(temp.path(), "devup.json", 4); + assert_eq!(found, vec![real.join("devup.json")]); + } + + #[test] + fn not_found_response_always_has_stop_and_report_action() { + let value = not_found_response("test", vec!["a".to_owned()]); + assert_eq!(value["found"], false); + assert_eq!(value["guardrail"]["action"], "stop-and-report"); + assert_eq!(value["guardrail"]["searchedPaths"][0], "a"); + } +} diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 50c73308..2c710667 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -1,13 +1,17 @@ +use std::collections::BTreeMap; use std::fmt::Write as _; use base64::{Engine as _, engine::general_purpose::STANDARD}; use devup_mcp_devup_ui::{ - codegen::{CodegenOptions, generate_component}, + codegen::{ + CodegenOptions, generate_component, normalize_component_name, responsive::merge_breakpoints, + }, theme::{generate_devup_json, variable_snapshot_from_result}, }; use devup_mcp_figma::{ - AssetManifest, AssetStatus, CollectedPayload, CollectionStats, DevupError, ErrorCode, - ExploreOptions, SearchOptions, TargetKind, classify_target, explore_snapshot, search_snapshot, + AssetManifest, AssetStatus, CollectedPayload, CollectionStats, DevupError, Diagnostic, + DiagnosticSeverity, ErrorCode, ExploreOptions, SearchOptions, TargetKind, classify_target, + explore_snapshot, search_snapshot, }; use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; @@ -16,7 +20,7 @@ use super::{ artifacts::{ArtifactLookup, ArtifactStore, OutputReservation}, delivery::{DeliveryMode, ProjectedOutput, choose_delivery_for_result}, format_epoch_rfc3339, - handoff::PendingOperation, + operation::PendingOperation, output::{OutputPolicy, OutputTransaction}, parse_scope, quality::{ @@ -36,6 +40,13 @@ pub(super) fn projected_outputs_from_result( tsx.as_bytes().to_vec(), )); } + if let Some(tsx) = result.get("componentTsx").and_then(Value::as_str) { + outputs.push(ProjectedOutput::text( + "componentTsx", + "text/typescript", + tsx.as_bytes().to_vec(), + )); + } if let Some(devup_json) = result.get("devupJson").and_then(Value::as_str) { outputs.push(ProjectedOutput::text( "devupJson", @@ -50,14 +61,14 @@ pub(super) fn projected_outputs_from_result( .ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "referencePng resource에 base64 data가 없습니다.", + "The referencePng resource has no base64 data.", false, ) })?; let bytes = STANDARD.decode(data.as_bytes()).map_err(|_| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "referencePng resource의 base64가 올바르지 않습니다.", + "The referencePng resource base64 is invalid.", false, ) })?; @@ -65,6 +76,7 @@ pub(super) fn projected_outputs_from_result( } for (field, name) in [ ("rawSnapshot", "raw-snapshot.json"), + ("rawPayload", "raw-payload.json"), ("sourceMap", "source-map.json"), ("assetManifest", "asset-manifest.json"), ] { @@ -101,7 +113,7 @@ fn encode_projected_json(value: &Value) -> Result, DevupError> { serde_json::to_vec(value).map_err(|error| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("resource output을 JSON으로 직렬화할 수 없습니다: {error}"), + format!("Cannot serialize the resource output to JSON: {error}"), false, ) }) @@ -130,7 +142,7 @@ pub(super) async fn apply_delivery( let result = result.as_object_mut().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "resource delivery 결과가 JSON object가 아닙니다.", + "The resource delivery result is not a JSON object.", false, ) })?; @@ -138,6 +150,7 @@ pub(super) async fn apply_delivery( "tsx", "devupJson", "rawSnapshot", + "rawPayload", "sourceMap", "assetManifest", "referencePng", @@ -211,7 +224,7 @@ fn materialize_asset_resource_references( .ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "asset resource에 대응하는 manifest 항목이 없습니다.", + "No manifest entry matches this asset resource.", false, ) })?; @@ -234,7 +247,7 @@ fn materialize_asset_resource_references( encode_projected_json(result.get("assetManifest").ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "asset manifest resource가 없습니다.", + "The asset manifest resource is missing.", false, ) })?)?; @@ -251,35 +264,35 @@ fn projected_asset_outputs(manifest: &AssetManifest) -> Result Result String { + name.chars() + .map(|character| match character { + ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\\' => '-', + other if other.is_control() => '-', + other => other, + }) + .collect() +} + +/// `/icons/ic:round-arrow-left.svg` as `/icons/ic-round-arrow-left.svg`. Only +/// the file name is touched; the folder the code looks in is left alone. +fn host_safe_asset_path(path: &str) -> String { + match path.rsplit_once('/') { + Some((directory, name)) => format!("{directory}/{}", host_safe_file_name(name)), + None => host_safe_file_name(path), + } +} + +/// Every asset the generated code points at, renamed the way its bytes will +/// be written. The code holds each path inside a string or a `url(...)`, so a +/// name runs to the first quote or closing parenthesis - spaces and dots +/// belong to it, as they do in `Frame 269.png`. +fn with_host_safe_asset_paths(code: &str) -> String { + let mut safe = String::with_capacity(code.len()); + let mut rest = code; + loop { + let Some((at, prefix)) = ["/icons/", "/images/"] + .into_iter() + .filter_map(|prefix| rest.find(prefix).map(|at| (at, prefix))) + .min_by_key(|(at, _)| *at) + else { + safe.push_str(rest); + return safe; + }; + let after = at + prefix.len(); + safe.push_str(&rest[..after]); + let end = rest[after..] + .find(['"', '\'', ')']) + .map_or(rest.len(), |offset| after + offset); + safe.push_str(&host_safe_file_name(&rest[after..end])); + rest = &rest[end..]; + } +} + fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes) .iter() @@ -378,7 +442,7 @@ pub(super) async fn complete_operation( let node_id = payload.target.node_id.as_deref().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "UI 변환 payload에는 node ID가 필요합니다.", + "A UI conversion payload requires a node ID.", false, ) })?; @@ -390,6 +454,9 @@ pub(super) async fn complete_operation( include_diagnostics, inline_instances: true, root_layout, + // `devup_figma_to_ui` hands back one module and no files, + // so there is nothing for a per-node name to keep apart. + asset_names_per_node: false, ..CodegenOptions::default() } .with_payload_tokens(payload), @@ -459,6 +526,19 @@ pub(super) async fn complete_operation( }; commit_delivery(attachment); result["outputPath"] = json!(written_path); + if status == "complete" { + // Unambiguous "this is the real, final answer" marker. + // Without it, an agent repeatedly seeing `needs_figma` + // intermediate steps has, in an observed real failure, + // concluded the conversion was "probably done" and moved + // on to hand-interpreting the raw node tree instead of + // waiting for this response. + result["deliverable"] = json!({ + "kind": "devup-ui-tsx", + "isFinal": true, + "note": "This tsx is the final deliverable. Implement from this value." + }); + } Ok(result) } PendingOperation::ToJson { @@ -470,7 +550,7 @@ pub(super) async fn complete_operation( let result = payload.variables.as_ref().ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "Figma 변수/style 수집 결과가 없습니다.", + "There is no Figma variable/style collection result.", false, ) })?; @@ -616,6 +696,7 @@ pub(super) async fn complete_operation( component_name, include_diagnostics, root_layout, + asset_names_per_node, scope, strict, output_paths, @@ -630,6 +711,7 @@ pub(super) async fn complete_operation( result.insert("completenessReport".to_owned(), json!(&completeness_report)); result.insert("collection".to_owned(), json!(collection)); result.insert("cache".to_owned(), artifact_metadata(artifact)); + result.insert("failures".to_owned(), json!(&payload.failures)); result.insert( "source".to_owned(), json!({ @@ -650,14 +732,14 @@ pub(super) async fn complete_operation( if !frame_ids.is_empty() && all_screens { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "frameIds와 allScreens는 동시에 사용할 수 없습니다.", + "frameIds and allScreens cannot be used together.", false, )); } if target_kind != TargetKind::Section && (!frame_ids.is_empty() || all_screens) { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "frameIds와 allScreens는 Section artifact에서만 사용할 수 있습니다.", + "frameIds and allScreens can only be used on a Section artifact.", false, )); } @@ -702,6 +784,14 @@ pub(super) async fn complete_operation( "truncated": candidates.len() == 100 }), ); + 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.", + "doNot": "Do not try to collect the whole Section at once." + }), + ); return Ok(Value::Object(result)); } @@ -722,8 +812,16 @@ pub(super) async fn complete_operation( .iter() .map(|candidate| (candidate.node.node_id.as_str(), candidate)) .collect::>(); + let failed_ids = payload + .failures + .iter() + .map(|failure| failure.node_id.as_str()) + .collect::>(); let selected = if all_screens { - candidates.iter().collect::>() + candidates + .iter() + .filter(|candidate| !failed_ids.contains(candidate.node.node_id.as_str())) + .collect::>() } else { let requested = frame_ids .iter() @@ -732,7 +830,7 @@ pub(super) async fn complete_operation( if requested.len() != frame_ids.len() { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "frameIds에 중복 node가 있습니다.", + "frameIds contains a duplicate node.", false, )); } @@ -743,14 +841,17 @@ pub(super) async fn complete_operation( return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, format!( - "Section 내부 screen frame이 아니거나 존재하지 않습니다: {node_id}" + "Not a screen frame inside the Section, or it does not exist: {node_id}" ), false, )); } candidates .iter() - .filter(|candidate| requested.contains(candidate.node.node_id.as_str())) + .filter(|candidate| { + requested.contains(candidate.node.node_id.as_str()) + && !failed_ids.contains(candidate.node.node_id.as_str()) + }) .collect::>() }; let mut frames = Vec::with_capacity(selected.len()); @@ -770,6 +871,7 @@ pub(super) async fn complete_operation( include_diagnostics, inline_instances: true, root_layout, + asset_names_per_node, ..CodegenOptions::default() } .with_payload_tokens(payload), @@ -815,11 +917,82 @@ pub(super) async fn complete_operation( section_tsx_projected = true; } + let component_name_for_components = component_name.clone(); + // A screen captured with its other widths is convertible as one + // tree. It is offered whenever those widths are present rather than + // only on request, because a caller asking for a screen that has + // them almost always wants the responsive form and cannot know from + // the node id alone whether it exists. + if outputs + .iter() + .any(|output| output == "tsx" || output == "responsiveTsx") + && let Some(merged) = merge_breakpoints( + &payload.snapshot, + &CodegenOptions { + component_name: component_name_for_components.clone(), + include_diagnostics, + inline_instances: false, + root_layout, + asset_names_per_node, + ..CodegenOptions::default() + } + .with_payload_tokens(payload), + )? + { + // Named as the plugin names it: after the Section the widths + // sit in, with `Page` on the end — `AboutPage` for a Section + // called `about`. The Section is outside the collected + // subtree, so each width carries its name as `parentName`. + // A caller's own name wins; without one and without a + // Section name the page is `ResponsivePage`. + let section_name = payload + .snapshot + .roots + .iter() + .filter_map(|root| payload.snapshot.nodes.get(root)) + .find_map(|root| root.typed_view().string("parentName").map(str::to_owned)); + let name = component_name_for_components.clone().map_or_else( + || { + section_name.map_or_else( + || "ResponsivePage".to_owned(), + |section| format!("{}Page", normalize_component_name(§ion)), + ) + }, + |name| normalize_component_name(&name), + ); + let module = merged.module(&name); + if output_paths.contains_key("responsiveTsx") { + pending_text_outputs.insert("responsiveTsx".to_owned(), module.clone()); + } + result.insert("responsiveTsx".to_owned(), json!(module)); + result.insert("responsiveImports".to_owned(), json!(merged.primitives())); + result.insert( + "responsiveComponents".to_owned(), + json!(merged.referenced_components()), + ); + result.insert("responsiveSlots".to_owned(), json!(merged.slots)); + if !merged.unrepresented.is_empty() { + result.insert( + "responsiveUnrepresented".to_owned(), + json!( + merged + .unrepresented + .iter() + .map(|note| json!({ + "nodeId": note.node_id, + "detail": note.detail + })) + .collect::>() + ), + ); + } + } + if outputs.iter().any(|output| output == "tsx") && !section_tsx_projected { let node_id = payload.target.node_id.as_deref().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "TSX export payload에는 node ID가 필요합니다.", + "A TSX export payload requires a node ID.", false, ) })?; @@ -831,6 +1004,7 @@ pub(super) async fn complete_operation( include_diagnostics, inline_instances: true, root_layout, + asset_names_per_node, ..CodegenOptions::default() } .with_payload_tokens(payload), @@ -850,11 +1024,39 @@ pub(super) async fn complete_operation( } } + if outputs.iter().any(|output| output == "componentTsx") { + let node_id = payload.target.node_id.as_deref().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "A component TSX export payload requires a node ID.", + false, + ) + })?; + let output = generate_component( + &payload.snapshot, + node_id, + &CodegenOptions { + component_name: component_name_for_components, + include_diagnostics: false, + inline_instances: false, + root_layout, + asset_names_per_node, + ..CodegenOptions::default() + } + .with_payload_tokens(payload), + )?; + if output_paths.contains_key("componentTsx") { + pending_text_outputs.insert("componentTsx".to_owned(), output.tsx.clone()); + } + result.insert("componentTsx".to_owned(), json!(output.tsx)); + result.insert("componentImports".to_owned(), json!(output.imports)); + } + if outputs.iter().any(|output| output == "devupJson") { let variables = payload.variables.as_ref().ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "Figma 변수/style 수집 결과가 없습니다.", + "There is no Figma variable/style collection result.", false, ) })?; @@ -883,7 +1085,7 @@ pub(super) async fn complete_operation( let raw = serde_json::to_value(&payload.snapshot).map_err(|error| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("raw snapshot을 직렬화할 수 없습니다: {error}"), + format!("Cannot serialize the raw snapshot: {error}"), false, ) })?; @@ -896,6 +1098,33 @@ pub(super) async fn complete_operation( result.insert("rawSnapshot".to_owned(), raw); } + // The whole collection, not only its node tree. A snapshot kept on + // its own can be converted, but not the way the server converts + // it: the token names come from the variables and styles collected + // beside it, and without them a `$gray200` fill comes out as the + // tail of its variable ID and a `typography="h4"` as five font + // props. Captures kept as fixtures need the resources too, so + // this writes them. The reference PNG is left out — it is large, + // binary, and has its own output. + if outputs.iter().any(|output| output == "rawPayload") { + let mut without_png = payload.clone(); + without_png.reference_png = None; + let raw = serde_json::to_value(&without_png).map_err(|error| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + format!("Cannot serialize the raw payload: {error}"), + false, + ) + })?; + if output_paths.contains_key("rawPayload") { + pending_text_outputs.insert( + "rawPayload".to_owned(), + serde_json::to_string_pretty(&raw).unwrap_or_default(), + ); + } + result.insert("rawPayload".to_owned(), raw); + } + if outputs.iter().any(|output| output == "sourceMap") && !section_tsx_projected { let source_map = json!({ "version": 1, @@ -922,7 +1151,7 @@ pub(super) async fn complete_operation( let reference = payload.reference_png.as_ref().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "artifact에 요청한 reference PNG가 없습니다. URL로 다시 수집하세요.", + "The requested reference PNG is not in the artifact. Re-collect it from the URL.", false, ) })?; @@ -931,7 +1160,7 @@ pub(super) async fn complete_operation( .map_err(|_| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "artifact reference PNG의 base64가 올바르지 않습니다.", + "The artifact reference PNG base64 is invalid.", false, ) })?; @@ -944,7 +1173,7 @@ pub(super) async fn complete_operation( { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "artifact reference PNG의 길이 또는 hash가 일치하지 않습니다.", + "The artifact reference PNG length or hash does not match.", false, )); } @@ -970,6 +1199,47 @@ pub(super) async fn complete_operation( manifest .assets .sort_by(|left, right| left.asset_id.cmp(&right.asset_id)); + // Where the generated code refers to each asset, so a + // consumer can write the bytes there without re-deriving the + // name - which, for two icons of one layer name, it would get + // wrong. + for asset in &mut manifest.assets { + if asset.path.is_none() { + // An image fill is named from the node it is painted + // on and which fill it is, which holds for a layout + // box carrying a photograph as much as for a node + // drawn entirely from a file. Anything else is named + // after the node itself. + asset.path = match asset + .field + .strip_prefix("fills/") + .and_then(|index| index.parse::().ok()) + { + Some(fill_index) => devup_mcp_devup_ui::codegen::image_fill_path( + &payload.snapshot, + &asset.node_id, + fill_index, + asset_names_per_node, + ), + None => devup_mcp_devup_ui::codegen::asset_path( + &payload.snapshot, + &asset.node_id, + asset_names_per_node, + ), + }; + } + } + // Where the bytes will be written, if a layer name is one a + // file system refuses. The code that points at them is + // renamed the same way once every output is assembled. + for asset in &mut manifest.assets { + if let Some(path) = asset.path.as_deref() { + let safe = host_safe_asset_path(path); + if safe != path { + asset.path = Some(safe); + } + } + } for capture in &asset_captures { if !payload.assets.iter().any(|asset| { asset.asset_id == capture.asset_id @@ -980,7 +1250,7 @@ pub(super) async fn complete_operation( return Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, format!( - "artifact에 요청한 정확한 asset export가 없습니다. URL로 다시 수집하세요: {}", + "The exact requested asset export is not in the artifact. Re-collect it from the URL: {}", capture.asset_id ), false, @@ -1023,7 +1293,7 @@ pub(super) async fn complete_operation( return Err(DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, format!( - "strict export는 exact/complete output만 허용합니다: status={}, quality={}", + "strict export only allows exact/complete output: status={}, quality={}", quality.status(), serde_json::to_string(&quality).unwrap_or_default() ), @@ -1035,8 +1305,44 @@ pub(super) async fn complete_operation( }), )); } - result.insert("status".to_owned(), json!(quality.status())); + let final_status = quality.status(); + result.insert("status".to_owned(), json!(final_status)); result.insert("quality".to_owned(), json!(quality)); + let tsx_produced = + section_tsx_projected || outputs.iter().any(|output| output == "tsx"); + if final_status == "complete" && tsx_produced { + // Same unambiguous final-answer marker as devup_figma_to_ui + // — see that branch's comment for why this exists. Checked + // here (before `apply_delivery` may move `tsx`/each frame's + // `tsx` into `resources`) so the marker reflects whether a + // devup-ui TSX was actually produced, independent of how + // large output routed it for delivery. + result.insert( + "deliverable".to_owned(), + json!({ + "kind": "devup-ui-tsx", + "isFinal": true, + "note": "This tsx is the final deliverable. Implement from this value." + }), + ); + } + // The code and the bytes have to name an asset alike. Renaming + // here, once both are assembled, keeps the two in step while the + // code generator itself stays byte-for-byte the plugin's. + for output in ["tsx", "responsiveTsx", "componentTsx"] { + if let Some(Value::String(code)) = result.get_mut(output) { + let safe = with_host_safe_asset_paths(code.as_str()); + if safe != *code { + *code = safe; + } + } + if let Some(code) = pending_text_outputs.get_mut(output) { + let safe = with_host_safe_asset_paths(code.as_str()); + if safe != *code { + *code = safe; + } + } + } let mut planned_outputs = Vec::new(); for (output, contents) in pending_text_outputs { if let Some(path) = output_paths.get(&output) { @@ -1063,14 +1369,14 @@ pub(super) async fn complete_operation( let data = asset.data_base64.as_deref().ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "export된 asset binary가 artifact에 없습니다.", + "The exported asset binary is not in the artifact.", false, ) })?; let bytes = STANDARD.decode(data.as_bytes()).map_err(|_| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "export된 asset binary의 base64가 올바르지 않습니다.", + "The exported asset binary base64 is invalid.", false, ) })?; @@ -1082,11 +1388,33 @@ pub(super) async fn complete_operation( } } let mut transaction = OutputTransaction::new(); + // Two nodes can be one picture - a logo drawn at three sizes + // shares a file, as the plugin has it - so several assets resolve + // to one path. That is one write, not a collision. Only differing + // bytes under one name are a mistake, and that is worth refusing. + // Two different drawings under one layer name land here too: the + // plugin names an asset after its layer, and a snapshot cannot + // tell two drawings of one name apart - only the exported bytes + // can. The first is written and the rest are reported, so the + // code still points at a file that exists and the caller learns + // which names hide more than one picture. + let mut staged_content: BTreeMap = BTreeMap::new(); + let mut shared_names: BTreeMap> = BTreeMap::new(); for (name, target, bytes) in planned_outputs { - written_paths.insert( - name.clone(), - json!(target.display_path().to_string_lossy().into_owned()), - ); + let path = target.display_path().to_string_lossy().into_owned(); + written_paths.insert(name.clone(), json!(path.clone())); + let fingerprint = sha256_hex(&bytes); + match staged_content.get(&path) { + Some(staged) if *staged == fingerprint => continue, + Some(_) => { + let asset = name.strip_prefix("asset:").unwrap_or(&name).to_owned(); + shared_names.entry(path).or_default().push(asset); + continue; + } + None => { + staged_content.insert(path, fingerprint); + } + } transaction.stage(name, target, &bytes)?; } if let Some(mut manifest) = pending_asset_manifest { @@ -1102,6 +1430,21 @@ pub(super) async fn complete_operation( asset.output_path = Some(path.to_owned()); asset.data_base64 = None; } + for (path, others) in &shared_names { + manifest.diagnostics.push(Diagnostic { + code: "DEVUP_ASSET_NAME_SHARED".to_owned(), + message: format!( + "{} further drawing(s) claim the file {path}; the first is written. \ + An asset is named after its layer, as the plugin names it, so two \ + drawings a designer named alike cannot both be written.", + others.len() + ), + severity: Some(DiagnosticSeverity::Warning), + resource_kind: Some("asset".to_owned()), + details: Some(json!({ "outputPath": path, "notWritten": others })), + ..Diagnostic::default() + }); + } result.insert("assetManifest".to_owned(), json!(manifest)); } result.insert("outputPaths".to_owned(), Value::Object(written_paths)); @@ -1126,8 +1469,44 @@ pub(super) async fn complete_operation( } PendingOperation::Collect | PendingOperation::Artifact { .. } => Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "내부 수집 operation은 MCP artifact로 완료할 수 없습니다.", + "An internal collect operation cannot be completed from an MCP artifact.", false, )), } } + +#[cfg(test)] +mod tests { + use super::{host_safe_asset_path, with_host_safe_asset_paths}; + + #[test] + fn a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_points_at_it() { + let code = concat!( + "\n", + "\n", + "\n", + "\n", + ); + assert_eq!( + with_host_safe_asset_paths(code), + concat!( + "\n", + "\n", + "\n", + "\n", + ) + ); + } + + #[test] + fn the_written_path_is_renamed_the_same_way_the_code_is() { + assert_eq!( + host_safe_asset_path("/icons/grommet-icons:language.svg"), + "/icons/grommet-icons-language.svg" + ); + assert_eq!( + host_safe_asset_path("/images/Frame 269.png"), + "/images/Frame 269.png" + ); + } +} diff --git a/crates/devup-mcp/src/server/stack_diff.rs b/crates/devup-mcp/src/server/stack_diff.rs new file mode 100644 index 00000000..0a19918f --- /dev/null +++ b/crates/devup-mcp/src/server/stack_diff.rs @@ -0,0 +1,1056 @@ +//! `devup_stack_diff` — cross-layer drift detection across the devup +//! stack (`vespertide model -> sea-orm entity -> vespera route -> +//! openapi.json -> @devup-api client`). This is the one ground-truth tool +//! that cannot be reduced to "read one file and report its contents": it +//! compares independently-authored layers that a human reviewer would +//! normally have to cross-reference by hand. +//! +//! Every check here is text/JSON-based, not a real compiler front end for +//! Rust or TypeScript. That is a deliberate, disclosed limitation, not an +//! oversight: extraction can miss macro-generated routes (e.g. +//! `vespera::export_app!`-merged sub-apps), non-standard formatting, or +//! re-exported client wrappers. Every reported drift and every skipped +//! layer carries an explicit `confidence` (`"low"` or `"medium"`) — never +//! `"high"`, since none of these checks is a real parse — and the tool +//! never claims a clean layer is drift-free with unwarranted certainty; +//! see each layer's doc comment for exactly what it can and cannot see. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use devup_mcp_figma::{DevupError, ErrorCode}; +use serde_json::{Value, json}; + +use super::project_root::{ + PROJECT_ROOT_NOT_FOUND_MESSAGE, display_path, find_dirs_named, find_files_named, + find_project_root, json_files_in, not_found_response, +}; + +const ALL_LAYERS: &[&str] = &[ + "db-entity", + "entity-route", + "route-openapi", + "openapi-client", +]; + +pub async fn run(project_root: Option<&str>, layers: &[String]) -> Result { + let requested = if layers.is_empty() { + ALL_LAYERS + .iter() + .map(|layer| (*layer).to_owned()) + .collect::>() + } else { + layers.to_vec() + }; + for layer in &requested { + if !ALL_LAYERS.contains(&layer.as_str()) { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "Each layers entry must be one of db-entity, entity-route, route-openapi, openapi-client.", + false, + json!({ "invalidLayer": layer }), + )); + } + } + + let start = match project_root { + Some(root) => PathBuf::from(root), + None => std::env::current_dir().map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "Could not determine the current directory.", + false, + json!({ "ioError": error.to_string() }), + ) + })?, + }; + let Some(root) = find_project_root(&start) else { + return Ok(not_found_response( + PROJECT_ROOT_NOT_FOUND_MESSAGE, + vec![display_path(&start)], + )); + }; + + let model_dirs = find_dirs_named(&root, "models", 5); + let mut layers_out = serde_json::Map::new(); + for layer in &requested { + let result = match layer.as_str() { + "db-entity" => db_entity_layer(&model_dirs), + "entity-route" => entity_route_layer(&root, &model_dirs), + "route-openapi" => route_openapi_layer(&root), + "openapi-client" => openapi_client_layer(&root), + _ => unreachable!("validated above"), + }; + layers_out.insert(layer.clone(), result); + } + + Ok(json!({ + "found": true, + "projectRoot": display_path(&root), + "layers": Value::Object(layers_out), + })) +} + +// --------------------------------------------------------------------- +// db-entity: vespertide models/*.json columns vs sea-orm src/models/*.rs +// --------------------------------------------------------------------- + +/// Compares each Vespertide model's declared columns against the field +/// names in its generated sea-orm `Model` struct +/// (`/src/models/.rs`, per `vespertide.json`'s +/// default `modelExportDir`). Field extraction is a brace-depth text scan +/// for `pub struct Model { ... }`, not a Rust parser, so it can miss +/// fields hidden behind `#[cfg(...)]` or unusual formatting — hence +/// `confidence: "medium"` rather than `"high"`. +fn db_entity_layer(model_dirs: &[PathBuf]) -> Value { + if model_dirs.is_empty() { + return json!({ + "checked": false, + "reason": "No models/ directory found (no Vespertide models).", + "drifts": [], + }); + } + let mut drifts = Vec::new(); + let mut tables_checked = 0usize; + for models_dir in model_dirs { + let vespertide_root = models_dir.parent().map(Path::to_path_buf); + for model_file in json_files_in(models_dir) { + let Ok(source) = std::fs::read_to_string(&model_file) else { + continue; + }; + let Ok(model) = serde_json::from_str::(&source) else { + continue; + }; + let Some(table) = model.get("name").and_then(Value::as_str) else { + continue; + }; + let column_names = model + .get("columns") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|column| column.get("name").and_then(Value::as_str)) + .map(str::to_owned) + .collect::>(); + if column_names.is_empty() { + continue; + } + tables_checked += 1; + let Some(vespertide_root) = &vespertide_root else { + continue; + }; + let entity_path = vespertide_root + .join("src") + .join("models") + .join(format!("{table}.rs")); + let Ok(entity_source) = std::fs::read_to_string(&entity_path) else { + drifts.push(json!({ + "table": table, + "kind": "entity-not-generated", + "message": format!( + "No sea-orm entity ({}) found for the {table} model. Check that `vespertide export --orm seaorm` was run.", + display_path(&entity_path) + ), + "confidence": "low", + })); + continue; + }; + let entity_fields = extract_model_struct_fields(&entity_source); + let missing_in_entity = column_names + .difference(&entity_fields) + .cloned() + .collect::>(); + let missing_in_model = entity_fields + .difference(&column_names) + .cloned() + .collect::>(); + if !missing_in_entity.is_empty() || !missing_in_model.is_empty() { + drifts.push(json!({ + "table": table, + "kind": "column-entity-mismatch", + "entityPath": display_path(&entity_path), + "columnsMissingInEntity": missing_in_entity, + "fieldsMissingInModel": missing_in_model, + "confidence": "medium", + })); + } + } + } + json!({ + "checked": true, + "tablesChecked": tables_checked, + "drifts": drifts, + }) +} + +/// Text-scans a sea-orm entity source for `pub struct Model { ... }` and +/// extracts each `pub : ,` line's field name via brace-depth +/// tracking (not a real Rust parser). +fn extract_model_struct_fields(source: &str) -> BTreeSet { + let mut fields = BTreeSet::new(); + let Some(struct_start) = source.find("struct Model") else { + return fields; + }; + let Some(open_brace_offset) = source[struct_start..].find('{') else { + return fields; + }; + let body_start = struct_start + open_brace_offset + 1; + let mut depth = 1i32; + let mut end = body_start; + for (offset, character) in source[body_start..].char_indices() { + match character { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = body_start + offset; + break; + } + } + _ => {} + } + } + let body = &source[body_start..end]; + for line in body.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("pub ") else { + continue; + }; + let Some(colon) = rest.find(':') else { + continue; + }; + let field_name = rest[..colon].trim(); + if !field_name.is_empty() && field_name.chars().all(|c| c.is_alphanumeric() || c == '_') { + fields.insert(field_name.to_owned()); + } + } + fields +} + +// --------------------------------------------------------------------- +// entity-route: does any route file even mention each entity field? +// --------------------------------------------------------------------- + +/// For each Vespertide column, checks whether its snake_case name or its +/// PascalCase sea-orm `Column::Variant` form appears as a plain substring +/// anywhere under a sibling `src/routes/` tree. This is a *presence* +/// check, not a semantic one: a column could appear in a comment, an +/// unrelated string, or a route that never actually serializes it, and a +/// column genuinely unused by any route (by design, e.g. an internal-only +/// audit column) will still be flagged. `confidence: "low"` reflects this; +/// treat every reported item as a lead to verify, not a confirmed bug. +fn entity_route_layer(root: &Path, model_dirs: &[PathBuf]) -> Value { + if model_dirs.is_empty() { + return json!({ + "checked": false, + "reason": "No models/ directory found (no Vespertide models).", + "drifts": [], + }); + } + let mut drifts = Vec::new(); + let mut columns_checked = 0usize; + for models_dir in model_dirs { + let Some(vespertide_root) = models_dir.parent() else { + continue; + }; + let routes_dir = vespertide_root.join("src").join("routes"); + let route_sources = collect_rust_sources(&routes_dir, 6) + .iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .collect::>(); + if route_sources.is_empty() { + drifts.push(json!({ + "kind": "no-routes-dir", + "message": format!( + "No route files under {}, so entity-route correspondence cannot be checked.", + display_path(&routes_dir) + ), + "confidence": "low", + })); + continue; + } + for model_file in json_files_in(models_dir) { + let Ok(source) = std::fs::read_to_string(&model_file) else { + continue; + }; + let Ok(model) = serde_json::from_str::(&source) else { + continue; + }; + let Some(table) = model.get("name").and_then(Value::as_str) else { + continue; + }; + for column in model + .get("columns") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(column_name) = column.get("name").and_then(Value::as_str) else { + continue; + }; + columns_checked += 1; + let pascal = snake_to_pascal(column_name); + let mentioned = route_sources + .iter() + .any(|source| source.contains(column_name) || source.contains(&pascal)); + if !mentioned { + drifts.push(json!({ + "table": table, + "column": column_name, + "kind": "column-never-referenced-in-routes", + "message": format!( + "No route referencing {table}.{column_name} was found. It may be an intentionally internal-only column." + ), + "confidence": "low", + })); + } + } + } + } + let _ = root; // reserved for future cross-app route roots; kept explicit rather than unused + json!({ + "checked": true, + "columnsChecked": columns_checked, + "drifts": drifts, + }) +} + +fn snake_to_pascal(input: &str) -> String { + input + .split('_') + .filter(|segment| !segment.is_empty()) + .map(|segment| { + let mut chars = segment.chars(); + match chars.next() { + Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), + None => String::new(), + } + }) + .collect() +} + +fn collect_rust_sources(dir: &Path, max_depth: usize) -> Vec { + find_files_by_extension(dir, "rs", max_depth) +} + +fn find_files_by_extension(dir: &Path, extension: &str, max_depth: usize) -> Vec { + let mut found = Vec::new(); + if !dir.is_dir() { + return found; + } + let mut queue = vec![(dir.to_path_buf(), 0usize)]; + const SKIP: &[&str] = &["node_modules", "target", "dist", "build", ".git", ".next"]; + while let Some((current, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_file() + && path.extension().and_then(|ext| ext.to_str()) == Some(extension) + { + found.push(path); + } else if file_type.is_dir() && depth < max_depth && !SKIP.contains(&name.as_ref()) { + queue.push((path, depth + 1)); + } + } + } + found.sort(); + found +} + +// --------------------------------------------------------------------- +// route-openapi: #[vespera::route(...)] handlers vs openapi.json paths +// --------------------------------------------------------------------- + +/// Scans every `.rs` file under each `src/routes/` tree found in the +/// project for `#[vespera::route( [, path = "..."])]` attributes, +/// derives each handler's URL from Vespera's documented file-structure +/// convention (`src/routes/users.rs` -> `/users`, `src/routes/admin/mod.rs` +/// -> `/admin`, `path = "/{id}"` appended), and compares the resulting +/// `(METHOD, path)` set against `openapi.json`'s `paths`. Attribute +/// extraction is a bracket-balanced text scan for the macro call, not a +/// real Rust/proc-macro parse, so multi-app merges +/// (`vespera::export_app!`/`merge = [...]`) and non-standard route-macro +/// formatting can produce false positives — `confidence: "medium"`. +fn route_openapi_layer(root: &Path) -> Value { + let routes_dirs = find_dirs_named(root, "routes", 5) + .into_iter() + .filter(|dir| dir.join("mod.rs").is_file() || !collect_rust_sources(dir, 0).is_empty()) + .collect::>(); + let openapi_files = find_files_named(root, "openapi.json", 4); + if routes_dirs.is_empty() && openapi_files.is_empty() { + return json!({ + "checked": false, + "reason": "Found neither src/routes/ nor openapi.json.", + "drifts": [], + }); + } + + let mut code_routes = BTreeSet::<(String, String)>::new(); + for routes_dir in &routes_dirs { + for file in collect_rust_sources(routes_dir, 6) { + let Ok(source) = std::fs::read_to_string(&file) else { + continue; + }; + let Ok(relative) = file.strip_prefix(routes_dir) else { + continue; + }; + let prefix = route_url_prefix(relative); + for (method, path_attr) in extract_vespera_route_attributes(&source) { + let url = join_route_url(&prefix, path_attr.as_deref()); + code_routes.insert((method.to_ascii_uppercase(), url)); + } + } + } + + let mut spec_routes = BTreeSet::<(String, String)>::new(); + let mut specs_checked = Vec::new(); + for file in &openapi_files { + let Ok(source) = std::fs::read_to_string(file) else { + continue; + }; + let Ok(spec) = serde_json::from_str::(&source) else { + continue; + }; + specs_checked.push(display_path(file)); + for (method, path) in extract_openapi_path_methods(&spec) { + spec_routes.insert((method, path)); + } + } + + if routes_dirs.is_empty() { + return json!({ + "checked": false, + "reason": "No src/routes/ found, so the code-side routes cannot be checked.", + "openapiSpecsFound": specs_checked, + "drifts": [], + }); + } + if openapi_files.is_empty() { + return json!({ + "checked": false, + "reason": "No openapi.json found, so there is no spec to compare against.", + "codeRoutesFound": code_routes.len(), + "drifts": [], + }); + } + + let stale_spec = code_routes + .difference(&spec_routes) + .map(|(method, path)| json!({ "method": method, "path": path })) + .collect::>(); + let stale_code_or_merged = spec_routes + .difference(&code_routes) + .map(|(method, path)| json!({ "method": method, "path": path })) + .collect::>(); + + let mut drifts = Vec::new(); + if !stale_spec.is_empty() { + drifts.push(json!({ + "kind": "route-missing-from-openapi", + "message": "A route present in the code is missing from openapi.json. The spec may be stale (rebuild needed).", + "routes": stale_spec, + "confidence": "medium", + })); + } + if !stale_code_or_merged.is_empty() { + drifts.push(json!({ + "kind": "openapi-path-not-found-in-scanned-routes", + "message": "A path in openapi.json was not found in the scanned route files. It may come from a merged sub-app, or the scan may have missed a non-standard route macro form.", + "routes": stale_code_or_merged, + "confidence": "low", + })); + } + + json!({ + "checked": true, + "codeRouteCount": code_routes.len(), + "openapiRouteCount": spec_routes.len(), + "openapiSpecsFound": specs_checked, + "drifts": drifts, + }) +} + +/// Extracts `(method, path_attribute)` pairs from every +/// `#[vespera::route(...)]` (or `#[route(...)]` when `vespera::route` is +/// imported directly) attribute in `source`, matched to the very next +/// `pub async fn` per Vespera's "route handlers MUST be `pub async fn`" +/// requirement — attributes not immediately followed by one are ignored. +fn extract_vespera_route_attributes(source: &str) -> Vec<(String, Option)> { + let mut results = Vec::new(); + let mut search_from = 0usize; + while let Some(relative) = source[search_from..].find("route(") { + let start = search_from + relative; + // Require this `route(` to be a `#[...route(` attribute, not an + // unrelated identifier ending in `route`. `start` points at the + // `r` of `route(`, so the text immediately preceding it is either + // `::` (`#[vespera::route(`) or `[`/whitespace (`#[route(`). + let before = source[..start].trim_end(); + if !before.ends_with("::") && !before.ends_with('[') { + search_from = start + "route(".len(); + continue; + } + let Some(open_paren) = source[start..].find('(') else { + break; + }; + let args_start = start + open_paren + 1; + let mut depth = 1i32; + let mut args_end = args_start; + for (offset, character) in source[args_start..].char_indices() { + match character { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + args_end = args_start + offset; + break; + } + } + _ => {} + } + } + let args = &source[args_start..args_end]; + search_from = args_end + 1; + let Some(method) = args + .split(',') + .next() + .map(str::trim) + .filter(|token| !token.is_empty()) + else { + continue; + }; + // Only accept the method token if it looks like a bare identifier + // (get/post/put/patch/delete), not `path = "..."` appearing first + // in an unusual ordering. + if !method.chars().all(|c| c.is_ascii_alphabetic()) { + continue; + } + let path_attr = extract_quoted_value_after(args, "path"); + // Confirm the next non-attribute, non-blank line is `pub async fn` + // per Vespera's handler requirement; otherwise this `route(...)` is + // not a real handler attribute (e.g. inside a doc example string). + let after = &source[search_from..]; + let next_code = after.lines().map(str::trim).find(|line| { + !line.is_empty() + && !line.starts_with('#') + && !line.starts_with("///") + // Skip the attribute macro's own closing bracket(s), e.g. a + // lone `]` left on its own line after `route(...)`'s `)`. + && !line.chars().all(|c| matches!(c, ']' | ')' | ',')) + }); + if next_code.is_some_and(|line| line.starts_with("pub async fn")) { + results.push((method.to_owned(), path_attr)); + } + } + results +} + +/// Finds ` = ""` inside `source` and returns ``. +fn extract_quoted_value_after(source: &str, key: &str) -> Option { + let index = source.find(key)?; + let rest = &source[index + key.len()..]; + let equals = rest.find('=')?; + let rest = &rest[equals + 1..]; + let first_quote = rest.find('"')?; + let rest = &rest[first_quote + 1..]; + let second_quote = rest.find('"')?; + Some(rest[..second_quote].to_owned()) +} + +/// Vespera's file-structure-to-URL convention: `users.rs` -> `/users`, +/// `mod.rs` (at any nesting) -> the directory path itself, `admin/stats.rs` +/// -> `/admin/stats`. Root `mod.rs` maps to the empty prefix. +fn route_url_prefix(relative_path: &Path) -> String { + let mut components = relative_path + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>(); + if let Some(last) = components.last_mut() { + if last == "mod.rs" { + components.pop(); + } else if let Some(stripped) = last.strip_suffix(".rs") { + *last = stripped.to_owned(); + } + } + if components.is_empty() { + String::new() + } else { + format!("/{}", components.join("/")) + } +} + +fn join_route_url(prefix: &str, path_attr: Option<&str>) -> String { + match path_attr { + Some(path) if !path.is_empty() => format!("{prefix}{path}"), + _ if prefix.is_empty() => "/".to_owned(), + _ => prefix.to_owned(), + } +} + +fn extract_openapi_path_methods(spec: &Value) -> Vec<(String, String)> { + const METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"]; + let mut results = Vec::new(); + if let Some(paths) = spec.get("paths").and_then(Value::as_object) { + for (path, methods) in paths { + let Some(methods) = methods.as_object() else { + continue; + }; + for method in METHODS { + if methods.contains_key(*method) { + results.push((method.to_ascii_uppercase(), path.clone())); + } + } + } + } + results +} + +// --------------------------------------------------------------------- +// openapi-client: does the frontend call endpoints the spec has? +// --------------------------------------------------------------------- + +/// Scans `.ts`/`.tsx` files (skipping generated `df/` client output and +/// the usual dependency directories) for `@devup-api/fetch`-style calls — +/// `api.get('operationIdOrPath', ...)`, `queryClient.useQuery('get', +/// 'operationIdOrPath', ...)`, `useMutation('post', 'operationIdOrPath', +/// ...)` — and checks whether each referenced identifier exists as an +/// `operationId` or raw path template in any discovered `openapi.json`. +/// String-literal extraction is done by scanning for the call-site +/// substrings and reading the following quoted literal, not a TS parser, +/// so template-built identifiers, re-exported wrapper functions, and +/// destructured/aliased `api` bindings will not be detected — +/// `confidence: "low"`. +fn openapi_client_layer(root: &Path) -> Value { + let ts_files = find_frontend_sources(root, 6); + let openapi_files = find_files_named(root, "openapi.json", 4); + if ts_files.is_empty() { + return json!({ + "checked": false, + "reason": "No frontend .ts/.tsx files found.", + "drifts": [], + }); + } + if openapi_files.is_empty() { + return json!({ + "checked": false, + "reason": "No openapi.json found, so frontend calls cannot be verified.", + "drifts": [], + }); + } + + let mut known_identifiers = BTreeSet::::new(); + for file in &openapi_files { + let Ok(source) = std::fs::read_to_string(file) else { + continue; + }; + let Ok(spec) = serde_json::from_str::(&source) else { + continue; + }; + if let Some(paths) = spec.get("paths").and_then(Value::as_object) { + for (path, methods) in paths { + known_identifiers.insert(path.clone()); + if let Some(methods) = methods.as_object() { + for operation in methods.values() { + if let Some(operation_id) = + operation.get("operationId").and_then(Value::as_str) + { + known_identifiers.insert(operation_id.to_owned()); + } + } + } + } + } + } + + let mut drifts = Vec::new(); + let mut calls_checked = 0usize; + for file in &ts_files { + let Ok(source) = std::fs::read_to_string(file) else { + continue; + }; + for (call_site, identifier) in extract_devup_api_calls(&source) { + calls_checked += 1; + if !known_identifiers.contains(&identifier) { + drifts.push(json!({ + "kind": "client-call-not-in-openapi", + "file": relative_or_absolute(root, file), + "callSite": call_site, + "identifier": identifier, + "message": "The endpoint/operationId the frontend calls was not found in openapi.json.", + "confidence": "low", + })); + } + } + } + + json!({ + "checked": true, + "filesScanned": ts_files.len(), + "callsChecked": calls_checked, + "knownIdentifierCount": known_identifiers.len(), + "drifts": drifts, + }) +} + +fn relative_or_absolute(root: &Path, file: &Path) -> String { + file.strip_prefix(root) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| display_path(file)) +} + +fn find_frontend_sources(root: &Path, max_depth: usize) -> Vec { + const SKIP: &[&str] = &[ + "node_modules", + "dist", + "build", + ".git", + ".next", + ".turbo", + "df", + "target", + ]; + let mut found = Vec::new(); + let mut queue = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_file() { + let is_ts = matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("ts") | Some("tsx") + ); + if is_ts && !name.ends_with(".d.ts") { + found.push(path); + } + } else if file_type.is_dir() && depth < max_depth && !SKIP.contains(&name.as_ref()) { + queue.push((path, depth + 1)); + } + } + } + found.sort(); + found +} + +const DEVUP_API_CALL_SITES: &[&str] = &[ + "api.get(", + "api.post(", + "api.put(", + "api.patch(", + "api.delete(", +]; +const DEVUP_API_HOOK_SITES: &[&str] = &[ + "useQuery(", + "useMutation(", + "useSuspenseQuery(", + "useInfiniteQuery(", +]; + +/// Returns `(call_site_label, referenced_identifier)` pairs found in +/// `source`. +fn extract_devup_api_calls(source: &str) -> Vec<(String, String)> { + let mut results = Vec::new(); + for call_site in DEVUP_API_CALL_SITES { + let mut search_from = 0usize; + while let Some(relative) = source[search_from..].find(call_site) { + let start = search_from + relative + call_site.len(); + if let Some(identifier) = read_next_string_literal(&source[start..]) { + results.push(((*call_site).to_owned(), identifier)); + } + search_from = start; + } + } + for call_site in DEVUP_API_HOOK_SITES { + let mut search_from = 0usize; + while let Some(relative) = source[search_from..].find(call_site) { + let start = search_from + relative + call_site.len(); + let tail = &source[start..]; + // First literal is the HTTP method ('get'/'post'/...); the + // identifier we care about is the second. + if let Some(after_method) = skip_past_string_literal(tail) + && let Some(identifier) = read_next_string_literal(after_method) + { + results.push(((*call_site).to_owned(), identifier)); + } + search_from = start; + } + } + results +} + +fn read_next_string_literal(text: &str) -> Option { + let mut chars = text.char_indices().peekable(); + let (start, quote) = loop { + let (index, character) = chars.next()?; + match character { + '\'' | '"' => break (index, character), + // Bail out if we hit something that isn't whitespace, a comma, + // or an opening paren before finding a string — this argument + // position isn't a plain string literal (e.g. a variable). + character if character.is_whitespace() || character == ',' => continue, + _ => return None, + } + }; + let rest = &text[start + 1..]; + let end = rest.find(quote)?; + Some(rest[..end].to_owned()) +} + +fn skip_past_string_literal(text: &str) -> Option<&str> { + let mut chars = text.char_indices().peekable(); + let (start, quote) = loop { + let (index, character) = chars.next()?; + match character { + '\'' | '"' => break (index, character), + character if character.is_whitespace() || character == ',' => continue, + _ => return None, + } + }; + let rest = &text[start + 1..]; + let end = rest.find(quote)?; + Some(&rest[end + 1..]) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct ScopedTempDir(PathBuf); + + impl ScopedTempDir { + fn new(label: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "devup-mcp-stackdiff-test-{label}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create scoped temp dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for ScopedTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn extracts_model_struct_fields_ignoring_derive_attributes() { + let source = r##" + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] + #[sea_orm(table_name = "user")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub email: String, + pub name: String, + pub avatar_url: Option, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + "##; + let fields = extract_model_struct_fields(source); + assert_eq!( + fields, + BTreeSet::from([ + "id".to_owned(), + "email".to_owned(), + "name".to_owned(), + "avatar_url".to_owned(), + ]) + ); + } + + #[test] + fn route_url_prefix_matches_vespera_file_structure_convention() { + assert_eq!(route_url_prefix(Path::new("mod.rs")), ""); + assert_eq!(route_url_prefix(Path::new("users.rs")), "/users"); + assert_eq!(route_url_prefix(Path::new("admin/mod.rs")), "/admin"); + assert_eq!( + route_url_prefix(Path::new("admin/stats.rs")), + "/admin/stats" + ); + } + + #[test] + fn extracts_vespera_route_attributes_and_matches_path() { + let source = r##" + #[vespera::route(get, path = "/{id}", tags = ["users"])] + pub async fn get_user(Path(id): Path) -> Json { todo!() } + + #[vespera::route(post, tags = ["users"])] + pub async fn create_user() -> Json { todo!() } + "##; + let routes = extract_vespera_route_attributes(source); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].0, "get"); + assert_eq!(routes[0].1.as_deref(), Some("/{id}")); + assert_eq!(routes[1].0, "post"); + assert_eq!(routes[1].1, None); + } + + #[test] + fn extracts_devup_api_client_calls() { + let source = r##" + const user = await api.get('getUser', { params: { id: '1' } }) + await api.put('/users/{id}', { params: { id: '1' } }) + queryClient.useQuery('get', '/users/{id}', { params: { id: userId } }) + "##; + let calls = extract_devup_api_calls(source); + let identifiers = calls.iter().map(|(_, id)| id.as_str()).collect::>(); + assert!(identifiers.contains(&"getUser")); + assert!(identifiers.contains(&"/users/{id}")); + } + + #[tokio::test] + async fn db_entity_layer_flags_missing_entity_field() { + let temp = ScopedTempDir::new("db-entity"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let api_root = temp.path().join("apis").join("api"); + let models_dir = api_root.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("user.json"), + r##"{ "name": "user", "columns": [ + { "name": "id", "type": "uuid", "nullable": false }, + { "name": "phone_number", "type": "text", "nullable": true } + ] }"##, + ) + .unwrap(); + let entity_dir = api_root.join("src").join("models"); + std::fs::create_dir_all(&entity_dir).unwrap(); + std::fs::write( + entity_dir.join("user.rs"), + r##" + pub struct Model { + pub id: Uuid, + } + "##, + ) + .unwrap(); + + let result = run( + Some(&temp.path().to_string_lossy()), + &["db-entity".to_owned()], + ) + .await + .unwrap(); + let drifts = result["layers"]["db-entity"]["drifts"].as_array().unwrap(); + assert!(!drifts.is_empty()); + let drift = &drifts[0]; + assert_eq!(drift["columnsMissingInEntity"][0], "phone_number"); + } + + #[tokio::test] + async fn route_openapi_layer_flags_route_missing_from_spec() { + let temp = ScopedTempDir::new("route-openapi"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let api_root = temp.path().join("apis").join("api"); + let routes_dir = api_root.join("src").join("routes"); + std::fs::create_dir_all(&routes_dir).unwrap(); + std::fs::write( + routes_dir.join("users.rs"), + r##" + #[vespera::route(get, path = "/{id}", tags = ["users"])] + pub async fn get_user() -> Json<()> { todo!() } + "##, + ) + .unwrap(); + std::fs::write(api_root.join("openapi.json"), r##"{ "paths": {} }"##).unwrap(); + + let result = run( + Some(&temp.path().to_string_lossy()), + &["route-openapi".to_owned()], + ) + .await + .unwrap(); + let layer = &result["layers"]["route-openapi"]; + assert_eq!(layer["checked"], true); + let drifts = layer["drifts"].as_array().unwrap(); + assert!( + drifts + .iter() + .any(|drift| drift["kind"] == "route-missing-from-openapi") + ); + } + + #[tokio::test] + async fn openapi_client_layer_flags_unknown_operation_id() { + let temp = ScopedTempDir::new("openapi-client"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::write( + temp.path().join("openapi.json"), + r##"{ "paths": { "/users": { "get": { "operationId": "getUsers" } } } }"##, + ) + .unwrap(); + let front = temp.path().join("apps").join("front").join("src"); + std::fs::create_dir_all(&front).unwrap(); + std::fs::write( + front.join("page.tsx"), + r##"const users = await api.get('getUsersThatDoesNotExist')"##, + ) + .unwrap(); + + let result = run( + Some(&temp.path().to_string_lossy()), + &["openapi-client".to_owned()], + ) + .await + .unwrap(); + let layer = &result["layers"]["openapi-client"]; + assert_eq!(layer["checked"], true); + let drifts = layer["drifts"].as_array().unwrap(); + assert!( + drifts + .iter() + .any(|drift| drift["identifier"] == "getUsersThatDoesNotExist") + ); + } + + #[tokio::test] + async fn missing_project_root_reports_guardrail() { + let temp = ScopedTempDir::new("stackdiff-no-root"); + let nested = temp.path().join("deep"); + std::fs::create_dir_all(&nested).unwrap(); + let result = run(Some(&nested.to_string_lossy()), &[]).await.unwrap(); + assert_eq!(result["found"], false); + assert_eq!(result["guardrail"]["action"], "stop-and-report"); + } + + #[tokio::test] + async fn invalid_layer_name_is_rejected() { + let temp = ScopedTempDir::new("stackdiff-bad-layer"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let error = run( + Some(&temp.path().to_string_lossy()), + &["bogus-layer".to_owned()], + ) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + } +} diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index e7735d88..4e3acf70 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -1,16 +1,23 @@ use rmcp::schemars::JsonSchema; -use schemars::{Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -/// `action` is `status`, `login`, `logout`, or `doctor`. `doctor` never -/// touches OAuth state; it measures which connection paths (direct OAuth, -/// local Dev Mode MCP, host handoff) are currently usable and returns -/// client-specific setup guidance. See `server::diagnostics`. +/// `action` is `status`, `login`, `logout`, `configure`, or `doctor`. +/// `doctor` never touches OAuth state; it measures which connection paths +/// (direct OAuth, host handoff) are currently usable +/// and returns client-specific setup guidance. `configure` persists a +/// pre-registered client credential (`clientId`, optional `clientSecret`) +/// so later `login` calls skip Dynamic Client Registration entirely; the +/// secret is stored in the OS credential store and never echoed back. See +/// `server::diagnostics`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct AuthInput { pub action: String, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub client_secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -57,6 +64,10 @@ pub struct FigmaExportInput { #[serde(default)] pub artifact_id: Option, #[serde(default = "default_outputs")] + #[schemars(extend("items" = serde_json::json!({ + "type": "string", + "enum": super::validation::EXPORT_OUTPUTS, + })))] pub outputs: Vec, #[serde(default)] pub component_name: Option, @@ -68,6 +79,20 @@ pub struct FigmaExportInput { pub scope: String, #[serde(default = "default_root_layout")] pub root_layout: String, + /// Name every asset after the node it came from rather than after its + /// layer, so two drawings a designer named alike get a file each, and a + /// picture drawn at three widths gets one per width at that width's own + /// size. + /// + /// On by default. Named after the layer, as the plugin names them, one + /// file serves every node sharing that name: eight nodes on the notice + /// screen claimed one file holding five different drawings, and a + /// photograph drawn at three widths kept whichever width was exported + /// last, so at the other two it was the wrong size for its box. Set it + /// false for the plugin's own naming, which the code generator still uses + /// by default when driven as a library. + #[serde(default = "default_true")] + pub asset_names_per_node: bool, #[serde(default)] pub strict: bool, #[serde(default)] @@ -89,6 +114,7 @@ pub struct FigmaExportInput { pub struct FigmaAssetRequestInput { pub asset_id: String, #[serde(default = "default_asset_format")] + #[schemars(extend("enum" = ["png", "jpg", "svg", "pdf"]))] pub format: String, #[serde(default = "default_asset_scale")] pub scale: u8, @@ -96,41 +122,6 @@ pub struct FigmaAssetRequestInput { pub output_path: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContinueInput { - pub session_id: String, - pub call_id: String, - // The verbatim result of a host-executed official Figma MCP read call. - // Its shape is dictated by that upstream tool (text, image content - // blocks, nested objects, ...), so the runtime type must stay - // `serde_json::Value` and accept anything. - // - // schemars' blanket `JsonSchema` impl for `Value` maps this to the - // JSON Schema 2020-12 boolean schema `true` ("accept anything"). That - // is spec-legal, but several MCP clients' schema converters assume - // every `properties` entry is a JSON object and reject a boolean value - // outright, which discards the *entire* `tools/list` response, not - // just this tool. `any_json_value_schema` overrides the generated - // schema to keep the identical "accept any JSON" semantics while - // expressing it as the empty object schema `{}`, which every JSON - // Schema 2020-12 consumer can parse. - // - // NOTE: intentionally a plain `//` comment, not `///`: a doc comment - // here would be captured by schemars as this field's schema - // "description" and shipped over the wire on every tools/list call. - #[schemars(schema_with = "any_json_value_schema")] - pub result: serde_json::Value, -} - -// See `ContinueInput::result` above for why this exists instead of relying -// on `serde_json::Value`'s default (boolean) schema. Plain comment for the -// same reason: schemars would otherwise turn `///` into this function's -// stand-in schema "description". -fn any_json_value_schema(_generator: &mut SchemaGenerator) -> Schema { - json_schema!({}) -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct FigmaSearchInput { @@ -160,6 +151,49 @@ pub struct FigmaExploreInput { pub refresh: bool, } +/// `scope` is `theme` (project `devup.json` tokens), `api` (project +/// `openapi.json` endpoints/schemas), `db` (Vespertide `models/*.json` +/// tables/columns), or `all`. Reads whichever target file(s) actually +/// exist on disk at call time — never cached across calls, never inferred +/// when missing. See `server::project_context`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProjectContextInput { + pub scope: String, + #[serde(default)] + pub project_root: Option, + #[serde(default)] + pub filter: Option, +} + +/// Validates devup-ui TSX against the rules in `server::project_context`'s +/// sibling module `ui_validate` (crate `devup-mcp-devup-ui`): unknown +/// `$token` references, hardcoded colors/lengths with an existing token, +/// unknown props on known primitives, and non-literal values inside +/// `css`/`globalCss`/`keyframes` calls. `strict: true` additionally fails +/// `ok` on warning-severity violations. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UiValidateInput { + pub tsx: String, + #[serde(default)] + pub project_root: Option, + #[serde(default)] + pub strict: bool, +} + +/// `layers` selects which cross-layer drift checks to run +/// (`db-entity`, `entity-route`, `route-openapi`, `openapi-client`); +/// omitted or empty runs all four. See `server::stack_diff`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StackDiffInput { + #[serde(default)] + pub project_root: Option, + #[serde(default)] + pub layers: Vec, +} + fn default_scope() -> String { "node".to_owned() } diff --git a/crates/devup-mcp/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index 48eeb0d5..3b1b52ac 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -10,6 +10,23 @@ use super::{ tools::FigmaAssetRequestInput, }; +/// The export outputs this server understands. +/// +/// The JSON schema for `outputs` advertises this same constant, so a caller +/// can discover the set instead of learning it one rejection at a time, and +/// the published schema cannot drift from what is actually accepted. +pub(crate) const EXPORT_OUTPUTS: [&str; 9] = [ + "tsx", + "componentTsx", + "responsiveTsx", + "devupJson", + "rawSnapshot", + "rawPayload", + "sourceMap", + "assetManifest", + "referencePng", +]; + pub(super) fn validate_artifact_projection( artifact: &ArtifactLookup, outputs: &[String], @@ -21,7 +38,14 @@ pub(super) fn validate_artifact_projection( let design_output_requested = outputs.iter().any(|output| { matches!( output.as_str(), - "tsx" | "rawSnapshot" | "sourceMap" | "assetManifest" | "referencePng" + "tsx" + | "componentTsx" + | "responsiveTsx" + | "rawSnapshot" + | "rawPayload" + | "sourceMap" + | "assetManifest" + | "referencePng" ) }); let theme_requested = outputs.iter().any(|output| output == "devupJson"); @@ -55,7 +79,7 @@ pub(super) fn validate_artifact_projection( Err(DevupError::with_details( ErrorCode::DevupFigmaHandoffInvalid, - "artifact capture capability가 요청한 export 범위를 충족하지 않습니다.", + "The artifact capture capability does not cover the requested export scope.", false, json!({ "capabilities": capabilities, @@ -80,18 +104,18 @@ pub(super) fn validate_outputs(outputs: &[String]) -> Result<(), DevupError> { if outputs.is_empty() { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "outputs는 하나 이상이어야 합니다.", + "outputs must contain at least one entry.", false, )); } for output in outputs { - if !matches!( - output.as_str(), - "tsx" | "devupJson" | "rawSnapshot" | "sourceMap" | "assetManifest" | "referencePng" - ) { + if !EXPORT_OUTPUTS.contains(&output.as_str()) { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("지원하지 않는 export output입니다: {output}"), + format!( + "Unsupported export output: {output}. Supported: {}.", + EXPORT_OUTPUTS.join(", ") + ), false, )); } @@ -103,10 +127,10 @@ pub(super) fn parse_source_policy(policy: &str) -> Result Ok(SourcePolicy::Auto), "direct" => Ok(SourcePolicy::Direct), - "host" => Ok(SourcePolicy::Host), + _ => Err(DevupError::new( - ErrorCode::DevupFigmaHostRequired, - "sourcePolicy는 auto, direct 또는 host여야 합니다.", + ErrorCode::DevupInvalidInput, + "sourcePolicy must be auto or direct.", false, )), } @@ -124,7 +148,7 @@ pub(super) fn parse_asset_requests( if requests.len() > 16 { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "한 번에 export할 asset은 16개 이하여야 합니다.", + "At most 16 assets can be exported at once.", false, )); } @@ -139,7 +163,7 @@ pub(super) fn parse_asset_requests( { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "assetRequests의 ID, scale 또는 중복 값이 올바르지 않습니다.", + "An assetRequests ID, scale, or duplicate entry is invalid.", false, )); } @@ -151,7 +175,7 @@ pub(super) fn parse_asset_requests( _ => { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "asset format은 png, jpg, svg 또는 pdf여야 합니다.", + "asset format must be png, jpg, svg, or pdf.", false, )); } @@ -175,7 +199,7 @@ pub(super) fn parse_collection_scope(scope: &str) -> Result Ok(CollectionScope::File), _ => Err(DevupError::new( ErrorCode::DevupThemeConflict, - "scope는 node, page 또는 file이어야 합니다.", + "scope must be node, page, or file.", false, )), } @@ -187,7 +211,7 @@ pub(super) fn parse_root_layout(root_layout: &str) -> Result Ok(RootLayout::Embedded), _ => Err(DevupError::new( ErrorCode::DevupThemeConflict, - "rootLayout은 standalone 또는 embedded여야 합니다.", + "rootLayout must be standalone or embedded.", false, )), } diff --git a/crates/devup-mcp/tests/artifact_cache.rs b/crates/devup-mcp/tests/artifact_cache.rs index 5336686e..1f9b9e6d 100644 --- a/crates/devup-mcp/tests/artifact_cache.rs +++ b/crates/devup-mcp/tests/artifact_cache.rs @@ -77,6 +77,7 @@ fn payload(file_key: &str, node_id: &str, marker: &str) -> CollectedPayload { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), } } diff --git a/crates/devup-mcp/tests/call_cache_resume.rs b/crates/devup-mcp/tests/call_cache_resume.rs new file mode 100644 index 00000000..3c6257c0 --- /dev/null +++ b/crates/devup-mcp/tests/call_cache_resume.rs @@ -0,0 +1,306 @@ +//! A refused collection keeps the reads it already paid for. +//! +//! Collection is all-or-nothing: one refusal ends it and every read it had +//! already made is discarded. Against a metered allowance that is not merely +//! wasteful, it is unrecoverable — one page-height screen was measured at over +//! a hundred and ten reads against a seat allowed two hundred a day, so three +//! attempts on three days each start from nothing and each end in the same +//! place. The allowance spends down and the work never accumulates. +//! +//! What is watched here is that the second attempt does not buy what the first +//! one already has. + +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{ + AuthStatus, BuiltinScript, DevupError, ErrorCode, FigmaUpstream, ReadToolCall, UpstreamResult, +}; +use rmcp::{ServiceExt, model::CallToolRequestParams}; +use serde_json::{Map, Value, json}; + +struct ConnectedAuth; + +#[async_trait] +impl DevupAuth for ConnectedAuth { + async fn status(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +/// Answers the Section index and refuses everything after it, the way an +/// allowance that runs out partway through a collection does. The counters are +/// shared between attempts because the allowance is. +#[derive(Default)] +struct AllowanceRunsOut { + index_calls: AtomicUsize, + total_calls: AtomicUsize, +} + +#[async_trait] +impl FigmaUpstream for AllowanceRunsOut { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, call: ReadToolCall) -> Result { + self.total_calls.fetch_add(1, Ordering::SeqCst); + match call { + ReadToolCall::Snapshot { + script: BuiltinScript::SectionIndex, + .. + } => { + self.index_calls.fetch_add(1, Ordering::SeqCst); + Ok(section_index_result()) + } + _ => Err(DevupError::with_details( + ErrorCode::DevupFigmaRateLimited, + "You've reached the Figma MCP tool call limit for your Full seat on the Professional plan.", + true, + json!({"source": "direct"}), + )), + } + } +} + +async fn attempt(upstream: Arc, cache: PathBuf) -> anyhow::Result<()> { + let server = DevupServer::new(Services::with_call_cache_dir( + Arc::new(ConnectedAuth), + upstream, + Some(cache), + )); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "allScreens": true, + "outputs": ["rawSnapshot"], + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .expect("arguments object"); + + // It cannot succeed: the reads after the index are all refused. What the + // attempt returns is beside the point; what it spent is not. + let _ = client + .call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(arguments)) + .await; + + client.cancel().await?; + task.abort(); + Ok(()) +} + +fn scratch(label: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "devup-resume-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_nanos()) + .unwrap_or_default() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("scratch directory"); + path +} + +/// Paused, because a refusal now stands the pacer down for a whole window and +/// three attempts of that is three minutes of real waiting for nothing. +#[tokio::test(start_paused = true)] +async fn a_second_attempt_does_not_buy_what_the_first_one_banked() -> anyhow::Result<()> { + let cache = scratch("banked"); + let upstream = Arc::new(AllowanceRunsOut::default()); + + attempt(upstream.clone(), cache.clone()).await?; + let first_total = upstream.total_calls.swap(0, Ordering::SeqCst); + let first_index = upstream.index_calls.swap(0, Ordering::SeqCst); + + assert_eq!( + first_index, 1, + "the first attempt has to buy the index once" + ); + assert!( + std::fs::read_dir(&cache) + .expect("cache directory") + .flatten() + .any(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("json")), + "a read that succeeded must be banked" + ); + + attempt(upstream.clone(), cache.clone()).await?; + let second_total = upstream.total_calls.load(Ordering::SeqCst); + let second_index = upstream.index_calls.load(Ordering::SeqCst); + + assert_eq!( + second_index, 0, + "the index was already paid for, so it must be replayed rather than bought again" + ); + assert!( + second_total < first_total, + "the second attempt must spend less: {second_total} against {first_total}" + ); + + let _ = std::fs::remove_dir_all(&cache); + Ok(()) +} + +/// Refuses every call the way Figma actually does: as a *successful* MCP call +/// whose body says no. `AllowanceRunsOut` refuses with `Err`, which never +/// reaches the bank; this is the shape that did. +#[derive(Default)] +struct RefusesInBand; + +#[async_trait] +impl FigmaUpstream for RefusesInBand { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + Ok(UpstreamResult { + raw: json!({ + "content": [{"type": "text", "text": "You've reached the Figma MCP tool call limit for your Full seat on the Professional plan. Upgrade your seat or plan for more tool calls."}], + "isError": true + }), + }) + } +} + +/// A refusal is not an answer and must not be banked as one. +/// +/// One was. It arrived as a successful call carrying `isError`, the bank kept +/// it beside nineteen real reads, and from then on every attempt — twenty-eight +/// overnight, and one on a fresh token — replayed that refusal from disk in +/// under a second without reaching Figma. The diagnosis went looking for a +/// shared allowance. It was a cached no. +#[tokio::test(start_paused = true)] +async fn a_refusal_that_arrives_as_an_answer_is_not_banked() -> anyhow::Result<()> { + let cache = scratch("refusal"); + let server = DevupServer::new(Services::with_call_cache_dir( + Arc::new(ConnectedAuth), + Arc::new(RefusesInBand), + Some(cache.clone()), + )); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "allScreens": true, + "outputs": ["rawSnapshot"], + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .expect("arguments object"); + let _ = client + .call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(arguments)) + .await; + client.cancel().await?; + task.abort(); + + let banked = std::fs::read_dir(&cache) + .expect("cache directory") + .flatten() + .filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("json")) + .count(); + assert_eq!(banked, 0, "a refusal was banked as if it were an answer"); + + let _ = std::fs::remove_dir_all(&cache); + Ok(()) +} + +/// Without a directory the bank does not exist, and every attempt pays again. +#[tokio::test(start_paused = true)] +async fn nothing_is_banked_unless_a_directory_was_named() -> anyhow::Result<()> { + let upstream = Arc::new(AllowanceRunsOut::default()); + + let run = |upstream: Arc| async move { + let server = DevupServer::new(Services::with_call_cache_dir( + Arc::new(ConnectedAuth), + upstream, + None, + )); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "allScreens": true, + "outputs": ["rawSnapshot"], + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .expect("arguments object"); + let _ = client + .call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(arguments)) + .await; + client.cancel().await?; + task.abort(); + anyhow::Ok(()) + }; + + run(upstream.clone()).await?; + let first_index = upstream.index_calls.swap(0, Ordering::SeqCst); + run(upstream.clone()).await?; + let second_index = upstream.index_calls.load(Ordering::SeqCst); + + assert_eq!(first_index, 1, "the first attempt buys the index"); + assert_eq!( + second_index, 1, + "with no bank named, the second attempt buys it again" + ); + Ok(()) +} + +fn section_index_result() -> UpstreamResult { + UpstreamResult { + raw: json!({ + "fileKey": "FileKey123", "version": null, "rootIds": ["10:1"], + "nodes": [ + {"id": "10:1", "type": "SECTION", "fields": { + "name": "Proofread states", "parentId": "0:1", "childrenIds": ["10:2", "10:3"], + "visible": true, "projectionTruncated": false, + "absoluteBoundingBox": {"x": 0, "y": 0, "width": 1200, "height": 1000} + }, "extra": {}, "fieldErrors": {}}, + {"id": "10:2", "type": "FRAME", "fields": { + "name": "Second", "parentId": "10:1", "childrenIds": [], "visible": true, + "directChildCount": 0, "subtreeNodeCount": 1, "estimatedSerializedBytes": 1000, + "absoluteBoundingBox": {"x": 500, "y": 120, "width": 360, "height": 740} + }, "extra": {}, "fieldErrors": {}}, + {"id": "10:3", "type": "FRAME", "fields": { + "name": "First", "parentId": "10:1", "childrenIds": [], "visible": true, + "directChildCount": 0, "subtreeNodeCount": 1, "estimatedSerializedBytes": 1000, + "absoluteBoundingBox": {"x": 100, "y": 120, "width": 360, "height": 740} + }, "extra": {}, "fieldErrors": {}} + ], "diagnostics": [] + }), + } +} diff --git a/crates/devup-mcp/tests/cli.rs b/crates/devup-mcp/tests/cli.rs index 615bbf76..6fa09cbd 100644 --- a/crates/devup-mcp/tests/cli.rs +++ b/crates/devup-mcp/tests/cli.rs @@ -1,6 +1,6 @@ use std::{ffi::OsString, fs, process::Command}; -use devup_mcp::{CliAction, parse_cli_args}; +use devup_mcp::{CliAction, ClientCredentialSource, parse_cli_args, resolve_figma_direct_config}; #[path = "../build_identity.rs"] mod build_identity; @@ -148,5 +148,207 @@ fn no_arguments_use_the_startup_current_directory() -> anyhow::Result<()> { panic!("no arguments must start the server") }; assert_eq!(config.allowed_write_roots, vec![std::env::current_dir()?]); + assert_eq!(config.figma_client_id, None); + assert_eq!(config.figma_client_secret, None); + assert_eq!(config.figma_callback_port, None); + assert_eq!(config.figma_client_name, None); Ok(()) } + +#[test] +fn figma_client_credential_and_callback_port_flags_populate_server_config() -> anyhow::Result<()> { + let action = parse_cli_args([ + OsString::from("--figma-client-id"), + OsString::from("preregistered-client"), + OsString::from("--figma-client-secret"), + OsString::from("preregistered-secret"), + OsString::from("--figma-callback-port"), + OsString::from("19876"), + ])?; + let CliAction::Serve(config) = action else { + panic!("figma flags must start the server") + }; + assert_eq!( + config.figma_client_id.as_deref(), + Some("preregistered-client") + ); + assert_eq!( + config.figma_client_secret.as_deref(), + Some("preregistered-secret") + ); + assert_eq!(config.figma_callback_port, Some(19876)); + Ok(()) +} + +#[test] +fn figma_callback_port_rejects_missing_or_non_numeric_values() { + assert!(parse_cli_args([OsString::from("--figma-callback-port")]).is_err()); + assert!( + parse_cli_args([ + OsString::from("--figma-callback-port"), + OsString::from("not-a-port"), + ]) + .is_err() + ); + assert!( + parse_cli_args([ + OsString::from("--figma-callback-port"), + OsString::from("70000"), + ]) + .is_err(), + "70000 exceeds u16::MAX and must be rejected, not silently truncated" + ); +} + +#[test] +fn figma_client_id_and_secret_reject_missing_or_empty_values() { + assert!(parse_cli_args([OsString::from("--figma-client-id")]).is_err()); + assert!(parse_cli_args([OsString::from("--figma-client-secret")]).is_err()); + assert!(parse_cli_args([OsString::from("--figma-client-id"), OsString::from("")]).is_err()); + assert!(parse_cli_args([OsString::from("--figma-client-secret"), OsString::from("")]).is_err()); +} + +/// The DCR `client_name` is what Figma's catalog allowlist is matched +/// against, so it is configurable at launch. It is trimmed, and a blank +/// value is an error rather than a silently-sent empty identity. +#[test] +fn figma_client_name_flag_populates_server_config_and_rejects_blank_values() -> anyhow::Result<()> { + let action = parse_cli_args([ + OsString::from("--figma-client-name"), + OsString::from(" Acme Registered Client "), + ])?; + let CliAction::Serve(config) = action else { + panic!("--figma-client-name must start the server") + }; + assert_eq!( + config.figma_client_name.as_deref(), + Some("Acme Registered Client") + ); + + assert!(parse_cli_args([OsString::from("--figma-client-name")]).is_err()); + assert!(parse_cli_args([OsString::from("--figma-client-name"), OsString::from("")]).is_err()); + assert!( + parse_cli_args([OsString::from("--figma-client-name"), OsString::from(" ")]).is_err() + ); + Ok(()) +} + +#[test] +fn version_and_self_check_are_rejected_when_combined_with_figma_flags() { + // `--version`/`--self-check` must only win when they are the *sole* + // argument; combined with a figma flag they must not silently swallow + // the other flag and report a stale version/self-check instead of an + // error. + assert!( + parse_cli_args([ + OsString::from("--figma-client-id"), + OsString::from("preregistered-client"), + OsString::from("--self-check"), + ]) + .is_err() + ); + assert!( + parse_cli_args([ + OsString::from("--figma-client-id"), + OsString::from("preregistered-client"), + OsString::from("--version"), + ]) + .is_err() + ); +} + +#[test] +fn resolve_figma_direct_config_prioritizes_cli_arg_over_env() { + let resolved = resolve_figma_direct_config( + Some("cli-client".to_owned()), + Some("cli-secret".to_owned()), + Some(19876), + Some("Cli Client Name".to_owned()), + Some("env-client".to_owned()), + Some("env-secret".to_owned()), + Some("Env Client Name".to_owned()), + ); + assert_eq!(resolved.client_id.as_deref(), Some("cli-client")); + assert_eq!(resolved.client_secret.as_deref(), Some("cli-secret")); + assert_eq!(resolved.credential_source, ClientCredentialSource::CliArg); + assert_eq!(resolved.callback_port, Some(19876)); + assert_eq!(resolved.client_name.as_deref(), Some("Cli Client Name")); +} + +#[test] +fn resolve_figma_direct_config_falls_back_to_env_then_to_none() { + let env_only = resolve_figma_direct_config( + None, + None, + None, + None, + Some("env-client".to_owned()), + Some("env-secret".to_owned()), + Some("Env Client Name".to_owned()), + ); + assert_eq!(env_only.client_id.as_deref(), Some("env-client")); + assert_eq!(env_only.credential_source, ClientCredentialSource::Env); + assert_eq!(env_only.client_name.as_deref(), Some("Env Client Name")); + + let neither = resolve_figma_direct_config(None, None, None, None, None, None, None); + assert_eq!(neither.client_id, None); + assert_eq!(neither.client_secret, None); + assert_eq!(neither.credential_source, ClientCredentialSource::None); + assert_eq!(neither.client_name, None); + + // Callback port is independent of credential source: it always comes + // from the cli-arg value regardless of which credential source won. + let callback_port_only = + resolve_figma_direct_config(None, None, Some(19876), None, None, None, None); + assert_eq!(callback_port_only.callback_port, Some(19876)); + assert_eq!( + callback_port_only.credential_source, + ClientCredentialSource::None + ); +} + +/// The client name lives on the Dynamic Client Registration path, which a +/// pre-registered `client_id` skips outright — so it must resolve +/// independently of the credential pair, and be available even when no +/// credential is configured at all (exactly the case where DCR runs). +#[test] +fn resolve_figma_direct_config_resolves_client_name_independently_of_credentials() { + let name_without_credentials = resolve_figma_direct_config( + None, + None, + None, + Some("Acme Registered Client".to_owned()), + None, + None, + None, + ); + assert_eq!( + name_without_credentials.client_name.as_deref(), + Some("Acme Registered Client") + ); + assert_eq!(name_without_credentials.client_id, None); + assert_eq!( + name_without_credentials.credential_source, + ClientCredentialSource::None + ); + + // No cli-arg name: the env value carries even when the winning + // credential source is the cli arg. + let env_name_with_cli_credentials = resolve_figma_direct_config( + Some("cli-client".to_owned()), + None, + None, + None, + None, + None, + Some("Env Client Name".to_owned()), + ); + assert_eq!( + env_name_with_cli_credentials.client_name.as_deref(), + Some("Env Client Name") + ); + assert_eq!( + env_name_with_cli_credentials.credential_source, + ClientCredentialSource::CliArg + ); +} diff --git a/crates/devup-mcp/tests/composite_export.rs b/crates/devup-mcp/tests/composite_export.rs index 2d248836..571a668c 100644 --- a/crates/devup-mcp/tests/composite_export.rs +++ b/crates/devup-mcp/tests/composite_export.rs @@ -136,6 +136,10 @@ async fn reference_png_is_acquired_once_and_delivered_as_a_binary_resource() -> reference_png_base64() ); assert_eq!(acquired["cache"]["capabilities"]["referencePng"], true); + // No tsx was requested/produced by this export, so no deliverable + // marker should be attached — it must not claim a devup-ui-tsx exists + // when only a reference PNG was exported. + assert!(acquired.get("deliverable").is_none()); let artifact_id = acquired["cache"]["artifactId"].as_str().unwrap(); let delivered_result = call_result( @@ -223,7 +227,7 @@ async fn one_acquisition_projects_all_outputs_and_artifact_reuse_is_zero_call() "devup_figma_export", json!({ "url": url, - "outputs": ["tsx", "devupJson", "rawSnapshot", "sourceMap", "assetManifest"], + "outputs": ["tsx", "devupJson", "rawSnapshot", "rawPayload", "sourceMap", "assetManifest"], "scope": "node", "sourcePolicy": "direct", "includeDiagnostics": true @@ -232,18 +236,36 @@ async fn one_acquisition_projects_all_outputs_and_artifact_reuse_is_zero_call() .await?; assert_eq!(first["status"], "complete"); + // The raw payload is the whole collection the snapshot came out of: the + // same nodes, plus the variables the token names are read from, and + // never the reference PNG, which has an output of its own. + assert_eq!(first["rawPayload"]["snapshot"], first["rawSnapshot"]); + assert!(first["rawPayload"]["variables"].is_object()); + assert!(first["rawPayload"].get("referencePng").is_none()); assert_eq!(first["collection"]["figmaToolCalls"], 1); assert_eq!(first["cache"]["cacheHit"], false); assert!(first["cache"]["artifactId"].as_str().is_some()); assert!(first["tsx"].as_str().unwrap().contains("$primary")); + // devup_figma_export must carry the same unambiguous final-answer + // marker as devup_figma_to_ui when it actually produced a tsx output. + assert_eq!(first["deliverable"]["kind"], "devup-ui-tsx"); + assert_eq!(first["deliverable"]["isFinal"], true); + assert!(!first["deliverable"]["note"].as_str().unwrap().is_empty()); assert!(first["devupJson"].as_str().unwrap().contains("\"primary\"")); assert_eq!(first["rawSnapshot"]["roots"], json!(["1:2"])); assert_eq!(first["sourceMap"]["version"], 1); + // Both pictures the generated code points at: the child drawn from its + // own image fill, and the root's second fill, which the code paints as a + // background. A container is a layout box rather than an asset, but the + // picture on it still has to be listed or nothing could deliver it. assert_eq!( - first["assetManifest"]["assets"][0]["assetId"], - "1:2:fills:1" + asset_ids(&first["assetManifest"]), + vec!["1:2:fills:1", "1:3:fills:0"] + ); + assert_eq!( + asset_by_id(&first["assetManifest"], "1:3:fills:0")["status"], + "available" ); - assert_eq!(first["assetManifest"]["assets"][0]["status"], "available"); assert!(first["sourceMap"]["tsx"].as_array().is_some_and(|entries| { entries.iter().any(|entry| { entry["nodeId"] == "1:2" && entry["property"] == "fills" && entry["variableId"] == "v" @@ -403,18 +425,16 @@ async fn explicit_asset_request_exports_once_and_returns_validated_binary() -> a "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", "outputs": ["tsx", "assetManifest"], "sourcePolicy": "direct", - "assetRequests": [{"assetId":"1:2:fills:1","format":"png","scale":2}] + "assetRequests": [{"assetId":"1:3:fills:0","format":"png","scale":2}] }), ) .await?; assert_eq!(result["status"], "complete"); assert_eq!(result["collection"]["figmaToolCalls"], 2); - assert_eq!(result["assetManifest"]["assets"][0]["status"], "exported"); - assert_eq!( - result["assetManifest"]["assets"][0]["dataBase64"], - STANDARD.encode(b"synthetic-png") - ); + let exported = asset_by_id(&result["assetManifest"], "1:3:fills:0"); + assert_eq!(exported["status"], "exported"); + assert_eq!(exported["dataBase64"], STANDARD.encode(b"synthetic-png")); assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); client.cancel().await?; @@ -446,7 +466,7 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> "sourcePolicy": "direct", "delivery": "resource", "assetRequests": [{ - "assetId":"1:2:fills:1", + "assetId":"1:3:fills:0", "format":"png", "scale":2, "outputPath": output_path.to_string_lossy() @@ -467,7 +487,8 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> .unwrap(); let manifest_bytes = read_resource_bytes(&client, manifest_uri).await?; let manifest: Value = serde_json::from_slice(&manifest_bytes)?; - let resource = &manifest["assets"][0]["resource"]; + let exported = asset_by_id(&manifest, "1:3:fills:0").clone(); + let resource = &exported["resource"]; let asset_uri = resource["uri"].as_str().expect("asset resource URI"); assert_eq!(resource["mimeType"], "image/png"); assert_eq!(resource["byteLength"], b"synthetic-png".len()); @@ -475,7 +496,7 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> resource["sha256"], "294ad7145322ec19f8250cca8480a933f1ce8c9e2ad1038e7ae8930d55a6598a" ); - assert!(manifest["assets"][0].get("dataBase64").is_none()); + assert!(exported.get("dataBase64").is_none()); let resource_bytes = read_resource_bytes(&client, asset_uri).await?; assert_eq!(resource_bytes, b"synthetic-png"); @@ -518,7 +539,7 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> "sourcePolicy": "direct", "delivery": "resource", "assetRequests": [{ - "assetId":"1:2:fills:1", + "assetId":"1:3:fills:0", "format":"png", "scale":2, "outputPath": output_path.to_string_lossy() @@ -538,7 +559,7 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> .expect("reused asset manifest URI"); let reused_manifest: Value = serde_json::from_slice(&read_resource_bytes(&client, reused_manifest_uri).await?)?; - let reused_asset_uri = reused_manifest["assets"][0]["resource"]["uri"] + let reused_asset_uri = asset_by_id(&reused_manifest, "1:3:fills:0")["resource"]["uri"] .as_str() .expect("reused asset resource URI"); assert!( @@ -597,6 +618,27 @@ async fn read_resource_bytes( Ok(bytes) } +/// One asset of a manifest, by the id it is known under. The manifest lists +/// every picture the generated code points at, so a test that wants a +/// particular one asks for it by name rather than by where it happens to sit. +fn asset_by_id<'a>(manifest: &'a Value, asset_id: &str) -> &'a Value { + manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .find(|asset| asset["assetId"] == asset_id) + .unwrap_or_else(|| panic!("no asset {asset_id} in {:?}", asset_ids(manifest))) +} + +fn asset_ids(manifest: &Value) -> Vec { + manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .map(|asset| asset["assetId"].as_str().unwrap_or_default().to_owned()) + .collect() +} + fn unique_temp_dir(label: &str) -> anyhow::Result { let path = std::env::temp_dir().join(format!( "devup-mcp-{label}-{}-{}", @@ -624,18 +666,18 @@ async fn artifact_reuse_rejects_a_different_asset_format_or_scale() -> anyhow::R "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", "outputs": ["assetManifest"], "sourcePolicy": "direct", - "assetRequests": [{"assetId":"1:2:fills:1","format":"png","scale":2}] + "assetRequests": [{"assetId":"1:3:fills:0","format":"png","scale":2}] }), ) .await?; let artifact_id = acquired["cache"]["artifactId"].as_str().unwrap(); assert_eq!(acquired["cache"]["capabilities"]["assetCaptureCount"], 1); - assert!(!serde_json::to_string(&acquired["cache"]["capabilities"])?.contains("1:2:fills:1")); + assert!(!serde_json::to_string(&acquired["cache"]["capabilities"])?.contains("1:3:fills:0")); assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); for request in [ - json!({"assetId":"1:2:fills:1","format":"svg","scale":2}), - json!({"assetId":"1:2:fills:1","format":"png","scale":1}), + json!({"assetId":"1:3:fills:0","format":"svg","scale":2}), + json!({"assetId":"1:3:fills:0","format":"png","scale":1}), ] { let reused = client .call_tool( @@ -756,31 +798,46 @@ async fn strict_tsx_export_rejects_lossy_projection() -> anyhow::Result<()> { fn fast_envelope_result(partial: bool, lossy: bool) -> UpstreamResult { let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "rootId": "1:2"}, "snapshot": { "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], - "nodes": [{ - "id": "1:2", - "type": "FRAME", - "fields": { - "name": "Synthetic", - "childrenIds": [], - "layoutMode": "VERTICAL", - "width": 320, - "height": 240, - "fills": [{ - "type": "SOLID", - "color": {"r": 0, "g": 0.4, "b": 1, "a": 1}, - "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "v"}} - }, {"type":"IMAGE","imageHash":"image-hash-123","scaleMode":"FILL"}], - "boundVariables": {"fills": [{"type": "VARIABLE_ALIAS", "id": "v"}]} + "nodes": [ + { + "id": "1:2", + "type": "FRAME", + "fields": { + "name": "Synthetic", + "childrenIds": ["1:3"], + "layoutMode": "VERTICAL", + "width": 320, + "height": 240, + "fills": [{ + "type": "SOLID", + "color": {"r": 0, "g": 0.4, "b": 1, "a": 1}, + "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "v"}} + }, {"type":"IMAGE","imageHash":"image-hash-123","scaleMode":"FILL"}], + "boundVariables": {"fills": [{"type": "VARIABLE_ALIAS", "id": "v"}]} + }, + "extra": {}, + "fieldErrors": {} }, - "extra": {}, - "fieldErrors": {} - }], + { + "id": "1:3", + "type": "RECTANGLE", + "fields": { + "name": "Synthetic asset", + "parentId": "1:2", + "isAsset": true, + "fills": [{"type":"IMAGE","imageHash":"image-hash-123","scaleMode":"FILL"}] + }, + "extra": {}, + "fieldErrors": {} + } + ], "diagnostics": [] }, "resources": { @@ -802,7 +859,7 @@ fn fast_envelope_result(partial: bool, lossy: bool) -> UpstreamResult { "unresolved": [] }, "integrity": { - "nodeCount": 1, + "nodeCount": 2, "variableRefCount": 1, "styleRefCount": 0, "utf8Bytes": 0 @@ -826,36 +883,14 @@ fn fast_envelope_result(partial: bool, lossy: bool) -> UpstreamResult { } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; + let _ = envelope_bytes; - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut payload = Vec::with_capacity(envelope_bytes.len() + 8); - payload.extend_from_slice(&0_u32.to_be_bytes()); - payload.extend_from_slice(&1_u32.to_be_bytes()); - payload.extend_from_slice(&envelope_bytes); - push_png_chunk(&mut png, b"duVp", &payload); - push_png_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_png_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "1:2", - "nodeCount": 1, - "variableRefCount": 1, - "styleRefCount": 0, - "utf8Bytes": envelope_bytes.len(), - "chunkCount": 1 - }); + // No binary transport exists any more: fast snapshots are always plain + // text (`devupFastSnapshotEnvelope`). Omitting the cursor marker node is + // treated by the decoder as a single, already-complete page. UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } @@ -883,24 +918,3 @@ fn asset_export_result( fn reference_png_base64() -> &'static str { "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" } - -fn push_png_chunk(output: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { - output.extend_from_slice(&(data.len() as u32).to_be_bytes()); - output.extend_from_slice(chunk_type); - output.extend_from_slice(data); - let mut crc_input = Vec::with_capacity(4 + data.len()); - crc_input.extend_from_slice(chunk_type); - crc_input.extend_from_slice(data); - output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); -} - -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} diff --git a/crates/devup-mcp/tests/downstream_integration.rs b/crates/devup-mcp/tests/downstream_integration.rs index 3a6e0ba6..f975e97d 100644 --- a/crates/devup-mcp/tests/downstream_integration.rs +++ b/crates/devup-mcp/tests/downstream_integration.rs @@ -258,17 +258,25 @@ impl DevupAuth for LoginAuth { } } +/// Converting says what it needs rather than reaching for the browser on its +/// own. A tool that logs a user in as a side effect of asking for code decides +/// something they did not ask it to decide, and the request that provoked it is +/// gone by the time they see the window. #[tokio::test] -async fn conversion_returns_host_handoff_without_starting_oauth() -> anyhow::Result<()> { +async fn conversion_asks_to_be_logged_in_rather_than_starting_oauth() -> anyhow::Result<()> { let auth = Arc::new(LoginAuth::default()); - let output = call_tool_with_auth( + let error = call_tool_with_auth( auth.clone(), "devup_figma_to_ui", json!({"url": "https://figma.com/design/85CgSws3o5XsLv7aAwWJyS/Name?node-id=3879-35481"}), ) - .await?; + .await + .expect_err("a disconnected direct path cannot convert"); - assert_eq!(output["status"], "needs_figma"); + assert!( + error.to_string().contains("devup_figma_auth login"), + "the error should name the action that fixes it: {error}" + ); assert_eq!(auth.logins.load(Ordering::SeqCst), 0); Ok(()) } diff --git a/crates/devup-mcp/tests/figma_doctor.rs b/crates/devup-mcp/tests/figma_doctor.rs index 7b8b6c0e..2942d08d 100644 --- a/crates/devup-mcp/tests/figma_doctor.rs +++ b/crates/devup-mcp/tests/figma_doctor.rs @@ -12,13 +12,15 @@ use std::sync::{ use async_trait::async_trait; use devup_mcp::server::{DevupAuth, DevupServer, Services}; use devup_mcp_figma::{ - AuthStatus, DevupError, ErrorCode, FigmaUpstream, ReadToolCall, UpstreamResult, + AuthStatus, ClientCredentialSource, DEFAULT_CLIENT_NAME, DevupError, DirectPathSnapshot, + ErrorCode, FigmaUpstream, ReadToolCall, TokenState, UpstreamResult, }; use rmcp::{ ServiceExt, model::{CallToolRequestParams, CallToolResult}, }; use serde_json::{Map, Value, json}; +use tokio::sync::Mutex; struct AuthProbe { status: AuthStatus, @@ -39,6 +41,45 @@ impl DevupAuth for AuthProbe { } } +/// A `DevupAuth` double that overrides `direct_path_snapshot` and +/// `configure_client_credentials`, unlike the plain `AuthProbe` above +/// which relies on the trait's default implementations. Used to verify +/// the server plumbing actually calls through to these methods and +/// surfaces their result verbatim, rather than the default fallback. +struct RichAuthProbe { + status: AuthStatus, + snapshot: DirectPathSnapshot, + configured: Mutex)>>, +} + +#[async_trait] +impl DevupAuth for RichAuthProbe { + async fn status(&self) -> Result { + Ok(self.status) + } + + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } + + async fn direct_path_snapshot(&self) -> Result { + Ok(self.snapshot.clone()) + } + + async fn configure_client_credentials( + &self, + client_id: String, + client_secret: Option, + ) -> Result<(), DevupError> { + *self.configured.lock().await = Some((client_id, client_secret)); + Ok(()) + } +} + #[derive(Default)] struct UnavailableUpstream { calls: AtomicUsize, @@ -98,29 +139,33 @@ async fn doctor_action_reports_measured_paths_and_client_setup_data() -> anyhow: assert_eq!(output["status"], "disconnected"); assert_eq!(output["paths"]["direct"]["available"], false); assert!(output["paths"]["direct"]["reason"].is_string()); - assert_eq!( - output["paths"]["localDevMode"]["endpoint"], - "http://127.0.0.1:3845/mcp" - ); - assert!(output["paths"]["localDevMode"]["reachable"].is_boolean()); - assert_eq!(output["paths"]["hostHandoff"]["expectedTool"], "use_figma"); let client_setup = &output["clientSetup"]; assert!(client_setup["constraints"]["clientNameAllowlist"].is_string()); assert!(client_setup["constraints"]["redirectUri"].is_string()); assert!(client_setup["constraints"]["callbackPortCaution"].is_string()); assert!(client_setup["constraints"]["personalAccessToken"].is_string()); - assert!(client_setup["opencode"]["example"]["mcp"]["figma"]["oauth"].is_object()); + // Codex is the primary, self-contained install path; the other hosts + // remain reachable but demoted under `otherHosts`. + assert_eq!(client_setup["codex"]["primary"], true); assert!( - client_setup["claudeCode"] + client_setup["codex"]["installDevupMcp"]["toml"] + .as_str() + .unwrap() + .contains("[mcp_servers.devup-mcp]") + ); + assert!( + client_setup["codex"]["officialFigmaMcp"] .as_str() .unwrap() .contains("figma") ); - assert!(client_setup["codex"].as_str().unwrap().contains("figma")); - assert_eq!( - client_setup["localDevMode"]["endpoint"], - "http://127.0.0.1:3845/mcp" + assert!(client_setup["otherHosts"]["opencode"]["example"]["mcp"]["figma"]["oauth"].is_object()); + assert!( + client_setup["otherHosts"]["claudeCode"] + .as_str() + .unwrap() + .contains("figma") ); // No actual credential material, ever. `clientSetup` legitimately @@ -166,75 +211,195 @@ async fn doctor_action_reflects_connected_status_without_changing_the_status_act Ok(()) } +/// The core deliverable of the handoff-completion fix: every `needs_figma` +/// step must carry `hostRequirement.resultContract` (so the agent submits +/// the right shape from the start) and `hostRequirement.outputExpectation` +/// (so it never falls back to hand-interpreting `use_figma`'s raw node +/// tree while waiting for devup-mcp's own TSX). See the real incident this +/// fixes in `crates/devup-mcp/src/server/handoff.rs`'s module docs. +/// A `DevupAuth` double that does not override `direct_path_snapshot` +/// (like `AuthProbe`) must still produce a shape-complete `doctor` +/// response via the trait's default implementation, so pre-existing +/// `DevupAuth` implementors outside this crate keep compiling *and* +/// keep working after this task's `credentialSource`/`tokenState`/ +/// `callbackPort` additions. #[tokio::test] -async fn needs_figma_always_carries_an_actionable_host_requirement() -> anyhow::Result<()> { - let result = call_named_tool( +async fn doctor_falls_back_to_default_direct_path_snapshot_for_plain_auth_doubles() +-> anyhow::Result<()> { + let output = call_named_tool( Arc::new(AuthProbe { status: AuthStatus::Disconnected, }), Arc::new(UnavailableUpstream::default()), - "devup_figma_to_ui", + "devup_figma_auth", + json!({ "action": "doctor" }), + ) + .await? + .structured_content + .unwrap(); + + assert_eq!(output["paths"]["direct"]["credentialSource"], "none"); + assert_eq!(output["paths"]["direct"]["tokenState"], "absent"); + assert!(output["paths"]["direct"]["callbackPort"]["port"].is_null()); + assert!(output["paths"]["direct"]["callbackPort"]["free"].is_null()); + + let connected = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Connected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "doctor" }), + ) + .await? + .structured_content + .unwrap(); + assert_eq!(connected["paths"]["direct"]["tokenState"], "valid"); + Ok(()) +} + +/// The core deliverable of this task's `doctor` update: `paths.direct` +/// must reflect the real, measured `credentialSource`/`tokenState`/ +/// `callbackPort` from a `DevupAuth` implementation that actually tracks +/// them (here `RichAuthProbe`, standing in for the real `OAuthManager`). +#[tokio::test] +async fn doctor_reports_measured_credential_source_token_state_and_callback_port() +-> anyhow::Result<()> { + let auth = RichAuthProbe { + status: AuthStatus::Disconnected, + snapshot: DirectPathSnapshot { + credential_source: ClientCredentialSource::CliArg, + token_state: TokenState::Expired, + callback_port: Some(19876), + callback_port_free: Some(false), + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }, + configured: Mutex::new(None), + }; + let output = call_named_tool( + Arc::new(auth), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "doctor" }), + ) + .await? + .structured_content + .unwrap(); + + assert_eq!(output["paths"]["direct"]["credentialSource"], "cli-arg"); + assert_eq!(output["paths"]["direct"]["tokenState"], "expired"); + assert_eq!(output["paths"]["direct"]["callbackPort"]["port"], 19876); + assert_eq!(output["paths"]["direct"]["callbackPort"]["free"], false); + Ok(()) +} + +/// `devup_figma_auth {"action":"configure"}` must persist the given +/// `clientId`/`clientSecret` via the auth backend, respond with only +/// `{"status":"configured"}` (never echoing the secret back), and reject +/// a missing `clientId` before ever calling the auth backend. +#[tokio::test] +async fn configure_action_persists_credentials_and_never_echoes_the_secret() -> anyhow::Result<()> { + let auth = Arc::new(RichAuthProbe { + status: AuthStatus::Disconnected, + snapshot: DirectPathSnapshot { + credential_source: ClientCredentialSource::None, + token_state: TokenState::Absent, + callback_port: None, + callback_port_free: None, + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }, + configured: Mutex::new(None), + }); + let result = call_named_tool( + auth.clone(), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "sourcePolicy": "auto" + "action": "configure", + "clientId": "preregistered-client", + "clientSecret": "preregistered-secret" }), ) .await?; let output = result.structured_content.unwrap(); + assert_eq!(output, json!({ "status": "configured" })); + let raw = output.to_string(); + assert!(!raw.contains("preregistered-secret")); - assert_eq!(output["status"], "needs_figma"); - let host_requirement = &output["hostRequirement"]; - assert!( - host_requirement["reason"] - .as_str() - .unwrap() - .contains("Figma") - ); - assert!(host_requirement["steps"].as_array().unwrap().len() >= 4); + let captured = auth.configured.lock().await.clone(); assert_eq!( - host_requirement["ifUnavailable"]["action"], - "stop-and-report" - ); - assert!( - host_requirement["ifUnavailable"]["message"] - .as_str() - .unwrap() - .contains("추측") - ); - assert!( - host_requirement["ifUnavailable"]["setupHint"] - .as_str() - .unwrap() - .contains("doctor") - ); - assert!(host_requirement["localDevMode"]["reachable"].is_boolean()); - assert_eq!( - host_requirement["localDevMode"]["endpoint"], - "http://127.0.0.1:3845/mcp" + captured, + Some(( + "preregistered-client".to_owned(), + Some("preregistered-secret".to_owned()) + )) ); Ok(()) } #[tokio::test] -async fn host_policy_needs_figma_also_carries_the_host_requirement() -> anyhow::Result<()> { - let result = call_named_tool( +async fn configure_action_without_client_id_is_rejected() -> anyhow::Result<()> { + let error = call_named_tool( Arc::new(AuthProbe { - status: AuthStatus::Connected, + status: AuthStatus::Disconnected, }), Arc::new(UnavailableUpstream::default()), - "devup_figma_to_ui", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "sourcePolicy": "host" + "devup_figma_auth", + json!({ "action": "configure" }), + ) + .await + .expect_err("configure without clientId must fail"); + assert!(error.to_string().contains("clientId")); + Ok(()) +} + +/// `DevupAuth` implementations that do not support persisting a client +/// credential (the trait's default `configure_client_credentials`) must +/// surface that as an explicit tool error, not silently succeed. +#[tokio::test] +async fn configure_action_fails_for_auth_backends_that_do_not_support_it() -> anyhow::Result<()> { + let error = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Disconnected, }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "configure", "clientId": "preregistered-client" }), ) - .await?; - let output = result.structured_content.unwrap(); + .await + .expect_err("plain AuthProbe does not support configure"); + assert!(!error.to_string().is_empty()); + Ok(()) +} - assert_eq!(output["status"], "needs_figma"); - assert_eq!( - output["hostRequirement"]["ifUnavailable"]["action"], - "stop-and-report" - ); +/// The Figma desktop app's local Dev Mode MCP serves six read tools and +/// `use_figma` is not among them, so every collection devup-mcp performs — +/// snapshot, explore, section index, theme — has no tool there to run. Its +/// tools also address whatever the desktop app currently has open rather than +/// a file key. It was reported as a third connection path and described as +/// usable without OAuth, and an agent that believed it spent its turn finding +/// out otherwise. Nothing devup-mcp says should name it. +#[tokio::test] +async fn nothing_offers_the_local_dev_mode_server_as_a_path() -> anyhow::Result<()> { + let doctor = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Disconnected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "doctor" }), + ) + .await? + .structured_content + .unwrap(); + for (label, value) in [("doctor", &doctor)] { + let rendered = serde_json::to_string(value)?; + for forbidden in ["localDevMode", "3845", "Dev Mode"] { + assert!( + !rendered.contains(forbidden), + "{label} still names the local Dev Mode server via {forbidden:?}" + ); + } + } Ok(()) } diff --git a/crates/devup-mcp/tests/figma_explore.rs b/crates/devup-mcp/tests/figma_explore.rs index 4c69b0bf..82cc59b0 100644 --- a/crates/devup-mcp/tests/figma_explore.rs +++ b/crates/devup-mcp/tests/figma_explore.rs @@ -94,17 +94,17 @@ fn projection() -> Value { }, { "id": "1:1", "type": "FRAME", - "fields": {"name": "[FR-026] 본연체", "parentId": "0:1", "childrenIds": [], "x": 0, "y": 0, "width": 1200, "height": 80, "childCount": 1, "textPreview": "본연체"}, + "fields": {"name": "[FR-026] Base Style", "parentId": "0:1", "childrenIds": [], "x": 0, "y": 0, "width": 1200, "height": 80, "childCount": 1, "textPreview": "Base Style"}, "extra": {}, "fieldErrors": {} }, { "id": "1:2", "type": "FRAME", - "fields": {"name": "A : STORY-F-PROOFREAD", "parentId": "0:1", "childrenIds": [], "x": 0, "y": 120, "width": 360, "height": 740, "childCount": 12, "textPreview": "이야기가 글로 정리되었어요"}, + "fields": {"name": "A : STORY-F-PROOFREAD", "parentId": "0:1", "childrenIds": [], "x": 0, "y": 120, "width": 360, "height": 740, "childCount": 12, "textPreview": "Your story has been written up"}, "extra": {}, "fieldErrors": {} }, { "id": "1:3", "type": "FRAME", - "fields": {"name": "A : STORY-F-PROOFREAD", "parentId": "0:1", "childrenIds": [], "x": 400, "y": 120, "width": 360, "height": 740, "childCount": 13, "textPreview": "공개 설정 나만 보기"}, + "fields": {"name": "A : STORY-F-PROOFREAD", "parentId": "0:1", "childrenIds": [], "x": 400, "y": 120, "width": 360, "height": 740, "childCount": 13, "textPreview": "Visibility: only me"}, "extra": {}, "fieldErrors": {} } ], @@ -273,200 +273,6 @@ async fn refresh_bypasses_an_exact_explore_cache_hit() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -async fn direct_and_host_explore_return_identical_candidate_data() -> anyhow::Result<()> { - let (client, task) = start_client(AuthStatus::Connected).await?; - let direct = client - .call_tool( - CallToolRequestParams::new("devup_figma_explore").with_arguments(input("direct")), - ) - .await? - .structured_content - .unwrap(); - let start = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(input("host"))) - .await? - .structured_content - .unwrap(); - assert_eq!(start["status"], "needs_figma"); - assert_eq!(start["calls"].as_array().unwrap().len(), 1); - assert_eq!(start["calls"][0]["tool"], "use_figma"); - let code = start["calls"][0]["arguments"]["code"].as_str().unwrap(); - assert!(code.contains("projectionTruncated")); - assert!(!code.contains("getVariableByIdAsync")); - - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": start["sessionId"], - "callId": start["calls"][0]["callId"], - "result": projection() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - - // Explore is an intentionally shallow spatial projection. Its candidate data is - // complete for the operation, while the preserved graph correctly reports that - // descendants represented by childCount were not included in the snapshot. - assert_eq!(direct["status"], "complete"); - assert_eq!(complete["status"], "complete"); - assert_eq!(direct["quality"]["acquisition"], "expected-projection"); - assert_eq!(complete["quality"]["acquisition"], "expected-projection"); - assert_eq!(direct["quality"]["projection"], "not-requested"); - assert!( - !direct["completenessReport"]["snapshot"]["childCountMismatches"] - .as_array() - .unwrap() - .is_empty() - ); - assert_eq!(direct["anchor"]["kind"], "heading"); - assert_eq!(direct["targetKind"], "other"); - assert_eq!(direct["count"], 2); - assert_eq!(direct["candidates"][0]["node"]["nodeId"], "1:2"); - for field in ["anchor", "group", "candidates", "truncated", "diagnostics"] { - assert_eq!(direct[field], complete[field], "source changed {field}"); - } - assert_eq!(direct["source"]["kind"], "direct"); - assert_eq!(complete["source"]["kind"], "host"); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn host_explore_accepts_the_public_string_result_contract() -> anyhow::Result<()> { - let (client, task) = start_client(AuthStatus::Connected).await?; - let start = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(input("host"))) - .await? - .structured_content - .unwrap(); - - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": start["sessionId"], - "callId": start["calls"][0]["callId"], - "result": projection().to_string() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - - assert_eq!(complete["status"], "complete"); - assert_eq!(complete["count"], 2); - assert_eq!(complete["source"]["kind"], "host"); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn completed_host_projection_serves_a_related_node_without_another_handoff() --> anyhow::Result<()> { - let (client, task) = start_client(AuthStatus::Connected).await?; - let start = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(input("host"))) - .await? - .structured_content - .unwrap(); - let completed = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": start["sessionId"], - "callId": start["calls"][0]["callId"], - "result": projection() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(completed["status"], "complete"); - - let mut related_input = input("host"); - related_input.insert( - "url".to_owned(), - json!("https://www.figma.com/design/FileKey123/Fixture?node-id=1-2"), - ); - let related = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(related_input)) - .await? - .structured_content - .unwrap(); - - assert_eq!(related["status"], "complete"); - assert_eq!(related["anchor"]["nodeId"], "1:2"); - assert_eq!(related["source"]["nodeId"], "1:2"); - assert_eq!(related["source"]["kind"], "artifact"); - assert_eq!(related["cache"]["cacheHit"], true); - assert_eq!(related["cache"]["reuseKind"], "related-node"); - assert_eq!(related["collection"]["figmaToolCalls"], 0); - assert_eq!(related["cache"]["originCollection"]["figmaToolCalls"], 1); - assert!(related.get("calls").is_none()); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn host_explore_unwraps_a_stringified_official_mcp_envelope() -> anyhow::Result<()> { - let (client, task) = start_client(AuthStatus::Connected).await?; - let start = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(input("host"))) - .await? - .structured_content - .unwrap(); - let official_result = json!({ - "content": [{"type": "text", "text": projection().to_string()}], - "isError": false - }); - - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": start["sessionId"], - "callId": start["calls"][0]["callId"], - "result": official_result.to_string() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - - assert_eq!(complete["status"], "complete"); - assert_eq!(complete["count"], 2); - - client.cancel().await?; - task.await??; - Ok(()) -} - #[tokio::test] async fn explore_rejects_missing_node_and_out_of_range_limit() -> anyhow::Result<()> { let (client, task) = start_client(AuthStatus::Connected).await?; diff --git a/crates/devup-mcp/tests/fixtures/ground-truth-project/devup.json b/crates/devup-mcp/tests/fixtures/ground-truth-project/devup.json new file mode 100644 index 00000000..d18d55ee --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/ground-truth-project/devup.json @@ -0,0 +1,25 @@ +{ + "theme": { + "colors": { + "default": { + "captionLight": "#8a8a8a", + "backgroundLight": "#fafafa", + "primaryColor": "#3366ff" + }, + "dark": { + "captionLight": "#cccccc", + "backgroundLight": "#111111", + "primaryColor": "#6699ff" + } + }, + "typography": { + "body1": { "fontSize": "14px", "lineHeight": "20px" } + }, + "length": { + "default": { "sm": "8px", "md": "16px", "lg": "24px" } + }, + "shadow": { + "default": { "card": "0 1px 2px rgba(0,0,0,0.1)" } + } + } +} diff --git a/crates/devup-mcp/tests/fixtures/ground-truth-project/models/message.json b/crates/devup-mcp/tests/fixtures/ground-truth-project/models/message.json new file mode 100644 index 00000000..7c3eedd4 --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/ground-truth-project/models/message.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "message", + "columns": [ + { "name": "id", "type": "uuid", "nullable": false, "primary_key": true }, + { "name": "body", "type": "text", "nullable": false }, + { "name": "author_id", "type": "integer", "nullable": false, "foreign_key": "user.id", "index": true }, + { + "name": "status", + "type": { "kind": "enum", "name": "message_status", "values": ["draft", "sent", "deleted"] }, + "nullable": false, + "default": "'draft'" + }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } + ] +} diff --git a/crates/devup-mcp/tests/fixtures/ground-truth-project/openapi.json b/crates/devup-mcp/tests/fixtures/ground-truth-project/openapi.json new file mode 100644 index 00000000..cb45e491 --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/ground-truth-project/openapi.json @@ -0,0 +1,28 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Fixture API", "version": "1.0.0" }, + "paths": { + "/messages": { + "get": { "operationId": "listMessages" }, + "post": { "operationId": "createMessage" } + }, + "/messages/{id}": { + "get": { "operationId": "getMessage" }, + "delete": { "operationId": "deleteMessage" } + } + }, + "components": { + "schemas": { + "Message": { + "type": "object", + "required": ["id", "body", "authorId"], + "properties": { + "id": { "type": "string" }, + "body": { "type": "string" }, + "authorId": { "type": "string" }, + "createdAt": { "type": "string" } + } + } + } + } +} diff --git a/crates/devup-mcp/tests/fixtures/ground-truth-project/package.json b/crates/devup-mcp/tests/fixtures/ground-truth-project/package.json new file mode 100644 index 00000000..8385e106 --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/ground-truth-project/package.json @@ -0,0 +1,4 @@ +{ + "name": "ground-truth-fixture-project", + "private": true +} diff --git a/crates/devup-mcp/tests/ground_truth_tools.rs b/crates/devup-mcp/tests/ground_truth_tools.rs new file mode 100644 index 00000000..dc168a59 --- /dev/null +++ b/crates/devup-mcp/tests/ground_truth_tools.rs @@ -0,0 +1,493 @@ +//! Integration tests for the three ground-truth tools +//! (`devup_project_context`, `devup_ui_validate`, `devup_stack_diff`) added +//! to prevent the exact failure documented in `README.md`'s brief: three +//! agents independently inventing a `$gray100` color token, a 16px bubble +//! radius, and a 36px avatar size that did not exist in the project's real +//! `devup.json`. +//! +//! These tools never call Figma, so the auth/upstream mocks here are +//! trivial stubs (unlike `source_orchestration.rs`'s fixtures, which +//! simulate real collection flows) — they exist only because `DevupServer` +//! requires a `Services` value to construct. + +use std::sync::Arc; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{AuthStatus, DevupError, FigmaUpstream, ReadToolCall, UpstreamResult}; +use rmcp::{ + ServiceExt, + model::{CallToolRequestParams, CallToolResult}, +}; +use serde_json::{Map, Value, json}; + +struct NeverCalledAuth; + +#[async_trait] +impl DevupAuth for NeverCalledAuth { + async fn status(&self) -> Result { + unreachable!("ground-truth tools never touch Figma auth") + } + + async fn login(&self) -> Result { + unreachable!("ground-truth tools never touch Figma auth") + } + + async fn logout(&self) -> Result { + unreachable!("ground-truth tools never touch Figma auth") + } +} + +struct NeverCalledUpstream; + +#[async_trait] +impl FigmaUpstream for NeverCalledUpstream { + async fn list_tools(&self) -> Result, DevupError> { + unreachable!("ground-truth tools never touch Figma upstream") + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + unreachable!("ground-truth tools never touch Figma upstream") + } +} + +async fn call_tool(tool: &str, arguments: Value) -> anyhow::Result { + let server = DevupServer::new(Services::new( + Arc::new(NeverCalledAuth), + Arc::new(NeverCalledUpstream), + )); + let (server_transport, client_transport) = tokio::io::duplex(64 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments: Map = arguments.as_object().cloned().unwrap_or_default(); + let result = client + .call_tool(CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments)) + .await?; + client.cancel().await?; + task.await??; + Ok(result) +} + +/// Absolute path to `tests/fixtures/ground-truth-project`, a minimal +/// synthetic project (not real girok-space data, per the brief's "저장소에 +/// 남기는 건 최소한의 합성 데이터로 하라") with a real `devup.json`, +/// `openapi.json`, and a Vespertide `models/message.json`. +fn fixture_project_root() -> String { + format!( + "{}/tests/fixtures/ground-truth-project", + env!("CARGO_MANIFEST_DIR") + ) +} + +// --------------------------------------------------------------------- +// devup_project_context +// --------------------------------------------------------------------- + +#[tokio::test] +async fn project_context_theme_scope_reads_exact_tokens_from_the_fixture_devup_json() +-> anyhow::Result<()> { + let result = call_tool( + "devup_project_context", + json!({ "scope": "theme", "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], true); + let file = &output["files"][0]; + assert_eq!( + file["colors"]["default"]["captionLight"], "#8a8a8a", + "must report the real fixture value, not an invented one: {output}" + ); + assert_eq!(file["colors"]["default"]["primaryColor"], "#3366ff"); + assert_eq!(file["length"]["default"]["md"], "16px"); + // The exact fabricated token from the brief's incident must NOT exist + // in this fixture's real devup.json. + assert!(file["colors"]["default"].get("gray100").is_none()); + assert!(file["colors"]["dark"].get("gray100").is_none()); + Ok(()) +} + +#[tokio::test] +async fn project_context_returns_stop_and_report_guardrail_when_devup_json_is_absent() +-> anyhow::Result<()> { + let empty_root = std::env::temp_dir().join(format!( + "devup-mcp-ground-truth-no-devup-json-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&empty_root)?; + std::fs::write(empty_root.join("package.json"), "{}")?; + + let result = call_tool( + "devup_project_context", + json!({ "scope": "theme", "projectRoot": empty_root.to_string_lossy() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], false); + assert_eq!(output["guardrail"]["action"], "stop-and-report"); + assert!( + output["guardrail"]["message"] + .as_str() + .unwrap() + .contains("guessing") + ); + assert!( + !output["guardrail"]["searchedPaths"] + .as_array() + .unwrap() + .is_empty() + ); + + std::fs::remove_dir_all(&empty_root)?; + Ok(()) +} + +#[tokio::test] +async fn project_context_missing_project_root_also_reports_stop_and_report_guardrail() +-> anyhow::Result<()> { + let orphan = std::env::temp_dir().join(format!( + "devup-mcp-ground-truth-orphan-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + // No package.json/devup.json/Cargo.toml/.git anywhere in this leaf. + std::fs::create_dir_all(&orphan)?; + let result = call_tool( + "devup_project_context", + json!({ "scope": "theme", "projectRoot": orphan.to_string_lossy() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], false); + assert_eq!(output["guardrail"]["action"], "stop-and-report"); + std::fs::remove_dir_all(&orphan)?; + Ok(()) +} + +#[tokio::test] +async fn project_context_api_scope_lists_real_endpoints_and_required_fields() -> anyhow::Result<()> +{ + let result = call_tool( + "devup_project_context", + json!({ "scope": "api", "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], true); + let spec = &output["specs"][0]; + let operation_ids = spec["endpoints"] + .as_array() + .unwrap() + .iter() + .filter_map(|endpoint| endpoint["operationId"].as_str()) + .collect::>(); + assert!(operation_ids.contains(&"listMessages")); + assert!(operation_ids.contains(&"getMessage")); + let message_schema = spec["schemas"] + .as_array() + .unwrap() + .iter() + .find(|schema| schema["name"] == "Message") + .expect("Message schema present"); + let required = message_schema["requiredFields"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect::>(); + assert!(required.contains(&"authorId")); + Ok(()) +} + +#[tokio::test] +async fn project_context_db_scope_lists_real_columns_and_enum_values() -> anyhow::Result<()> { + let result = call_tool( + "devup_project_context", + json!({ "scope": "db", "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], true); + let table = &output["tables"][0]; + assert_eq!(table["table"], "message"); + let column_names = table["columns"] + .as_array() + .unwrap() + .iter() + .map(|column| column["name"].as_str().unwrap()) + .collect::>(); + assert!(column_names.contains(&"author_id")); + assert!(column_names.contains(&"status")); + let enum_def = &table["enums"][0]; + assert_eq!(enum_def["values"][0], "draft"); + Ok(()) +} + +// --------------------------------------------------------------------- +// devup_ui_validate — the $gray100 regression case is the core deliverable +// --------------------------------------------------------------------- + +#[tokio::test] +async fn ui_validate_catches_the_exact_gray100_regression_from_the_incident() -> anyhow::Result<()> +{ + // This TSX is exactly the shape of the fabricated failure documented + // in the brief: an agent using a plausible-looking but nonexistent + // color token instead of one of the real tokens in devup.json. + let tsx = r##" + import { Box } from "@devup-ui/react"; + + export const ChatBubble = () => ( + + ); + "##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["themeAvailable"], true); + assert_eq!(output["ok"], false, "must fail: {output}"); + let violations = output["violations"].as_array().unwrap(); + let token_violation = violations + .iter() + .find(|violation| violation["rule"] == "unknown-token") + .expect("unknown-token violation for $gray100"); + assert_eq!(token_violation["severity"], "error"); + assert!( + token_violation["message"] + .as_str() + .unwrap() + .contains("gray100"), + "{token_violation}" + ); + // The tool must not silently accept the same input's hardcoded 16px + // radius either — devup.json has a real "md": "16px" length token. + assert!( + violations + .iter() + .any(|violation| violation["rule"] == "hardcoded-length"), + "{violations:?}" + ); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_accepts_tsx_using_only_real_project_tokens() -> anyhow::Result<()> { + let tsx = r##" + import { Box } from "@devup-ui/react"; + + export const ChatBubble = () => ( + + ); + "##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["ok"], true, "{output}"); + assert_eq!(output["checkedTokens"], 2); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_suggests_the_matching_real_token_for_a_hardcoded_hex_color() +-> anyhow::Result<()> { + let tsx = r##""##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + let violation = output["violations"] + .as_array() + .unwrap() + .iter() + .find(|violation| violation["rule"] == "hardcoded-color") + .expect("hardcoded-color violation"); + assert_eq!(violation["severity"], "warning"); + assert!( + violation["suggestion"] + .as_str() + .unwrap() + .contains("captionLight"), + "{violation}" + ); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_suggests_the_matching_real_token_for_a_hardcoded_px_length() +-> anyhow::Result<()> { + let tsx = r##""##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + let violation = output["violations"] + .as_array() + .unwrap() + .iter() + .find(|violation| violation["rule"] == "hardcoded-length") + .expect("hardcoded-length violation"); + assert!(violation["suggestion"].as_str().unwrap().contains("md")); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_catches_runtime_value_inside_css_call() -> anyhow::Result<()> { + let tsx = r##" + import { css } from "@devup-ui/react"; + const dynamicWidth = getWidth(); + const cls = css({ width: dynamicWidth }); + "##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["ok"], false); + assert!( + output["violations"] + .as_array() + .unwrap() + .iter() + .any(|violation| violation["rule"] == "runtime-value"), + "{output}" + ); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_does_not_flag_dynamic_jsx_props_as_runtime_value() -> anyhow::Result<()> { + // Verified against @devup-ui/react's own docs: `` + // compiles to a CSS custom property, it is not a runtime-value error. + let tsx = r##"export const X = ({ color }) => ;"##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert!( + output["violations"] + .as_array() + .unwrap() + .iter() + .all(|violation| violation["rule"] != "runtime-value"), + "{output}" + ); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_reports_missing_theme_without_crashing_and_skips_token_checks() +-> anyhow::Result<()> { + let empty_root = std::env::temp_dir().join(format!( + "devup-mcp-ground-truth-ui-validate-no-theme-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&empty_root)?; + std::fs::write(empty_root.join("package.json"), "{}")?; + + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": r##""##, "projectRoot": empty_root.to_string_lossy() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["themeAvailable"], false); + assert_eq!(output["themeGuardrail"]["action"], "stop-and-report"); + assert!( + output["violations"] + .as_array() + .unwrap() + .iter() + .all(|violation| violation["rule"] != "unknown-token"), + "without a theme, unknown-token must be skipped, not guessed at: {output}" + ); + + std::fs::remove_dir_all(&empty_root)?; + Ok(()) +} + +#[tokio::test] +async fn ui_validate_strict_mode_fails_on_warning_severity_violations() -> anyhow::Result<()> { + let tsx = r##""##; + let lenient = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root(), "strict": false }), + ) + .await? + .structured_content + .unwrap(); + let strict = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root(), "strict": true }), + ) + .await? + .structured_content + .unwrap(); + assert_eq!(lenient["ok"], true); + assert_eq!(strict["ok"], false); + Ok(()) +} + +// --------------------------------------------------------------------- +// devup_stack_diff +// --------------------------------------------------------------------- + +#[tokio::test] +async fn stack_diff_reports_every_requested_layer_with_explicit_confidence() -> anyhow::Result<()> { + let result = call_tool( + "devup_stack_diff", + json!({ "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], true); + for layer in [ + "db-entity", + "entity-route", + "route-openapi", + "openapi-client", + ] { + assert!( + output["layers"].get(layer).is_some(), + "missing layer {layer} in {output}" + ); + assert!( + output["layers"][layer].get("checked").is_some(), + "layer {layer} must report whether it could run" + ); + } + Ok(()) +} + +#[tokio::test] +async fn stack_diff_rejects_unknown_layer_names() -> anyhow::Result<()> { + let result = call_tool( + "devup_stack_diff", + json!({ "projectRoot": fixture_project_root(), "layers": ["not-a-real-layer"] }), + ) + .await; + assert!(result.is_err()); + Ok(()) +} diff --git a/crates/devup-mcp/tests/handoff.rs b/crates/devup-mcp/tests/handoff.rs deleted file mode 100644 index 278dd9d8..00000000 --- a/crates/devup-mcp/tests/handoff.rs +++ /dev/null @@ -1,430 +0,0 @@ -use std::{ - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::Duration, -}; - -use devup_mcp::server::handoff::{ - Clock, HandoffLimits, HandoffStep, HandoffStore, PendingOperation, -}; -use devup_mcp_figma::{ - CollectionRequest, CollectionScope, CollectorSession, ErrorCode, FigmaTarget, -}; -use serde_json::{Value, json}; - -#[derive(Debug, Default)] -struct FakeClock(AtomicU64); - -impl FakeClock { - fn advance(&self, seconds: u64) { - self.0.fetch_add(seconds, Ordering::SeqCst); - } -} - -impl Clock for FakeClock { - fn now_epoch_seconds(&self) -> u64 { - self.0.load(Ordering::SeqCst) - } -} - -fn collector() -> CollectorSession { - let target = - FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=1-2").unwrap(); - CollectorSession::new(CollectionRequest::new(target, CollectionScope::Node)) -} - -fn metadata_result() -> Value { - json!({ - "structuredContent": { - "devupMetadata": { - "fileKey": "FileKey123", - "version": "v1", - "rootId": "1:2", - "nodes": [{ - "id": "1:2", - "type": "FRAME", - "childrenIds": [], - "descendantCount": 1 - }] - } - } - }) -} - -fn snapshot_result() -> Value { - json!({ - "fileKey": "FileKey123", - "version": "v1", - "rootIds": ["1:2"], - "nodes": [{ - "id": "1:2", - "type": "FRAME", - "fields": {"name": "Synthetic", "childrenIds": []}, - "extra": {}, - "fieldErrors": {} - }] - }) -} - -fn limits() -> HandoffLimits { - HandoffLimits { - ttl: Duration::from_secs(600), - max_sessions: 8, - max_result_bytes: 1024, - max_total_bytes: 4096, - } -} - -#[tokio::test] -async fn expires_sessions_after_ten_minutes() { - let clock = Arc::new(FakeClock::default()); - let store = HandoffStore::with_clock(clock.clone(), limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - - clock.advance(601); - let error = store.next(&id).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffExpired); - assert_eq!(error.details["reason"], "expired"); -} - -#[tokio::test] -async fn expired_session_remains_distinguishable_after_pruning() { - let clock = Arc::new(FakeClock::default()); - let store = HandoffStore::with_clock(clock.clone(), limits()); - let expired_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - - clock.advance(601); - store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let error = store.next(&expired_id).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffExpired); - assert!(error.retryable); - assert_eq!(error.details["reason"], "expired"); -} - -#[tokio::test] -async fn enforces_session_and_payload_memory_limits() { - let clock = Arc::new(FakeClock::default()); - let store = HandoffStore::with_clock(clock, limits()); - for _ in 0..8 { - store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - } - let error = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); - - let strict = HandoffStore::with_limits(HandoffLimits { - max_result_bytes: 32, - max_total_bytes: 64, - ..limits() - }); - let id = strict - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = strict.next(&id).await.unwrap() else { - panic!() - }; - let error = strict - .accept(&id, &calls[0].call_id, json!({"large": "x".repeat(80)})) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); - let removed = strict.next(&id).await.unwrap_err(); - assert_eq!(removed.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[tokio::test] -async fn uses_opaque_ids_and_consumes_each_call_once() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - assert_eq!(id.len(), 43); - assert!( - id.bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - ); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].call_id.len(), 43); - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let replay = store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap_err(); - assert_eq!(replay.code, ErrorCode::DevupFigmaHandoffInvalid); - assert_eq!(replay.details["reason"], "consumed"); -} - -#[tokio::test] -async fn accepted_results_renew_the_lease_but_polling_does_not() { - let clock = Arc::new(FakeClock::default()); - let store = HandoffStore::with_clock(clock.clone(), limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { - calls, - expires_at_epoch_seconds, - .. - } = store.next(&id).await.unwrap() - else { - panic!() - }; - assert_eq!(expires_at_epoch_seconds, 600); - - clock.advance(590); - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { - expires_at_epoch_seconds, - .. - } = store.next(&id).await.unwrap() - else { - panic!() - }; - assert_eq!(expires_at_epoch_seconds, 1_190); - - clock.advance(590); - let HandoffStep::NeedsFigma { - expires_at_epoch_seconds, - .. - } = store.next(&id).await.unwrap() - else { - panic!() - }; - assert_eq!(expires_at_epoch_seconds, 1_190); - clock.advance(11); - assert_eq!( - store.next(&id).await.unwrap_err().code, - ErrorCode::DevupFigmaHandoffExpired - ); -} - -#[tokio::test] -async fn invalid_call_id_does_not_destroy_the_session() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - let error = store - .accept(&id, "unknown-call-id", metadata_result()) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); - - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); -} - -#[tokio::test] -async fn collector_rejection_keeps_the_call_pending_for_a_corrected_result() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - store - .accept(&id, &calls[0].call_id, json!({"malformed": true})) - .await - .unwrap_err(); - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); -} - -#[tokio::test] -async fn removes_the_session_after_collection_completes() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - store - .accept(&id, &calls[0].call_id, snapshot_result()) - .await - .unwrap(); - let HandoffStep::Complete { parts, operation } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(operation, PendingOperation::Collect); - assert_eq!(parts.snapshot_chunks.len(), 1); - - let removed = store.next(&id).await.unwrap_err(); - assert_eq!(removed.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[tokio::test] -async fn stringified_tool_results_are_normalized_at_the_handoff_boundary() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "get_metadata"); - store - .accept( - &id, - &calls[0].call_id, - Value::String(metadata_result().to_string()), - ) - .await - .unwrap(); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); - store - .accept( - &id, - &calls[0].call_id, - Value::String(snapshot_result().to_string()), - ) - .await - .unwrap(); - - let HandoffStep::Complete { parts, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(parts.snapshot_chunks.len(), 1); -} - -#[tokio::test] -async fn rejects_cross_session_calls_and_concurrent_replays() { - let store = HandoffStore::with_limits(limits()); - let first_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let second_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&first_id).await.unwrap() else { - panic!() - }; - let cross_session = store - .accept(&second_id, &calls[0].call_id, metadata_result()) - .await - .unwrap_err(); - assert_eq!(cross_session.code, ErrorCode::DevupFigmaHandoffInvalid); - - let call_id = calls[0].call_id.clone(); - let left = { - let store = store.clone(); - let session_id = first_id.clone(); - let call_id = call_id.clone(); - tokio::spawn(async move { store.accept(&session_id, &call_id, metadata_result()).await }) - }; - let right = { - let store = store.clone(); - let session_id = first_id.clone(); - tokio::spawn(async move { store.accept(&session_id, &call_id, metadata_result()).await }) - }; - let results = [left.await.unwrap(), right.await.unwrap()]; - assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); - assert_eq!( - results - .iter() - .filter_map(|result| result.as_ref().err()) - .next() - .unwrap() - .code, - ErrorCode::DevupFigmaHandoffInvalid - ); -} - -#[tokio::test] -async fn enforces_the_aggregate_limit_across_sessions() { - let payload = metadata_result(); - let encoded_len = serde_json::to_vec(&payload).unwrap().len(); - let store = HandoffStore::with_limits(HandoffLimits { - max_result_bytes: encoded_len + 1, - max_total_bytes: encoded_len * 2 - 1, - ..limits() - }); - let first_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let second_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls: first, .. } = store.next(&first_id).await.unwrap() else { - panic!() - }; - let HandoffStep::NeedsFigma { calls: second, .. } = store.next(&second_id).await.unwrap() - else { - panic!() - }; - store - .accept(&first_id, &first[0].call_id, payload.clone()) - .await - .unwrap(); - let error = store - .accept(&second_id, &second[0].call_id, payload) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); -} diff --git a/crates/devup-mcp/tests/output_policy.rs b/crates/devup-mcp/tests/output_policy.rs index 9d57ee4e..7586a6a7 100644 --- a/crates/devup-mcp/tests/output_policy.rs +++ b/crates/devup-mcp/tests/output_policy.rs @@ -1,11 +1,15 @@ use std::{ fs::{self, File, FileTimes}, - path::PathBuf, + path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; use devup_mcp::server::output::{OutputPolicy, OutputTransaction}; +/// Deliberately returns the spelling `std::env::temp_dir()` gives, symlinks and +/// all. On macOS that is under `/var/folders`, which resolves to +/// `/private/var/folders`, so configuring a policy from this exercises the case +/// where the configured root and the canonical root differ. fn unique_temp_dir(label: &str) -> anyhow::Result { let path = std::env::temp_dir().join(format!( "devup-mcp-{label}-{}-{}", @@ -16,6 +20,12 @@ fn unique_temp_dir(label: &str) -> anyhow::Result { Ok(path) } +/// Where the policy will actually report files, which is the canonical location +/// rather than the configured spelling. Assertions compare against this. +fn canonical(path: &Path) -> PathBuf { + dunce::canonicalize(path).expect("canonicalize an existing temp directory") +} + #[test] fn resolves_only_files_inside_preopened_roots() -> anyhow::Result<()> { let root = unique_temp_dir("allowed-root")?; @@ -25,11 +35,16 @@ fn resolves_only_files_inside_preopened_roots() -> anyhow::Result<()> { let relative = policy.resolve("nested/Component.tsx")?; assert_eq!( relative.display_path(), - root.join("nested").join("Component.tsx") + canonical(&root).join("nested").join("Component.tsx") ); + // Spelled exactly as the root was configured, which on macOS is not the + // canonical path. This must resolve, and must report the canonical one. let absolute_path = root.join("theme").join("devup.json"); let absolute = policy.resolve(absolute_path.to_str().unwrap())?; - assert_eq!(absolute.display_path(), absolute_path); + assert_eq!( + absolute.display_path(), + canonical(&root).join("theme").join("devup.json") + ); for invalid in [ "", @@ -53,6 +68,57 @@ fn resolves_only_files_inside_preopened_roots() -> anyhow::Result<()> { Ok(()) } +/// A root reached through a symlink is canonicalised when the policy opens it, +/// so the path devup-mcp reports back no longer shares a prefix with the one +/// the caller was given. Before this was handled, every such `outputPath` was +/// refused with "outputPath is outside the allowed root" — which on macOS is +/// not an edge case at all, since `/tmp` and `std::env::temp_dir()` both reach +/// their targets through `/var -> /private/var`. +/// +/// Asserted here with an explicit symlink so the guarantee holds on every +/// platform with symlinks, instead of only where the OS happens to provide one. +#[cfg(unix)] +#[test] +fn accepts_a_root_reached_through_a_symlink_in_either_spelling() -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + let real = unique_temp_dir("symlink-spelling")?; + let link = real.with_file_name(format!( + "{}-link", + real.file_name().unwrap().to_string_lossy() + )); + symlink(&real, &link)?; + + // Configured through the symlink, exactly as a client whose project path + // traverses one would. + let policy = OutputPolicy::from_roots(vec![link.clone()])?; + let expected = canonical(&real).join("Component.tsx"); + + let through_link = policy.resolve(link.join("Component.tsx").to_str().unwrap())?; + assert_eq!(through_link.display_path(), expected); + + // The resolved spelling must keep working too. + let through_real = policy.resolve(expected.to_str().unwrap())?; + assert_eq!(through_real.display_path(), expected); + + // Accepting both spellings must not accept an escape through either. + let outside = unique_temp_dir("symlink-spelling-outside")?; + assert!( + policy + .resolve(outside.join("escape.tsx").to_str().unwrap()) + .is_err() + ); + assert!(policy.resolve("../escape.tsx").is_err()); + + drop(through_link); + drop(through_real); + drop(policy); + fs::remove_file(&link)?; + fs::remove_dir_all(real)?; + fs::remove_dir_all(outside)?; + Ok(()) +} + #[cfg(unix)] #[test] fn rejects_a_symlink_parent_that_escapes_the_root() -> anyhow::Result<()> { @@ -117,10 +183,16 @@ fn commits_multiple_outputs_only_after_every_stage_succeeds() -> anyhow::Result< b"export const Component = 1;\n" ); assert_eq!(fs::read(root.join("theme/devup.json"))?, br#"{"theme":{}}"#); - assert_eq!(paths["tsx"], root.join("Component.tsx").to_string_lossy()); + assert_eq!( + paths["tsx"], + canonical(&root).join("Component.tsx").to_string_lossy() + ); assert_eq!( paths["devupJson"], - root.join("theme").join("devup.json").to_string_lossy() + canonical(&root) + .join("theme") + .join("devup.json") + .to_string_lossy() ); drop(policy); diff --git a/crates/devup-mcp/tests/rate_limit_patience.rs b/crates/devup-mcp/tests/rate_limit_patience.rs new file mode 100644 index 00000000..b7fd5333 --- /dev/null +++ b/crates/devup-mcp/tests/rate_limit_patience.rs @@ -0,0 +1,151 @@ +//! A collection is a burst: a Section spends five to seventeen calls back to +//! back and Figma meters by the minute, so a large enough target outruns its +//! own allowance partway through. That refusal used to end the collection and +//! return nothing, spending the allowance for no result at all. + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{ + AuthStatus, DevupError, ErrorCode, FigmaUpstream, ReadToolCall, UpstreamResult, +}; +use rmcp::{ + ServiceExt, + model::{CallToolRequestParams, CallToolResult}, +}; +use serde_json::{Map, Value, json}; + +struct ConnectedAuth; + +#[async_trait] +impl DevupAuth for ConnectedAuth { + async fn status(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +/// Refuses the first `refusals` calls the way a spent allowance does, then +/// answers. Counts every attempt so the test can tell a retry from a give-up. +struct SpentAllowance { + refusals: AtomicUsize, + attempts: AtomicUsize, + retry_after_seconds: Option, +} + +#[async_trait] +impl FigmaUpstream for SpentAllowance { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["get_metadata".to_owned(), "use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + self.attempts.fetch_add(1, Ordering::SeqCst); + if self + .refusals + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + left.checked_sub(1) + }) + .is_ok() + { + let mut details = json!({ "source": "direct" }); + if let Some(seconds) = self.retry_after_seconds { + details["retryAfterSeconds"] = json!(seconds); + } + return Err(DevupError::with_details( + ErrorCode::DevupFigmaRateLimited, + "Figma request rate limit reached.", + true, + details, + )); + } + Err(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "answered — the collection got past the allowance", + false, + )) + } +} + +async fn export(upstream: Arc) -> anyhow::Result { + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); + let (server_transport, client_transport) = tokio::io::duplex(64 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .unwrap(); + let result = client + .call_tool( + CallToolRequestParams::new("devup_figma_export".to_owned()).with_arguments(arguments), + ) + .await?; + client.cancel().await?; + task.await??; + Ok(result) +} + +/// The refusal asks to be waited out — it is marked retryable and often names +/// the seconds. Honouring that turns a lost collection into a slow one. +#[tokio::test(start_paused = true)] +async fn a_refused_call_is_waited_out_rather_than_ending_the_collection() -> anyhow::Result<()> { + let upstream = Arc::new(SpentAllowance { + refusals: AtomicUsize::new(2), + attempts: AtomicUsize::new(0), + retry_after_seconds: Some(30), + }); + + // Whatever the collection then reports is beside the point here; what is + // being watched is how many times the refusal was answered. + let _ = export(upstream.clone()).await; + + assert_eq!( + upstream.refusals.load(Ordering::SeqCst), + 0, + "both refusals should have been answered, not surrendered to" + ); + assert!( + upstream.attempts.load(Ordering::SeqCst) > 2, + "waiting out both refusals takes a third call, and the collection carries on from there" + ); + Ok(()) +} + +/// Bounded, because an allowance that is genuinely gone must be reported. Four +/// refusals outlast three attempts, and the fourth is never made. +#[tokio::test(start_paused = true)] +async fn an_allowance_that_stays_gone_is_reported_rather_than_waited_on_forever() +-> anyhow::Result<()> { + let upstream = Arc::new(SpentAllowance { + refusals: AtomicUsize::new(9), + attempts: AtomicUsize::new(0), + retry_after_seconds: None, + }); + + // Whatever the collection then reports is beside the point here; what is + // being watched is how many times the refusal was answered. + let _ = export(upstream.clone()).await; + + assert_eq!( + upstream.attempts.load(Ordering::SeqCst), + 3, + "three attempts and then the truth" + ); + Ok(()) +} diff --git a/crates/devup-mcp/tests/resource_delivery.rs b/crates/devup-mcp/tests/resource_delivery.rs index e4dfae54..b1eac5ae 100644 --- a/crates/devup-mcp/tests/resource_delivery.rs +++ b/crates/devup-mcp/tests/resource_delivery.rs @@ -241,7 +241,7 @@ async fn resource_protocol_lists_manifests_and_round_trips_chunks() -> anyhow::R payload(), ) .await?; - let original = "가나다".repeat(100_000).into_bytes(); + let original = "€€€".repeat(100_000).into_bytes(); let attached = store .attach_outputs( &artifact.artifact_id, @@ -479,5 +479,6 @@ fn payload() -> CollectedPayload { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), } } diff --git a/crates/devup-mcp/tests/responsive_export.rs b/crates/devup-mcp/tests/responsive_export.rs new file mode 100644 index 00000000..b57fd9f1 --- /dev/null +++ b/crates/devup-mcp/tests/responsive_export.rs @@ -0,0 +1,382 @@ +//! A screen captured with its other widths comes back as one tree. +//! +//! This drives the real export tool over a stubbed upstream, so it covers the +//! whole path: the fast snapshot envelope, the projection, and the module the +//! caller is handed. + +use std::sync::Arc; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{AuthStatus, DevupError, FigmaUpstream, ReadToolCall, UpstreamResult}; +use rmcp::{ServiceExt, model::CallToolRequestParams}; +use serde_json::{Value, json}; + +#[derive(Debug)] +struct ConnectedAuth; + +#[async_trait] +impl DevupAuth for ConnectedAuth { + async fn status(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +/// One frame of a screen: an outer stack padded on both sides, a card of two +/// lines inside it, and on the wider width an extra panel the narrow one does +/// not draw. +fn width(root: &str, name: &str, pixels: u32, padding: u32, gap: u32, aside: bool) -> Vec { + let card = format!("{root}:card"); + let panel = format!("{root}:aside"); + let lines = [format!("{root}:line1"), format!("{root}:line2")]; + let mut children = vec![card.clone()]; + if aside { + children.push(panel.clone()); + children.push(format!("{root}:note")); + } + let text = |id: &str, characters: &str| { + json!({ + "id": id, + "type": "TEXT", + "fields": { + "name": characters, + "childrenIds": [], + "characters": characters, + "width": 200, + "height": 24 + }, + "extra": {}, + "fieldErrors": {} + }) + }; + let mut nodes = vec![ + json!({ + "id": root, + "type": "FRAME", + "fields": { + "name": name, + "parentId": "0:section", + "parentType": "SECTION", + "parentName": "notice board", + "childrenIds": children, + "layoutMode": "VERTICAL", + "width": pixels, + "height": 800, + "inferredAutoLayout": { + "layoutMode": "VERTICAL", + "paddingTop": 0, + "paddingBottom": 0, + "paddingLeft": padding, + "paddingRight": padding, + "itemSpacing": 0 + } + }, + "extra": {}, + "fieldErrors": {} + }), + json!({ + "id": card, + "type": "FRAME", + "fields": { + "name": "card", + "childrenIds": lines, + "layoutMode": "VERTICAL", + "width": pixels - padding * 2, + "height": 200, + "inferredAutoLayout": {"layoutMode": "VERTICAL", "itemSpacing": gap} + }, + "extra": {}, + "fieldErrors": {} + }), + text(&lines[0], "first"), + text(&lines[1], "second"), + ]; + if aside { + // A row rather than a column, so its shape is nothing like the card's. + // Children are paired by shape where that is unambiguous, and two + // frames with the same shape would be told apart by name instead. + nodes.push(json!({ + "id": panel, + "type": "FRAME", + "fields": { + "name": "aside", + "childrenIds": [], + "layoutMode": "HORIZONTAL", + "width": 300, + "height": 120, + "inferredAutoLayout": {"layoutMode": "HORIZONTAL", "itemSpacing": 0} + }, + "extra": {}, + "fieldErrors": {} + })); + // No layout mode, so this one is a plain `Box`. It is here to pin what + // a hidden-then-shown node is given back: a `div` is `block`, and + // `display: initial` would make it `inline`. + nodes.push(json!({ + "id": format!("{root}:note"), + "type": "FRAME", + "fields": { + "name": "note", + "childrenIds": [], + "width": 300, + "height": 40 + }, + "extra": {}, + "fieldErrors": {} + })); + } + nodes +} + +#[derive(Debug)] +struct TwoWidths; + +#[async_trait] +impl FigmaUpstream for TwoWidths { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, call: ReadToolCall) -> Result { + match call { + ReadToolCall::Snapshot { .. } => { + let mut nodes = width("1:10", "mobile", 360, 16, 20, false); + nodes.extend(width("1:20", "desktop", 1920, 40, 30, true)); + let node_count = nodes.len(); + let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", + "schemaVersion": 1, + "source": {"fileKey": "FileKey123", "rootId": "1:20"}, + "snapshot": { + "fileKey": "FileKey123", + "version": "v1", + "rootIds": ["1:10", "1:20"], + "nodes": nodes, + "diagnostics": [] + }, + "resources": { + "collections": [], + "variables": [], + "styles": [], + "usedVariableIds": [], + "usedStyleIds": [], + "usedRemoteVariables": [], + "localComplete": true, + "usedRemoteComplete": true, + "unresolved": [] + }, + "integrity": { + "nodeCount": node_count, + "variableRefCount": 0, + "styleRefCount": 0, + "utf8Bytes": 0 + } + }); + // The envelope declares its own length, so it has to be + // serialised until the declaration matches. + loop { + let bytes = serde_json::to_vec(&envelope).unwrap_or_default().len(); + if envelope["integrity"]["utf8Bytes"] == bytes as u64 { + break; + } + envelope["integrity"]["utf8Bytes"] = Value::from(bytes); + } + Ok(UpstreamResult { + raw: json!({"content": [ + {"type": "text", "text": envelope.to_string()} + ]}), + }) + } + ReadToolCall::Metadata { .. } => Ok(UpstreamResult { + raw: json!({"content": [{"type": "text", "text": json!({ + "fileKey": "FileKey123", + "version": "v1", + "rootId": "1:20", + "nodes": [ + {"id": "1:10", "type": "FRAME", "name": "mobile", + "childrenIds": ["1:10:card"], "descendantCount": 1}, + {"id": "1:20", "type": "FRAME", "name": "desktop", + "childrenIds": ["1:20:card", "1:20:aside"], "descendantCount": 2} + ] + }).to_string()}]}), + }), + // Nothing in this screen is an asset or an image, so anything else + // the export asks for is answered empty rather than refused. + _ => Ok(UpstreamResult { + raw: json!({"content": []}), + }), + } + } +} + +async fn export(arguments: Value) -> anyhow::Result { + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), Arc::new(TwoWidths))); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments = arguments.as_object().cloned().unwrap_or_default(); + let result = client + .call_tool( + CallToolRequestParams::new("devup_figma_export".to_owned()).with_arguments(arguments), + ) + .await?; + let structured = result + .structured_content + .clone() + .ok_or_else(|| anyhow::anyhow!("no structured content"))?; + client.cancel().await?; + task.await??; + Ok(structured) +} + +#[tokio::test] +async fn a_screen_with_other_widths_is_exported_as_one_responsive_module() -> anyhow::Result<()> { + let result = export(json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-20", + "outputs": ["tsx"], + "sourcePolicy": "direct", + "componentName": "Notice" + })) + .await?; + + let written = result["responsiveTsx"] + .as_str() + .expect("a screen drawn at two widths has a responsive module"); + // Arrays are written one slot to a line; fold them so the expectations + // below can be read in one. + let module = folded(written); + + // 360 goes in the first slot and 1920 in the last, so a value that differs + // between them is written at both ends of the array. + assert_eq!( + result["responsiveSlots"].as_array().map(Vec::len), + Some(2), + "{module}" + ); + assert!( + module.contains(r#"px={["16px", null, null, null, "40px"]}"#), + "the outer padding should have merged:\n{module}" + ); + assert!( + module.contains(r#"gap={["20px", null, null, null, "30px"]}"#), + "the card's spacing should have merged:\n{module}" + ); + // The panel is drawn at one width only, so it is kept and toggled. + assert!( + module.contains(r#"display={["none", null, null, null, "flex"]}"#), + "the wide-only panel should be toggled:\n{module}" + ); + // And what a hidden node is given back is what the element is, not + // `initial` — for `display` that is `inline` however the element is drawn, + // so a `Box` would come back inline instead of block. + assert!( + module.contains(r#"display={["none", null, null, null, "block"]}"#), + "a wide-only Box should come back as a block:\n{module}" + ); + assert!( + !module.contains("initial"), + "display is never cleared to initial:\n{module}" + ); + + // It is a module, not a fragment: it imports what it uses and exports the + // screen. + assert!(module.starts_with("import { ")); + assert!(module.contains("} from '@devup-ui/react'")); + assert!(module.contains("export default function Notice() {")); + assert!(module.trim_end().ends_with('}')); + assert_eq!( + result["responsiveImports"], + json!(["Box", "Flex", "Text", "VStack"]), + "only the elements it actually uses" + ); + assert_eq!( + result["responsiveComponents"], + json!([]), + "this screen has no instances, so it imports no components of its own" + ); + Ok(()) +} + +/// It is an output in its own right, not something that only falls out of +/// asking for `tsx`. +#[tokio::test] +async fn the_responsive_module_can_be_asked_for_on_its_own() -> anyhow::Result<()> { + let result = export(json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-20", + "outputs": ["responsiveTsx"], + "sourcePolicy": "direct", + "componentName": "Notice" + })) + .await?; + assert!( + result["responsiveTsx"] + .as_str() + .is_some_and(|module| module.contains("export default function Notice() {")), + "{result:#?}" + ); + assert!( + result.get("tsx").is_none(), + "asking for one output should not produce the other" + ); + Ok(()) +} + +/// Without a name from the caller the page is named as the plugin names it: +/// after the Section the widths sit in, in PascalCase, with `Page` on the +/// end. +#[tokio::test] +async fn the_page_is_named_after_its_section_when_the_caller_gives_no_name() -> anyhow::Result<()> { + let result = export(json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-20", + "outputs": ["responsiveTsx"], + "sourcePolicy": "direct" + })) + .await?; + assert!( + result["responsiveTsx"] + .as_str() + .is_some_and(|module| module.contains("export default function NoticeBoardPage() {")), + "{result:#?}" + ); + Ok(()) +} + +/// A responsive array written one slot to a line, folded back onto one. +fn folded(tsx: &str) -> String { + let mut out = String::with_capacity(tsx.len()); + let mut items: Option> = None; + for line in tsx.lines() { + let trimmed = line.trim(); + match &mut items { + Some(collected) => { + if let Some(rest) = trimmed.strip_prefix("]}") { + out.push_str(&collected.join(", ")); + out.push_str("]}"); + out.push_str(rest); + out.push('\n'); + items = None; + } else { + collected.push(trimmed.trim_end_matches(',').to_owned()); + } + } + None => { + out.push_str(line); + if trimmed.ends_with("={[") { + items = Some(Vec::new()); + } else { + out.push('\n'); + } + } + } + } + out +} diff --git a/crates/devup-mcp/tests/section_export.rs b/crates/devup-mcp/tests/section_export.rs index 7c1c78f7..65e46e0b 100644 --- a/crates/devup-mcp/tests/section_export.rs +++ b/crates/devup-mcp/tests/section_export.rs @@ -4,7 +4,6 @@ use std::sync::{ }; use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose::STANDARD}; use devup_mcp::server::{DevupAuth, DevupServer, Services}; use devup_mcp_figma::{ AuthStatus, BuiltinScript, DevupError, ErrorCode, FigmaUpstream, ReadToolCall, UpstreamResult, @@ -99,6 +98,18 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .collect::>(), ["10:3", "10: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." + ); 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"); @@ -137,8 +148,8 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .any(|entry| entry["nodeId"] == "10:3" && entry["property"] == "type")) ); assert_eq!(selected["cache"]["cacheHit"], false); - assert_eq!(selected["collection"]["figmaToolCalls"], 1); - assert_eq!(upstream.0.load(Ordering::SeqCst), 2); + assert_eq!(selected["collection"]["figmaToolCalls"], 2); + assert_eq!(upstream.0.load(Ordering::SeqCst), 3); let selected_artifact_id = selected["cache"]["artifactId"].as_str().unwrap(); let all = call( @@ -159,10 +170,10 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .collect::>(), ["10:3", "10:2"] ); - assert_eq!(upstream.0.load(Ordering::SeqCst), 2); + assert_eq!(upstream.0.load(Ordering::SeqCst), 3); assert_eq!(all["collection"]["figmaToolCalls"], 0); - assert_eq!(all["cache"]["originCollection"]["figmaToolCalls"], 1); - assert_eq!(all["cache"]["avoidedFigmaToolCalls"], 1); + assert_eq!(all["cache"]["originCollection"]["figmaToolCalls"], 2); + assert_eq!(all["cache"]["avoidedFigmaToolCalls"], 2); let invalid = client .call_tool( @@ -185,6 +196,75 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o Ok(()) } +/// The real fast snapshot script does not return a Section snapshot: it throws +/// `DEVUP_TARGET_IS_SECTION`, and MCP delivers a thrown error as a *successful* +/// call whose result carries `isError`. +/// +/// `SectionUpstream` above answers the very first call with the index, so it +/// never exercises that step — which is how the direct path came to hand the +/// thrown error straight to `accept` and fail with "snapshot data not found", +/// leaving a Section link with no way to discover the screens inside it. The +/// handoff path had always converted it into a rejection. +#[derive(Debug, Default)] +struct ThrowingSectionUpstream(AtomicUsize); + +#[async_trait] +impl FigmaUpstream for ThrowingSectionUpstream { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + // Keyed on call order rather than script variant, so the test pins the + // recovery itself and not which script the collector retries with. + if self.0.fetch_add(1, Ordering::SeqCst) == 0 { + return Ok(UpstreamResult { + raw: json!({ + "content": [{"type": "text", "text": "Error: DEVUP_TARGET_IS_SECTION"}], + "isError": true + }), + }); + } + Ok(compact_section_index_result()) + } +} + +#[tokio::test] +async fn a_thrown_section_error_on_the_direct_path_returns_selectable_screens() -> anyhow::Result<()> +{ + let upstream = Arc::new(ThrowingSectionUpstream::default()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let selection = call( + &client, + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "outputs": ["tsx"], + "sourcePolicy": "direct" + }), + ) + .await?; + + assert_eq!(selection["status"], "selection_required"); + assert_eq!(selection["targetKind"], "section"); + assert!(selection.get("tsx").is_none()); + let candidates = selection["selection"]["candidates"] + .as_array() + .expect("a Section answers with the screens inside it"); + assert!(!candidates.is_empty()); + // The throw, then the index retry. + assert_eq!(upstream.0.load(Ordering::SeqCst), 2); + + client.cancel().await?; + task.await??; + Ok(()) +} + #[test] fn actual_wquw_151_section_fixture_preserves_the_ten_screen_index() { let fixture: Value = serde_json::from_str(include_str!("fixtures/wquw-151-section.json")) @@ -260,6 +340,7 @@ fn multi_root_envelope(root_ids: &[String]) -> UpstreamResult { }) .collect::>(); let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "rootId": "10:1"}, "snapshot": { @@ -273,55 +354,19 @@ fn multi_root_envelope(root_ids: &[String]) -> UpstreamResult { }, "integrity": {"nodeCount": root_ids.len(), "variableRefCount": 0, "styleRefCount": 0, "utf8Bytes": 0} }); - let bytes = loop { + let _bytes = loop { let bytes = serde_json::to_vec(&envelope).unwrap(); if envelope["integrity"]["utf8Bytes"] == bytes.len() as u64 { break bytes; } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut chunk = Vec::with_capacity(bytes.len() + 8); - chunk.extend_from_slice(&0_u32.to_be_bytes()); - chunk.extend_from_slice(&1_u32.to_be_bytes()); - chunk.extend_from_slice(&bytes); - push_chunk(&mut png, b"duVp", &chunk); - push_chunk( - &mut png, - b"IDAT", - &[0x78, 1, 1, 5, 0, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1], - ); - push_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", "schemaVersion": 1, "rootId": "10:1", - "nodeCount": root_ids.len(), "variableRefCount": 0, "styleRefCount": 0, - "utf8Bytes": bytes.len(), "chunkCount": 1 - }); + // No binary transport exists any more: fast snapshots are always plain + // text (`devupFastSnapshotEnvelope`). Omitting the cursor marker node is + // treated by the decoder as a single, already-complete page. UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } - -fn push_chunk(output: &mut Vec, kind: &[u8; 4], data: &[u8]) { - output.extend_from_slice(&(data.len() as u32).to_be_bytes()); - output.extend_from_slice(kind); - output.extend_from_slice(data); - let mut crc_input = kind.to_vec(); - crc_input.extend_from_slice(data); - output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); -} - -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} diff --git a/crates/devup-mcp/tests/source_orchestration.rs b/crates/devup-mcp/tests/source_orchestration.rs index aea317f4..c7d4de76 100644 --- a/crates/devup-mcp/tests/source_orchestration.rs +++ b/crates/devup-mcp/tests/source_orchestration.rs @@ -275,48 +275,6 @@ fn snapshot_result() -> Value { }) } -#[tokio::test] -async fn auto_disconnected_returns_handoff_without_starting_oauth() -> anyhow::Result<()> { - let auth = Arc::new(AuthProbe { - status: AuthStatus::Disconnected, - logins: AtomicUsize::new(0), - }); - let upstream = Arc::new(UpstreamProbe::unavailable()); - let result = call_tool(auth.clone(), upstream.clone(), input("auto")).await?; - let output = result.structured_content.unwrap(); - - assert_eq!(output["status"], "needs_figma"); - assert_eq!(output["resumeTool"], "devup_figma_continue"); - assert_eq!(output["calls"][0]["tool"], "use_figma"); - assert!( - output["calls"][0]["arguments"]["code"] - .as_str() - .unwrap() - .contains("devupFastSnapshotDescriptor") - ); - assert!(output["expiresAt"].as_str().unwrap().contains('T')); - assert!(output["expiresAt"].as_str().unwrap().ends_with('Z')); - assert_eq!(auth.logins.load(Ordering::SeqCst), 0); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 0); - Ok(()) -} - -#[tokio::test] -async fn host_policy_never_calls_direct_auth_or_upstream() -> anyhow::Result<()> { - let auth = Arc::new(AuthProbe { - status: AuthStatus::Connected, - logins: AtomicUsize::new(0), - }); - let upstream = Arc::new(UpstreamProbe::unavailable()); - let result = call_tool(auth.clone(), upstream.clone(), input("host")).await?; - let output = result.structured_content.unwrap(); - - assert_eq!(output["status"], "needs_figma"); - assert_eq!(auth.logins.load(Ordering::SeqCst), 0); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 0); - Ok(()) -} - #[tokio::test] async fn direct_disconnected_never_starts_oauth() -> anyhow::Result<()> { let auth = Arc::new(AuthProbe { @@ -351,6 +309,14 @@ async fn connected_auto_completes_through_the_direct_collector() -> anyhow::Resu assert_eq!(output["collection"]["fallbackUsed"], true); assert_eq!(upstream.calls.load(Ordering::SeqCst), 3); assert_eq!(auth.logins.load(Ordering::SeqCst), 0); + + // The unambiguous final-answer marker: without it, an agent that only + // ever sees intermediate `needs_figma` steps has, in a real observed + // failure, concluded the conversion was "probably done" and started + // hand-interpreting the raw node tree instead of using this `tsx`. + assert_eq!(output["deliverable"]["kind"], "devup-ui-tsx"); + assert_eq!(output["deliverable"]["isFinal"], true); + assert!(!output["deliverable"]["note"].as_str().unwrap().is_empty()); Ok(()) } @@ -425,223 +391,57 @@ async fn direct_fast_call_error_restarts_the_legacy_collector() -> anyhow::Resul Ok(()) } +/// Auto has one source now, so "auto" means direct and a refusal is reported +/// rather than handed anywhere else. What it must not do is log the caller in +/// on its own: a browser window they did not ask for, opened by a request for +/// code, long after the request that provoked it has scrolled away. #[tokio::test] -async fn auto_falls_back_for_capability_failure_but_not_rate_limit() -> anyhow::Result<()> { - let auth = Arc::new(AuthProbe { - status: AuthStatus::Connected, - logins: AtomicUsize::new(0), - }); - let unavailable = Arc::new(UpstreamProbe::unavailable()); - let fallback = call_tool(auth.clone(), unavailable, input("auto")).await?; - assert_eq!( - fallback.structured_content.unwrap()["status"], - "needs_figma" - ); - - let rate_limited = Arc::new(UpstreamProbe { - calls: AtomicUsize::new(0), - error_code: ErrorCode::DevupFigmaRateLimited, - }); - let rejected = call_tool(auth, rate_limited.clone(), input("auto")).await; - assert!(rejected.is_err()); - assert_eq!(rate_limited.calls.load(Ordering::SeqCst), 1); - Ok(()) -} - -#[tokio::test] -async fn public_continuation_finishes_a_multi_call_host_collection() -> anyhow::Result<()> { +async fn auto_asks_to_be_logged_in_rather_than_starting_oauth() -> anyhow::Result<()> { let auth = Arc::new(AuthProbe { status: AuthStatus::Disconnected, logins: AtomicUsize::new(0), }); let upstream = Arc::new(UpstreamProbe::unavailable()); - let server = DevupServer::new(Services::new(auth, upstream)); - let (server_transport, client_transport) = tokio::io::duplex(128 * 1024); - let task = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(client_transport).await?; + let error = call_tool(auth.clone(), upstream.clone(), input("auto")) + .await + .expect_err("a disconnected direct path cannot collect"); - let start = client - .call_tool( - CallToolRequestParams::new("devup_figma_to_ui") - .with_arguments(input("host").as_object().cloned().unwrap()), - ) - .await? - .structured_content - .unwrap(); - let session_id = start["sessionId"].as_str().unwrap(); - let fast_call = start["calls"][0]["callId"].as_str().unwrap(); - let after_fast = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": fast_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(after_fast["calls"][0]["tool"], "get_metadata"); - assert_eq!(after_fast["collection"]["figmaToolCalls"], 2); - assert_eq!(after_fast["collection"]["fallbackUsed"], true); - assert_eq!( - after_fast["collection"]["fallbackReason"], - "descriptorMissing" + assert!( + error.to_string().contains("devup_figma_auth login"), + "the error should name the action that fixes it: {error}" ); - let metadata_call = after_fast["calls"][0]["callId"].as_str().unwrap(); - let after_metadata = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": metadata_call, - "result": metadata_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(after_metadata["status"], "needs_figma"); - assert_eq!(after_metadata["calls"][0]["tool"], "use_figma"); - - let snapshot_call = after_metadata["calls"][0]["callId"].as_str().unwrap(); - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": snapshot_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(complete["status"], "complete"); - assert_eq!(complete["source"]["kind"], "host"); - assert!(complete["tsx"].as_str().unwrap().contains("SyntheticFrame")); - assert_eq!(complete["collection"]["figmaToolCalls"], 3); - assert_eq!(complete["collection"]["fallbackUsed"], true); - - client.cancel().await?; - task.await??; + assert_eq!(auth.logins.load(Ordering::SeqCst), 0); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 0); Ok(()) } -#[tokio::test] -async fn direct_and_host_collection_produce_identical_artifacts() -> anyhow::Result<()> { +/// Every refusal now surfaces as itself. A capability that is missing says so +/// at once; a spent allowance is waited out three times first, because a +/// collection can cross a per-minute line partway through its own burst. +#[tokio::test(start_paused = true)] +async fn a_refusal_is_reported_as_itself() -> anyhow::Result<()> { let auth = Arc::new(AuthProbe { status: AuthStatus::Connected, logins: AtomicUsize::new(0), }); - let upstream = Arc::new(FixtureUpstream::default()); - let server = DevupServer::new(Services::new(auth, upstream)); - let (server_transport, client_transport) = tokio::io::duplex(128 * 1024); - let task = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(client_transport).await?; - let direct = client - .call_tool( - CallToolRequestParams::new("devup_figma_to_ui") - .with_arguments(input("direct").as_object().cloned().unwrap()), - ) - .await? - .structured_content - .unwrap(); - let start = client - .call_tool( - CallToolRequestParams::new("devup_figma_to_ui") - .with_arguments(input("host").as_object().cloned().unwrap()), - ) - .await? - .structured_content - .unwrap(); - let session_id = start["sessionId"].as_str().unwrap(); - let fast_call = start["calls"][0]["callId"].as_str().unwrap(); - let after_fast = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": fast_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - let metadata_call = after_fast["calls"][0]["callId"].as_str().unwrap(); - let after_metadata = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": metadata_call, - "result": metadata_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - let snapshot_call = after_metadata["calls"][0]["callId"].as_str().unwrap(); - let host = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": snapshot_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - - for field in [ - "tsx", - "imports", - "usedTokens", - "diagnostics", - "snapshot", - "collection", - ] { - assert_eq!(direct[field], host[field], "source changed {field}"); - } - assert_eq!(direct["source"]["kind"], "direct"); - assert_eq!(host["source"]["kind"], "host"); + let unavailable = Arc::new(UpstreamProbe::unavailable()); + assert!( + call_tool(auth.clone(), unavailable.clone(), input("auto")) + .await + .is_err() + ); + assert!(unavailable.calls.load(Ordering::SeqCst) >= 1); - client.cancel().await?; - task.await??; + let rate_limited = Arc::new(UpstreamProbe { + calls: AtomicUsize::new(0), + error_code: ErrorCode::DevupFigmaRateLimited, + }); + assert!( + call_tool(auth, rate_limited.clone(), input("auto")) + .await + .is_err() + ); + assert_eq!(rate_limited.calls.load(Ordering::SeqCst), 3); Ok(()) } diff --git a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs index 28f51da7..82391d94 100644 --- a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs +++ b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs @@ -209,8 +209,8 @@ fn tools_list_over_raw_stdio_has_no_boolean_schemas_and_object_output_types() -> .expect("tools/list result must contain a tools array"); assert_eq!( tools.len(), - 7, - "expected all 7 devup_figma_* tools to be listed: {tools:?}" + 9, + "expected all 9 devup-mcp tools (6 devup_figma_* + devup_project_context + devup_ui_validate + devup_stack_diff) to be listed: {tools:?}" ); let mut boolean_schema_hits = Vec::new(); diff --git a/crates/devup-mcp/tests/stdio_tools.rs b/crates/devup-mcp/tests/stdio_tools.rs index 4e051d67..93405e70 100644 --- a/crates/devup-mcp/tests/stdio_tools.rs +++ b/crates/devup-mcp/tests/stdio_tools.rs @@ -73,6 +73,13 @@ fn collect_boolean_schemas(path: &str, node: &Value, hits: &mut Vec) { } } +// NOTE: kept as `exposes_the_seven_read_only_devup_figma_tools` even though +// this now asserts 9 tools (6 devup_figma_* + 3 ground-truth tools): +// `fixtures/devup-figma-plugin/{ledger,coverage-registry}.json` reference +// this exact Rust test symbol as coverage evidence for the pinned plugin +// compatibility corpus, and the brief instructs not to touch the Figma +// pipeline. Renaming this function would require rewriting ~40 fixture +// entries in a file this task must not modify. #[tokio::test] async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); @@ -144,12 +151,14 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { names, [ "devup_figma_auth", - "devup_figma_continue", "devup_figma_explore", "devup_figma_export", "devup_figma_search", "devup_figma_to_json", "devup_figma_to_ui", + "devup_project_context", + "devup_stack_diff", + "devup_ui_validate", ] ); @@ -201,17 +210,6 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { assert!(explore_text.contains("sourcePolicy")); assert!(!explore_text.contains("code")); - let continuation = tools - .iter() - .find(|tool| tool.name == "devup_figma_continue") - .unwrap(); - let continuation_schema = serde_json::to_value(&continuation.input_schema)?; - let continuation_text = continuation_schema.to_string(); - assert!(continuation_text.contains("sessionId")); - assert!(continuation_text.contains("callId")); - assert!(continuation_text.contains("result")); - assert!(!continuation_text.contains("code")); - client.cancel().await?; server.await??; Ok(()) diff --git a/crates/devup-mcp/tests/upstream_error_surfacing.rs b/crates/devup-mcp/tests/upstream_error_surfacing.rs new file mode 100644 index 00000000..fc5aee41 --- /dev/null +++ b/crates/devup-mcp/tests/upstream_error_surfacing.rs @@ -0,0 +1,292 @@ +//! An upstream refusal must be reported as itself. +//! +//! MCP delivers a refusal as a *successful* tool call whose result carries +//! `isError`. Handing that to the collector made it search the response for +//! data that was never in it and then blame the parser — "metadata not found +//! in the Figma MCP response", or the equivalent for snapshot data, variable +//! batches or asset descriptors, depending only on which step happened to +//! receive it. The reason was in the response all along. + +use std::sync::Arc; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{AuthStatus, DevupError, FigmaUpstream, ReadToolCall, UpstreamResult}; +use rmcp::{ServiceExt, model::CallToolRequestParams}; +use serde_json::{Map, Value, json}; + +/// Verbatim shape of a real Figma rate-limit response. +const RATE_LIMIT_TEXT: &str = "You've reached the Figma MCP tool call limit for your Full seat on the Professional plan. Upgrade your seat or plan for more tool calls."; + +#[derive(Debug)] +struct ConnectedAuth; + +#[async_trait] +impl DevupAuth for ConnectedAuth { + async fn status(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +#[derive(Debug)] +struct RateLimitedUpstream; + +#[async_trait] +impl FigmaUpstream for RateLimitedUpstream { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + Ok(UpstreamResult { + raw: json!({ + "content": [ + {"type": "text", "text": RATE_LIMIT_TEXT}, + {"type": "resource_link", "uri": "file://figma/docs/rate-limits-access.md"} + ], + "isError": true + }), + }) + } +} + +/// The same refusal with no `isError` on it at all. +/// +/// One arrived this way during a real capture: unflagged, so it passed +/// straight through to the collector, which searched it for the variable batch +/// it did not contain and reported "variable/style batch not found in the +/// Figma MCP response" — twenty-four minutes in, naming the parser that +/// happened to be next rather than the refusal that was there all along. +#[derive(Debug)] +struct UnflaggedRefusal; + +#[async_trait] +impl FigmaUpstream for UnflaggedRefusal { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + Ok(UpstreamResult { + raw: json!({ + "content": [{"type": "text", "text": RATE_LIMIT_TEXT}] + }), + }) + } +} + +/// A response that is not a refusal but says the words, at the length a real +/// payload has. A design may name a layer anything, so recognising a refusal +/// by its text has to stop short of failing a collection that worked. +#[derive(Debug)] +struct PayloadMentioningTheLimit; + +#[async_trait] +impl FigmaUpstream for PayloadMentioningTheLimit { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + let padding = "x".repeat(4000); + Ok(UpstreamResult { + raw: json!({ + "content": [{ + "type": "text", + "text": format!("{{\"nodes\":{{\"1:2\":{{\"name\":\"rate limit banner\"}}}},\"pad\":\"{padding}\"}}") + }] + }), + }) + } +} + +/// Same refusal, but with the wait Figma's REST API states in `Retry-After`. +/// The MCP relay does not forward it today; this pins that it is used the +/// moment it appears, rather than the caller being told to guess. +#[derive(Debug)] +struct RateLimitedWithRetryAfter; + +#[async_trait] +impl FigmaUpstream for RateLimitedWithRetryAfter { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + Ok(UpstreamResult { + raw: json!({ + "content": [{"type": "text", "text": RATE_LIMIT_TEXT}], + "isError": true, + "headers": {"Retry-After": 42} + }), + }) + } +} + +/// Runs an export against `upstream` and returns what the caller is told. +async fn reported_failure(upstream: Arc) -> anyhow::Result { + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "outputs": ["tsx"], + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .expect("arguments object"); + + let reported = client + .call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(arguments)) + .await + .expect_err("this collection cannot succeed") + .to_string(); + + client.cancel().await?; + task.abort(); + Ok(reported) +} + +/// A refusal is recognised by what it says, not only by how it is flagged. +#[tokio::test] +async fn an_unflagged_refusal_is_still_read_as_one() -> anyhow::Result<()> { + let reported = reported_failure(Arc::new(UnflaggedRefusal)).await?; + + assert!( + reported.contains("DEVUP_FIGMA_RATE_LIMITED"), + "an unflagged refusal is still a refusal: {reported}" + ); + assert!( + !reported.contains("not found in the Figma MCP response"), + "it must not be blamed on whichever parser was next: {reported}" + ); + Ok(()) +} + +/// And the recognition stops at the length a refusal can be, so a payload that +/// merely contains the words is still collected rather than refused. +#[tokio::test] +async fn a_payload_that_mentions_the_limit_is_not_mistaken_for_one() -> anyhow::Result<()> { + let reported = reported_failure(Arc::new(PayloadMentioningTheLimit)).await?; + + assert!( + !reported.contains("DEVUP_FIGMA_RATE_LIMITED"), + "a design may name a layer anything; that is not a refusal: {reported}" + ); + Ok(()) +} + +#[tokio::test] +async fn a_stated_retry_after_is_reported_instead_of_a_guess() -> anyhow::Result<()> { + let server = DevupServer::new(Services::new( + Arc::new(ConnectedAuth), + Arc::new(RateLimitedWithRetryAfter), + )); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "outputs": ["tsx"], + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .expect("arguments object"); + + let reported = client + .call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(arguments)) + .await + .expect_err("a refused collection must fail") + .to_string(); + + assert!( + reported.contains("\"retryAfterSeconds\":42"), + "the stated wait must be surfaced: {reported}" + ); + assert!( + !reported.contains("Not stated"), + "a stated wait must not also be reported as unstated: {reported}" + ); + + client.cancel().await?; + task.abort(); + Ok(()) +} + +#[tokio::test] +async fn a_rate_limited_upstream_reports_its_own_reason_not_a_parse_failure() -> anyhow::Result<()> +{ + let server = DevupServer::new(Services::new( + Arc::new(ConnectedAuth), + Arc::new(RateLimitedUpstream), + )); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "outputs": ["tsx"], + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .expect("arguments object"); + + let error = client + .call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(arguments)) + .await + .expect_err("a refused collection must fail"); + let reported = error.to_string(); + + assert!( + reported.contains("tool call limit"), + "the upstream reason must survive: {reported}" + ); + assert!( + !reported.contains("not found in the Figma MCP response"), + "the refusal must not be reported as missing data: {reported}" + ); + // A quota refusal clears on its own, so reporting it as permanent would + // tell the caller to give up on something that fixes itself. + assert!( + reported.contains("DEVUP_FIGMA_RATE_LIMITED"), + "a quota refusal must be classified as one: {reported}" + ); + assert!( + reported.contains("\"retryable\":true"), + "a quota refusal must be retryable: {reported}" + ); + // Figma meters with a leaky bucket, so promising a reset would send the + // caller waiting for a rollover that never arrives. + assert!( + reported.contains("leaky bucket"), + "recovery must be described as gradual: {reported}" + ); + // This relay forwards no Retry-After, so the response must admit that + // rather than pick a ceiling on the caller's behalf. + assert!( + reported.contains("Not stated"), + "an unstated ceiling must be reported as unstated: {reported}" + ); + + client.cancel().await?; + task.abort(); + Ok(()) +} diff --git a/docs/responsive-merge-rules.md b/docs/responsive-merge-rules.md new file mode 100644 index 00000000..b88f0873 --- /dev/null +++ b/docs/responsive-merge-rules.md @@ -0,0 +1,324 @@ +# Breakpoint merging, as the plugin does it + +The rules below are read off the plugin's source at the commit this repo's +corpus pins, `243db65` of `dev-five-git/devup-figma-plugin` — chiefly +`src/codegen/responsive/index.ts` (`getBreakpointByWidth`, +`mergePropsToResponsive`, `optimizeResponsiveValue`) and +`ResponsiveCodegen.ts`. Where a claim came from reading output rather than +source it says so. + +They are checked against two screens in `devup-Test`, chosen because they take +opposite branches of the merge. + +- **`notice`** (node `422:6865`, widths 360 / 992 / 1920) — the widths disagree + in shape, so almost nothing merges. Its four arrays are all `display`, and + there is not one merged value in the file. +- **`popup`** (node `422:5758`) — the widths agree in shape everywhere, so this + is the screen that shows value merging: 15 arrays, no `display` at all. + +A screen tends to take one branch wholesale rather than a mixture, which is why +one screen alone was a misleading sample. + +## The four outputs + +| Output | What it is | +|---|---| +| Pure Code | the selected frame, primitives only, every instance expanded | +| desktop | the same frame with instances left as `
`, `` | +| desktop - Components | the definitions of those components, with their prop types | +| notice - Responsive | all three widths merged, components kept | + +Only the fourth carries `display` arrays. The first three describe one width. + +## Definitions cannot be derived + +The difference between Pure Code and the component-applied output gives a +component's *body*, so it looked as though definitions did not need their own +output. They do: + +```tsx +export interface HeaderProps { + property1: 'scroll' | 'transparent' | 'mobileTranspa' | 'mobileScroll' +} +``` + +That union comes from the component set's variants. Three of those four +variants appear nowhere in a screen that uses `property1="transparent"`, so no +amount of diffing recovers them. The same holds for `FooterProps`, and for +`Icons`, whose union names fifty-odd glyphs whose call site mentions one. + +A definition also carries what a call site cannot: `_hover` / `_active` / +`_selected` blocks, and per-variant prop maps written as +`bg={{ scroll: "$headerBg", mobileScroll: "$headerBg" }[property1]}`. + +## The array + +Five slots, `[mobile, mid, tablet, mid, PC]`. A slot is written only when it +changes what is in effect, because `null` is not "no value" but "whatever the +slot before it said". So the array records *transitions*, not widths, and +trailing nulls are dropped: + +```tsx +display={["none", null, "flex"]} // absent on mobile, present from tablet up +display={[null, null, "none"]} // present on mobile, absent from tablet up +``` + +**Which slot a width occupies is decided by how wide its frame is, not by what +it is called.** The plugin's `getBreakpointByWidth` places a frame in the first +band its width fits: + +| Slot | 0 | 1 | 2 | 3 | 4 | +|---|---|---|---|---|---| +| name | mobile | sm | tablet | lg | pc | +| width | ≤ 480 | ≤ 768 | ≤ 992 | ≤ 1280 | rest | + +`notice` is drawn at 360 / 992 / 1920, so its three frames land on 0 / 2 / 4. +`popup`'s frame named `tablet` is under 768 and lands on slot **1**. Reading +the frame's name and assuming slot 2 puts every value of that screen a band too +wide. + +**A prop that stops has to say so.** Because `null` inherits, a prop cannot +simply be dropped at a wider width — the narrower value would carry. The +reference writes `"initial"`, and spends one only where something would +otherwise be inherited: + +```tsx +pl={["36.5px", "initial"]} // mobile sets it, nothing else does +px={[null, "184px", null, null, "initial"]} // slot 0 is null: nothing to clear yet +``` + +Two limits of that device are worth knowing, because they are the reference's +and this repo follows them rather than quietly improving on them. + +*Only layout props are cleared.* `SPECIAL_PROPS_WITH_INITIAL` lists display, +position, transform, `w`/`h`, `textAlign`, the flex and grid props, the offsets, +overflow, and every padding and margin. A `bg` that stops being set is left to +inherit. + +*Exactly one `"initial"` is placed*, at the first drawn width after the last +value, and the array is cut there. So a prop that is set, dropped, then set +again at a wider width cannot be expressed — the drop is lost. Neither screen +here does that, but a design could. + +**A first value that is already the default is not written.** `flexDir="row"`, +`alignItems`/`justifyContent="flex-start"` and `gap="0"` are dropped from slot +0, so `["row", null, "column"]` is written `[null, null, "column"]`. The +padding and margin entries of that table are commented out upstream. + +This is the whole reason the two branches below are not interchangeable. Values +that move can become arrays; a subtree whose *shape* moves cannot, because +props appear and disappear with the nodes that carried them. + +## The element can merge too + +Where the widths draw different elements, the merge does not keep both. The +`popup` root is a `VStack` at two widths and a `Flex` at the third, and it comes +out as one `VStack` with the difference demoted to a prop: + +```tsx + +``` + +Props are unioned rather than reconciled. Mobile sets `pl` and `pr`, tablet sets +`px`, desktop sets neither; all three survive as three arrays, and nothing tries +to rewrite `pl`/`pr` into `px`. + +## Two ways a subtree can differ + +These are not two equal paths. A screen drawn at three widths is meant to be +the same tree three times, and merging into arrays is what should happen; the +other branch is what saves an export when the design drifted. Of this screen's +four children, three merge and one does not: + +``` +[0] main banner mobile 3 children / tablet 2 / desktop 2 ← the odd one +[1] Header 1 / 1 / 1 +[2] section 1 / 1 / 1 +[3] Footer 1 / 1 / 1 +``` + +**Structure matches → merge, and let differing values become arrays.** The +`Header` instance is identical across all three widths, so it appears once and +is not toggled at all: + +```tsx + +
+ +``` + +**Structure differs → keep both, toggle with `display`.** The banner is not one +node with responsive values; it is two nodes, each shown at its own widths. The +capture says why — the same-named frame is shaped differently: + +``` +mobile 'main banner' kids=3 [Frame…289, Logo, Logo] +desktop 'main banner' kids=2 [Frame…289, Frame…364] +``` + +The two logos are wrapped in a frame on desktop and left loose on mobile — the +same intent grouped two ways, which is a drift in the file rather than a +difference the screen means to express. It shows in the output: the desktop +wrapper folds into `maskImage="url('/icons/Frame 1000014364.svg')"` while +mobile emits two separately placed logos. Read this branch as the cost of that +drift, not as the feature. A design whose widths agree in shape never reaches +it. + +The mobile banner also holds two absolutely-placed logos the desktop one does +not, and its text sits in a `pos="absolute"` stack rather than a centred +column. There is no alignment to merge, so both survive. + +The same split appears again in the content section: desktop puts the tabs +beside the search box in a `Flex`, mobile stacks them in a `VStack` with the +search box first, and both are emitted with opposite `display` arrays. + +## What the reference does not do + +Component props are not responsive. `
` stays +`desktop` at every width even though `FooterProps` admits `'mobile'` and +`'tablet'` and the definition lays out all three. Passing an array there does +nothing, and the design owner reads this as the plugin's omission rather than +intended behaviour — worth knowing before treating it as ground truth. + +## Where this lands in the code + +`variant.rs` already merges trees for viewport *variants* of a component set: +`same_rendered_structure` decides whether two trees are the same shape, +`merged_props` folds differing values into an expression, and `unrepresented` +collects what could not be represented. + +`responsive.rs` holds the breakpoint side. `breakpoints` finds the widths a +snapshot carries and `divergences` names every place their shapes part company +— the input to the toggle branch. `slot_of_width` is `getBreakpointByWidth`, +and `merge_slots` is `mergePropsToResponsive` folded together with +`optimizeResponsiveValue`, carrying both of its limits deliberately. +`tests/responsive_merge.rs` checks it against all 15 arrays of `popup`, all 4 +of `notice`, and the five widths the plugin's own unit test pins. + +`Expression::Responsive` now carries its slots as a vector rather than a fixed +pair, so the two callers can write different shapes: the viewport path keeps +writing `[mobile, null, null, null, PC]`, which the pinned corpus requires, +while the breakpoint path writes whatever `merge_slots` returns. + +The join is `merge_breakpoints`, ported from +`ResponsiveCodegen.generateMergedCode`. Its shape is worth stating because it is +not what reading the output suggests: + +- **Children are paired by name, not by index.** Each width's children are + bucketed by name, and for a name held by several children they are paired by + position *within that name*. So a node inserted at one width shifts nothing. +- **The toggle is not a separate branch.** A child missing at some width is not + left out; the width is given a *copy of a present sibling's tree* with + `display: 'none'` added. The copies then go through the ordinary merge, and + the `display` array falls out of `merge_slots` like any other prop. That is + why `notice`'s toggles need no special case here — they are what this + function already does. +- Which also explains `["flex", null, "none"]` against `[null, null, "none"]`: + the difference is only whether the shown copy's own props carried a + `display`, not a decision about how to hide it. The plugin keeps `display` in + a node's props and picks the element from it; this repo picks the element + first, so `restore_implied_display` puts the value back before merging. Not + doing so is a real defect rather than a formatting difference — the hidden + width would clear the others to `"initial"`, and `display: initial` is + `inline`, not `flex`. + +- **An instance whose component is only a shape is spelled out, not + referenced.** `isAssetLeafTree` — a childless `Image` with a `src` or `Box` + with a `maskImage`. That is why the plugin writes `{/* */}` and then + a `Box`: it recognised the component and declined to reference it. Without + this the output carries `` and `` and asks for two component + files whose whole body is one masked box. + +`tests/responsive_screen.rs` runs the join on the `notice` capture and checks it +against the plugin's answer: the four `display` arrays, the slot placement, what +is referenced against what is inlined, and every element at its nesting. + +## Reaching it + +`responsiveTsx` is an export output beside `tsx` and `componentTsx`. It carries +the whole module — the `@devup-ui/react` import, one `@/components/…` import per +component the screen references, and the screen as a default export — because a +fragment would leave the caller to work out which of the two import lines each +element belongs on. It is also produced whenever `tsx` is asked for and the +capture holds more than one width, since a caller cannot tell from a node id +whether the responsive form exists. + +Alongside it the export names `responsiveSlots` (which slot each width took), +`responsiveImports` and `responsiveComponents` (the two import lines, already +separated), and `responsiveUnrepresented` when the widths asked for something a +single tree cannot say. `crates/devup-mcp/tests/responsive_export.rs` drives the +real tool over a stubbed upstream and checks that path end to end. + +## Where this repo differs, and why + +Two differences from the reference are deliberate. Both are recorded because a +difference is a question until someone says how it was settled. + +**The first width is the narrowest, not the first in the file.** +`categorizeChildren` walks the Section's children in their canvas order, so the +plugin's "first" width is wherever the designer happened to put a frame — +`notice` is authored desktop-first and `popup` mobile-first, and that alone +decides which width lends the merged node its element name. This repo orders by +width. The visible effect is that a pair of regions shown at opposite widths +comes out in the other order; neither is ever visible at the same time, so it +is order between two things that never meet. + +**A component prop that differs by width is reported.** `
` wants +`mobile`, `tablet` and `desktop`, and devup-ui reads an array only where it +applies CSS, so no value is right. The reference silently keeps whichever width +came first, which the design owner reads as an omission rather than a decision. +The widest is kept here too, so the output matches, but the loss is named in +`MergedScreen::unrepresented` instead of passing quietly. + +**A positioned shape is one element, not two.** The reference wraps the inlined +shape in a `Box` that carries only its placement. Merging the placement into +the shape's own `Box` draws the same thing with two elements fewer, which is +the only place the element counts differ. + +**A selector block says only what the state changes.** The reference writes +`_hover={{ "gap": size === 'md' && "10px" }}` where this writes +`{ md: varient === 'ghost' && "10px" }[size]`, and the second is the narrower +claim: at `md` the base gap is `8px` for `ghost` and `10px` for everything else, +so hover changes it for `ghost` alone. + +The difference comes from one line of `createNestedVariantProp`. It computes +each state's delta per combination and then folds the combinations together, +and when a branch has only one combination *carrying a value* it treats them as +agreeing and collapses to that value — filling in the combinations that had +none. Here that is harmless because the value it fills in is the value those +combinations already had. It is harmless by coincidence: were the base `8px` +elsewhere at `md`, hover would be given a change the design never asked for. + +The same collapse was written here first and the pinned corpus rejected it — +`border={variant === 'white' && …}` became `border="solid 1px …"`, handing a +border to the four variants that refuse one. Presence is asked of the record +rather than of the value for that reason, and the rule is not suspended for +selector blocks. + +**`display` is never cleared to `"initial"`.** `initial` is the value the CSS +specification gives a property, not the value the element has. For the other +forty props in the set those coincide — `w` is `auto`, `p` is `0`, `pos` is +`static`, `overflow` is `visible` — and clearing to `initial` is exactly right. +`display` is the one where they part: an element's display comes from the +user-agent stylesheet, so `div` is `block` and `img` is `inline`, while the +property's initial value is `inline` for all of them. A node hidden at a narrow +width and shown at a wider one would come back inline: a `Box` would stop being +a block, and a `Text` — which devup-ui renders as a `p` — with it. + +`revert` is the keyword that means what is wanted here, and it cannot be used +either. It rolls the cascade back past the author origin, and that is where +devup-ui puts `VStack`'s own `display: flex`, so reverting would take that with +it. The value is written out instead: + +| element | renders as | written | +|---|---|---| +| `Flex` `VStack` `Center` | `div` + class | `flex` | +| `Grid` | `div` + class | `grid` | +| `Box` | `div` | `block` | +| `Text` | `p` | `block` | +| `Image` | `img` | `inline` | + +Neither screen kept here reaches the case, so this is a correction made from +reading the rule rather than from a disagreement with an answer. The design +owner confirmed the `initial` in the reference was a mistake rather than +intent. diff --git a/docs/visual-renderer-contract.md b/docs/visual-renderer-contract.md index 14f5090e..5538d558 100644 --- a/docs/visual-renderer-contract.md +++ b/docs/visual-renderer-contract.md @@ -14,7 +14,7 @@ Request `referencePng` together with the desired projection: } ``` -The collector makes one `get_screenshot` call for the linked node, accepts only a bounded `image/png`, validates its signature and SHA-256, and records `capabilities.referencePng=true` on the artifact. `referencePng` currently applies to one linked node; Section multi-frame selection must acquire each frame by its canonical URL. +The collector makes one `get_screenshot` call for the linked node, asking for the PNG inline (`enableBase64Response`) at the node's own size (`maxDimension` raised from the tool's 1024px default to 8192px), takes the one `image/png` block beside the URL text the tool also writes, validates its signature and SHA-256, and records `capabilities.referencePng=true` on the artifact. `referencePng` currently applies to one linked node; Section multi-frame selection must acquire each frame by its canonical URL. ## 2. Render the generated component diff --git a/fixtures/devup-figma-plugin/manifest.json b/fixtures/devup-figma-plugin/manifest.json index 803eec72..32e8f183 100644 --- a/fixtures/devup-figma-plugin/manifest.json +++ b/fixtures/devup-figma-plugin/manifest.json @@ -1599,7 +1599,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-114-855686da78.snap", - "sha256": "20b665387217396219fb63be572b7c2780a4f160978d06977bbefbd82dfaaab0" + "sha256": "dd3a4d16d14c3c187fd624c6fa1e04dc08016ae18de686cb05fcea76cbb30e2d" }, { "path": "snapshots/codegen/upstream-codegen-115-45d3f116b5.snap", @@ -2015,15 +2015,15 @@ }, { "path": "snapshots/codegen/upstream-codegen-218-587666c709.snap", - "sha256": "3b343c4988076d2b1674db437e9579bf8ee6b1047b7527cef635660943708381" + "sha256": "22587f45bfac79d19434690c3e02aeefdec6143eef65e943052dc5817402c384" }, { "path": "snapshots/codegen/upstream-codegen-219-c6446b7b2e.snap", - "sha256": "60818cde4db27ad1878e23a0d236bff7be8b2175cd5bf9b36fe3f0c8bc1de56b" + "sha256": "eac13db27588a7610017b605c5828ab13a5959639536f64b5291b763eb452b50" }, { "path": "snapshots/codegen/upstream-codegen-220-5980153f26.snap", - "sha256": "a147eecd443176ecd68bbfa21909133f7298c0fd79b983fc86917c179d6729bb" + "sha256": "08e0e0d288ac10d2c5956ae19d03b476f3b8d4a7f0fdeb8ac1f408a2bb1a3663" }, { "path": "snapshots/codegen/upstream-codegen-221-d003bdb336.snap", @@ -2035,7 +2035,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-223-4dc8092992.snap", - "sha256": "d446072c4604d6298a00415a161d55b7a3d1faf77ae555ea0cdd4fee6149c36b" + "sha256": "5fcbc9882ad9eefe2bdab78f2162d920577358948720f2c855d7461b386f3128" }, { "path": "snapshots/codegen/upstream-codegen-224-075a41e65c.snap", @@ -2087,7 +2087,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-236-621c6d6a9e.snap", - "sha256": "46b22a387cf010790c10de0a1a2f5abcadd530396aaa97084229c0764906a241" + "sha256": "3b2ec85d78cb9730bb9ce463b380c35426a800abe6993201a762f015817350ed" }, { "path": "snapshots/codegen/upstream-codegen-237-01ee478666.snap", @@ -2127,7 +2127,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-246-295f39e09b.snap", - "sha256": "d7501e23e776794310b8a6ac9ebbc003214f69952ab508d2858b59a2ea3268c1" + "sha256": "132da3aba8a8a5fef1e243b257dbd49fe4f12dfa402b6764123194f284c5c535" }, { "path": "snapshots/codegen/upstream-codegen-247-39d6959685.snap", @@ -2143,19 +2143,19 @@ }, { "path": "snapshots/codegen/upstream-codegen-250-935ccdb80f.snap", - "sha256": "879ef30a2d4c0e4b26a56a9e4222f490b784e16ddcb35386e647069b4da3b8d1" + "sha256": "f8a7b08cb2be01fa31ec8836a11e6e9e2be951c1315028fa22c807e262b29597" }, { "path": "snapshots/codegen/upstream-codegen-251-50acddfb96.snap", - "sha256": "c20e28e8155b33779019db48526fd5c2af2649257f7ead079974e750fdacf533" + "sha256": "bc5e7a6980d20ba44f1039dab82d2782e7beee3a394c575bc92f19e0d3f8007c" }, { "path": "snapshots/codegen/upstream-codegen-252-49e979239e.snap", - "sha256": "d0bbc2da26aa80c8ee730a86f25c646e9d3d6739f5efd68c4fa4ddc66f2a306d" + "sha256": "563744409082a9811f0df1c3ab326d027d85b3abc2958d5f1e56275bc978b7d9" }, { "path": "snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap", - "sha256": "014c07f7150972049d130a4235abc2ad6ea39cfc381880e4408dd43ca21d2069" + "sha256": "b23ece0f3d522fe222bb03bb7b357d38f7cb4c3d9a576eb536db2c9b228bbc6c" }, { "path": "snapshots/codegen/upstream-codegen-254-9f340b6a10.snap", diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-114-855686da78.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-114-855686da78.snap index 57442e35..8432246d 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-114-855686da78.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-114-855686da78.snap @@ -2,4 +2,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-218-587666c709.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-218-587666c709.snap index e7bf38d3..2f75530c 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-218-587666c709.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-218-587666c709.snap @@ -4,5 +4,5 @@ expression: actual --- "export function MixBlendMode() { - return + return }" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-219-c6446b7b2e.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-219-c6446b7b2e.snap index f5b524ed..705de1d1 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-219-c6446b7b2e.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-219-c6446b7b2e.snap @@ -4,5 +4,5 @@ expression: actual --- "export function MixBlendMode() { - return + return }" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-220-5980153f26.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-220-5980153f26.snap index ceb6e3f3..629a88a9 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-220-5980153f26.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-220-5980153f26.snap @@ -4,5 +4,5 @@ expression: actual --- "export function MixBlendMode() { - return + return }" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap index ab1133d3..f60d9ef8 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap @@ -10,8 +10,8 @@ expression: actual bg="$primaryBg" border="solid 1px $border" gap="20px" - px="40px" - py="30px" + px="39px" + py="29px" > +
diff --git a/fixtures/plugin-answers/README.md b/fixtures/plugin-answers/README.md new file mode 100644 index 00000000..db529412 --- /dev/null +++ b/fixtures/plugin-answers/README.md @@ -0,0 +1,503 @@ +# What the plugin answered + +Not "the correct output". These are what `devup-figma-plugin` returned when +asked for real screens in `devup-Test`, and they are kept because they are the +only account of how it behaves on one, not because they are known to be right. + +The distinction matters. The plugin is the reference this repo aims to match or +beat, and its author does not vouch for every line here. Treat a difference as a +question, the same way a difference against the pinned corpus is a question, and +say which way it was settled. + +## The screens + +`notice/` and `popup/` are the two kept in full, chosen to sit at opposite ends +of the merge so that between them they exercise both of its branches. `button/` +is not a screen at all but a component set, kept for what it says about props a +call site may not pass. `about/` holds one output rather than a set, and its +section says how it got here, which is not the way the others did. + +### `notice/` — where the widths part company + +Node `422:6865`, the `desktop` width. Four outputs: + +| File | The plugin's tab | What it shows | +|---|---|---| +| `pure.tsx` | Pure Code | the frame with every instance expanded to primitives | +| `with-components.tsx` | desktop | the same frame with instances left as `
` | +| `components.tsx` | desktop - Components | the definitions of those components | +| `responsive.tsx` | notice - Responsive | all three widths merged | + +Its widths are 360 / 992 / 1920. They disagree in *shape*, so almost nothing +merges: the four arrays in `responsive.tsx` are all `display`, and there is not +one merged value in the file. + +### `popup/` — where they agree and only the numbers move + +Node `422:5758`. Its widths agree in shape everywhere, so this is the screen +that exercises value merging — 15 arrays, no `display` at all. It has no +components, so its Pure Code and `desktop` tabs are identical and only one is +kept. + +| File | The plugin's tab | +|---|---| +| `pure-mobile.tsx` | Pure Code at the `mobile` width | +| `pure-tablet.tsx` | Pure Code at the `tablet` width | +| `pure-desktop.tsx` | Pure Code at the `desktop` width | +| `responsive.tsx` | popup - Responsive | + +Together the two screens say that a merge has two branches and that a screen +usually takes one of them wholesale, not a mixture. + +### `about` — the widths that go back, kept as tests and not as a file + +Node `422:3798`, and `responsive.tsx` is its `about - Responsive` output. + +**It arrived differently from the others, and that is worth knowing.** The three +above came from the plugin to a file. This one came through a chat window, and +for a while it was deliberately not kept: a reference is only useful as a judge +if it is exact, and a 1,400-line file copied by hand is a judge that cannot be +trusted, which is worse than none. + +What changed is not the route but the checking. Six of its arrays had already +been read and locked as tests — `responsive_merge.rs::the_about_screen_needs_every_slot` +— before the file existed, so they could be used to check the file rather than +the other way round. All six are present in it, along with its imports, both +component references, the literal `display="none"`, and a single default export; +its indentation steps by four, as `notice`'s does, which is what the comparison +reads nesting from. A paste that had been truncated or mangled would have failed +one of those. This one did not. + +So it is kept, and `responsive_screen.rs::the_about_screen_matches_the_answer_when_both_are_present` +decides the comparison rather than a person reading two files side by side. It +still waits on the capture at `fixtures/local-screens/about-family.json` and +skips until that arrives. Do not reformat this file: the nesting is the +comparison. + +`about - Components` is still worth adding if it is to hand. A definitions +output cannot be recovered from the others, and the union behind +`
` exists nowhere else. + +It earns an entry because it is the first answer whose widths *return*. `notice` +only ever toggles one way and `popup` only ever moves a number forward, so +between them every array stops by slot 2. `about` has both a region shown at +tablet and hidden again at desktop, and values that come back at desktop to what +mobile said: + +```tsx +display={["none", null, "flex", null, "none"]} // tablet only +w={["770px", null, "778px", null, "770px"]} // tablet is the odd one +textAlign={[null, null, "right", null, "initial"]} +``` + +The closing slot in each is not redundant with the opening one. Leaving it off +would inherit the tablet value, so these are the arrays that need all five +slots, and they are the reason the merge cannot stop at the last *changed* slot. +The `textAlign` row also confirms that the cleared set reaches beyond spacing +and layout — this repo already had it there, and the answer agrees. + +Two more things it settles: + +- **A node hidden at every width is emitted, not dropped.** The output opens + with ``, a literal rather than an array, which is + three widths all reporting `display: 'none'` and collapsing. The plugin's + `getVisibilityProps` returns `{ display: 'none' }` for `!node.visible` and + merges it with the rest at `index.ts:203`; this repo does the same at + `style.rs:274`. +- **A variant prop is whatever the set calls it.** Here it is + `
` and `
` — not + `property1`, and the same value at all three widths. That is a different + situation from the `notice` footer noted below: there the widths differ and + the plugin picks one, here they genuinely agree, so nothing is being papered + over. + +### `button/` — a component set, for what a call site may not pass + +Node `582:2137`. Not a screen: a four-dimension component set (`size`, +`varient`, and the boolean `leftIcon` / `rightIcon`) that also carries an +`effect` dimension. + +| File | The plugin's tab | +|---|---| +| `pure.tsx` | Pure Code, one variant flattened | +| `usage.tsx` | Usage — how a call site writes it | +| `components.tsx` | Button - Components | + +**`effect` is not a prop.** `ButtonProps` does not declare it. The dimension +exists so the *definition* can fold its variants into `_hover` and `_active` +blocks, which is where all five of the set's hover colours live. A call site has +no interaction state to pass, so `usage.tsx` writes +`
+ + + + + + Comparison Bechmarks + + + Next.js Build Time and Build Size
(github action - ubuntu-latest) +
+
+ + + + + + Devup-ui + + + 1.0.18 + + + + + + Next.js Build TIme{" "} + + + + + 18.2s + + + + + + Bulid Size + + + + + 54.7MB + + + + + + + + + + Chakra UI + + + 3.27.0 + + + + + + Bulid Time + + + 29.9s + + + + + Bulid Size + + + 200.4MB + + + + + + + + Mui + + + 7.3.2 + + + + + + Bulid Time + + + 21.6s + + + + + Bulid Size + + + 84.3MB + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + +
+
+ + + + + Features + + + Devup UI offers a performance-optimized CSS-in-JS system, theme typing,
and amazing features for faster and safer development. +
+
+ + + {/* <010.idea style="flat" /> */} + + + + Zero Runtime + + + A futuristic design that eliminates the root causes of performance degradation. + + + + + {/* <019.trophy style="flat" /> */} + + + + Top Performance + + + The fastest build speed and the smallest bundle size among CSS-in-JS solutions. + + + + + {/* <021.heart style="flat" /> */} + + + + Type Safety + + + Enhanced DX with typing-based support. + + + + + {/* <016.notice style="flat" /> */} + + + + Figma Plugin + + + A Figma plugin enabling safer and faster development.{" "} + + + + + + + + + + +
+
+ + + + + + + Join our community + + + Join our Discord and help build the future of frontend with
CSS-in-JS!{" "} +
+
+ + + + +
+
+
+ + + + + Docs + + + + Overview + + + Installation + + + Features + + + API + + + Devup + + + + + + Components + + + Overview + + + Layouts + + + + + Team + + + Team + + + 상세 메뉴 2 + + + 상세 메뉴 3 + + + 상세 메뉴 4 + + + + + 메뉴 타이틀 4 + + + 상세 메뉴 1 + + + 상세 메뉴 2 + + + 상세 메뉴 3 + + + 상세 메뉴 4 + + + + + 메뉴 타이틀 5 + + + 상세 메뉴 1 + + + 상세 메뉴 2 + + + 상세 메뉴 3 + + + 상세 메뉴 4 + + + 상세 메뉴 5 + + + + + + + + + Docs + + + Team + + + 서브 메뉴 3 + + + 서브 메뉴 4 + + + + + + 상호: (주)데브파이브 | 대표자명: 오정민 |
사업자등록번호: 868-86-03159
주소: 경기 고양시 덕양구 마상로140번길 81 4층 +
+ + Copyright © 2021-2024 데브파이브. All Rights Reserved.{" "} + +
+
+
+ diff --git a/fixtures/plugin-answers/devup-ui-landing/pc.tsx b/fixtures/plugin-answers/devup-ui-landing/pc.tsx new file mode 100644 index 00000000..a5588b23 --- /dev/null +++ b/fixtures/plugin-answers/devup-ui-landing/pc.tsx @@ -0,0 +1,592 @@ +
+ + + + + + + Zero + + {" "}Config{" "} + + Zero + + {" "}FOUC{" "} + + Zero + + {" "}Runtime
CSS in JS Preprocessor +
+ + Building the Future of CSS-in-JS Analyze all possible scenarios at the fastest speed and style with optimal performance. + +
+
diff --git a/fixtures/plugin-answers/devup-ui-landing/pure-mobile.tsx b/fixtures/plugin-answers/devup-ui-landing/pure-mobile.tsx new file mode 100644 index 00000000..6710d8c5 --- /dev/null +++ b/fixtures/plugin-answers/devup-ui-landing/pure-mobile.tsx @@ -0,0 +1,836 @@ +
+ + + + + +
+ + + + + Zero + + {" "}Config{" "} + + Zero + + {" "}FOUC{" "} + + Zero + + {" "}Runtime
CSS in JS Preprocessor +
+ + Building the Future of CSS-in-JS Analyze all possible scenarios at the fastest speed and style with optimal performance. + +
+
+ + + + Get started + + + +
+ + + + + + Star + + +
+ + 36 + +
+
+ + + + Sponsor + + +
+
+
+ + + + + Comparison Bechmarks + + + Next.js Build Time and Build Size
(github action - ubuntu-latest) +
+
+ + + + + + Devup-ui + + + 1.0.18 + + + + + + Next.js Build TIme{" "} + + + + + 18.2s + + + + + + Bulid Size + + + + + 54.7MB + + + + + + + + + + Chakra UI + + + 3.27.0 + + + + + + Bulid Time + + + 29.9s + + + + + Bulid Size + + + 200.4MB + + + + + + + + Mui + + + 7.3.2 + + + + + + Bulid Time + + + 21.6s + + + + + Bulid Size + + + 84.3MB + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + +
+
+ + + + + Features + + + Devup UI offers a performance-optimized CSS-in-JS system, theme typing,
and amazing features for faster and safer development. +
+
+ + + + + + Zero Runtime + + + A futuristic design that eliminates the root causes of performance degradation. + + + + + + + + Top Performance + + + The fastest build speed and the smallest bundle size among CSS-in-JS solutions. + + + + + + + + Type Safety + + + Enhanced DX with typing-based support. + + + + + + + + Figma Plugin + + + A Figma plugin enabling safer and faster development.{" "} + + + + + + Go Figma Community + + + + + + + + + + + + +
+
+ + + + + + + Join our community + + + Join our Discord and help build the future of frontend with
CSS-in-JS!{" "} +
+
+ + + + + Open KakaoTalk + + + + + + + + Join our Discord + + + + + +
+
+
+ + + + + Docs + + + + Overview + + + Installation + + + Features + + + API + + + Devup + + + + + + Components + + + Overview + + + Layouts + + + + + Team + + + Team + + + 상세 메뉴 2 + + + 상세 메뉴 3 + + + 상세 메뉴 4 + + + + + 메뉴 타이틀 4 + + + 상세 메뉴 1 + + + 상세 메뉴 2 + + + 상세 메뉴 3 + + + 상세 메뉴 4 + + + + + 메뉴 타이틀 5 + + + 상세 메뉴 1 + + + 상세 메뉴 2 + + + 상세 메뉴 3 + + + 상세 메뉴 4 + + + 상세 메뉴 5 + + + + + + + + + Docs + + + Team + + + 서브 메뉴 3 + + + 서브 메뉴 4 + + + + + + 상호: (주)데브파이브 | 대표자명: 오정민 |
사업자등록번호: 868-86-03159
주소: 경기 고양시 덕양구 마상로140번길 81 4층 +
+ + Copyright © 2021-2024 데브파이브. All Rights Reserved.{" "} + +
+
+
+
diff --git a/fixtures/plugin-answers/devup-ui-landing/pure-pc.tsx b/fixtures/plugin-answers/devup-ui-landing/pure-pc.tsx new file mode 100644 index 00000000..ebb0249a --- /dev/null +++ b/fixtures/plugin-answers/devup-ui-landing/pure-pc.tsx @@ -0,0 +1,775 @@ +
+ + + + + + + Zero + + {" "}Config{" "} + + Zero + + {" "}FOUC{" "} + + Zero + + {" "}Runtime
CSS in JS Preprocessor +
+ + Building the Future of CSS-in-JS Analyze all possible scenarios at the fastest speed and style with optimal performance. + +
+ + + + + Get started + + + + + + + + + + Star + + +
+ + 36 + +
+
+ + + + Sponsor + + +
+
+
+ + + + + Comparison Bechmarks + + + Next.js Build Time and Build Size (github action - ubuntu-latest) + + + + + + + + + Devup-ui + + + 1.0.18 + + + + + + Next.js Build TIme{" "} + + + + + 18.23s + + + + + + Bulid Size + + + + + 54.75MB + + + + + + + + + Chakra UI + + + 3.27.0 + + + + + + Bulid Time + + + 29.99s + + + + + Bulid Size + + + 200.39MB + + + + + + + + Mui + + + 7.3.2 + + + + + + Bulid Time + + + 22.21s + + + + + Bulid Size + + + 89.87MB + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 21.61s + + + + + Bulid Size + + + 64.30MB + + + + + + + + + + Tailwind CSS + + + 4.1.13 + + + + + + Bulid Time + + + 20.22s + + + + + Bulid Size + + + 54.76MB + + + + + + + + panda CSS + + + 1.3.1 + + + + + + Bulid Time + + + 22.01s + + + + + Bulid Size + + + 59.53MB + + + + + + + + styleX + + + 0.15.4 + + + + + + Bulid Time + + + 38.97s + + + + + Bulid Size + + + 72.72MB + + + + + + + + vanilla extract + + + 1.17.4 + + + + + + Bulid Time + + + 20.09s + + + + + Bulid Size + + + 56.61MB + + + + + + + + + + + + + Features + + + Devup UI offers a performance-optimized CSS-in-JS system, theme typing,
and amazing features for faster and safer development. +
+
+ + + + + + + Zero Runtime + + + A futuristic design that eliminates the root causes of performance degradation. + + + + + + + + Top Performance + + + The fastest build speed and the smallest bundle size among CSS-in-JS solutions. + + + + + + + + + + Type Safety + + + Enhanced DX with typing-based support. + + + + + + + + Figma Plugin + + + A Figma plugin enabling safer and faster development.{" "} + + + + + + Go Figma Community + + + + + + + +
+
+ + + + + + + Join our community + + + Join our Discord and help build the future of frontend with CSS-in-JS!{" "} + + + + + + + Open KakaoTalk + + + + + + + + Join our Discord + + + + + + + + +
+ + + + + 상호: (주)데브파이브 | 대표자명: 오정민 | 사업자등록번호: 868-86-03159
주소: 경기 고양시 덕양구 마상로140번길 81 4층 +
+ + Copyright © 2021-2024 데브파이브. All Rights Reserved.{" "} + +
+
+
+ + + + +
+ + Docs + +
+
+ + Components + +
+
+ + Showcase + +
+
+ + Team + +
+
+ + Stroybook + + +
+ + + + + + +
+
+
+
diff --git a/fixtures/plugin-answers/devup-ui-landing/pure-tablet.tsx b/fixtures/plugin-answers/devup-ui-landing/pure-tablet.tsx new file mode 100644 index 00000000..109eceaf --- /dev/null +++ b/fixtures/plugin-answers/devup-ui-landing/pure-tablet.tsx @@ -0,0 +1,710 @@ +
+ + + + + + + + + + + Zero + + {" "}Config{" "} + + Zero + + {" "}FOUC{" "} + + Zero + + {" "}Runtime
CSS in JS Preprocessor +
+ + Building the Future of CSS-in-JS Analyze all possible scenarios at the fastest speed and style with optimal performance. + +
+ + + + + Get started + + + + + + + + + + Star + + +
+ + 36 + +
+
+ + + + Sponsor + + +
+
+
+ + + + + Comparison Bechmarks + + + Next.js Build Time and Build Size (github action - ubuntu-latest) + + + + + + + + Devup-ui + + + 1.0.18 + + + + + + Next.js Build TIme{" "} + + + + + 18.2s + + + + + + Bulid Size + + + + + 54.7MB + + + + + + + + + + Chakra UI + + + 3.27.0 + + + + + + Bulid Time + + + 29.9s + + + + + Bulid Size + + + 200.4MB + + + + + + + + Mui + + + 7.3.2 + + + + + + Bulid Time + + + 22.2s + + + + + Bulid Size + + + 84.3MB + + + + + + + + Kuma UI + + + 1.5.9 + + + + + + Bulid Time + + + 20.6s + + + + + Bulid Size + + + 60.3B + + + + + + + + Tailwind CSS + + + 0.0.0 + + + + + + Bulid Time + + + 0s + + + + + Bulid Size + + + 0MB + + + + + + + + panda CSS + + + 0.0.0 + + + + + + Bulid Time + + + 0s + + + + + Bulid Size + + + 0MB + + + + + + + + styleX + + + 0.0.0 + + + + + + Bulid Time + + + 0s + + + + + Bulid Size + + + 0MB + + + + + + + + vanilla extract + + + 0.0.0 + + + + + + Bulid Time + + + 0s + + + + + Bulid Size + + + 0MB + + + + + + + + + + + + + Features + + + Devup UI offers a performance-optimized CSS-in-JS system, theme typing,
and amazing features for faster and safer development. +
+
+ + + + + + + Zero Runtime + + + A futuristic design that eliminates the root causes of performance degradation. + + + + + + + + Top Performance + + + The fastest build speed and the smallest bundle size among CSS-in-JS solutions. + + + + + + + + + + Type Safety + + + Enhanced DX with typing-based support. + + + + + + + + Figma Plugin + + + A Figma plugin enabling safer and faster development.{" "} + + + + + + Go Figma Community + + + + + + + + Go Figma Community + + + + + + + +
+
+ + + + + + + Join our community + + + Join our Discord and help build the future of frontend with CSS-in-JS!{" "} + + + + + + + Open KakaoTalk + + + + + + + + Join our Discord + + + + + + + + + + + + + + 상호: (주)데브파이브 | 대표자명: 오정민 | 사업자등록번호: 868-86-03159
주소: 경기 고양시 덕양구 마상로140번길 81 4층 +
+ + Copyright © 2021-2024 데브파이브. All Rights Reserved.{" "} + +
+
+
+
diff --git a/fixtures/plugin-answers/devup-ui-landing/tablet.tsx b/fixtures/plugin-answers/devup-ui-landing/tablet.tsx new file mode 100644 index 00000000..a4f8093c --- /dev/null +++ b/fixtures/plugin-answers/devup-ui-landing/tablet.tsx @@ -0,0 +1,589 @@ +
+ + + + + + + + Zero + + {" "}Config{" "} + + Zero + + {" "}FOUC{" "} + + Zero + + {" "}Runtime
CSS in JS Preprocessor +
+ + Building the Future of CSS-in-JS Analyze all possible scenarios at the fastest speed and style with optimal performance. + +
+
diff --git a/fixtures/plugin-answers/grid/pure.tsx b/fixtures/plugin-answers/grid/pure.tsx new file mode 100644 index 00000000..1fb7ae44 --- /dev/null +++ b/fixtures/plugin-answers/grid/pure.tsx @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/fixtures/plugin-answers/keyframes/pure.tsx b/fixtures/plugin-answers/keyframes/pure.tsx new file mode 100644 index 00000000..0c9fb1ec --- /dev/null +++ b/fixtures/plugin-answers/keyframes/pure.tsx @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/plugin-answers/notice/components.tsx b/fixtures/plugin-answers/notice/components.tsx new file mode 100644 index 00000000..0e6c9881 --- /dev/null +++ b/fixtures/plugin-answers/notice/components.tsx @@ -0,0 +1,881 @@ +export interface HeaderItemProps { + property1: '기본' | 'selected' +} + +export function HeaderItem({ property1 }: HeaderItemProps) { + return ( +
+ + menu + +
+ ) +} + +export function LanguageButton() { + return ( + + + + KOR + + + + ) +} + +export interface HeaderProps { + property1: 'scroll' | 'transparent' | 'mobileTranspa' | 'mobileScroll' +} + +export function Header({ property1 }: HeaderProps) { + return ( + + {property1 === "scroll" && } + {property1 === "transparent" && ( + + + {/* */} + + + + + + + + + + + + )} + {(property1 === "mobileTranspa" || property1 === "mobileScroll") && ( + + + + + )} + + ) +} + +export interface TabProps { + /** text */ + children: React.ReactNode +} + +export function Tab({ children }: TabProps) { + return ( + + + {children} + + + + ) +} + +export interface IconsProps { + Property1: 'plus' | 'minus' | 'write' | 'left' | 'right' | 'bottom' | 'top' | 'leftSkip' | 'rightSkip' | 'globe' | 'language' | 'drag' | 'search' | 'delete' | 'document' | 'caution' | 'folder' | 'import' | 'folderPlus' | 'hamburger' | 'close' | 'imagePlus' | 'image' | 'export' | 'addCircle' | 'meatball' | 'code' | 'info' | 'public' | 'lock' | 'unlock' | 'comment' | 'comment-fill' | 'copy' | 'duplicate' | 'paste' | 'cut' | 'folderMinus' | 'publicOutline' | 'privacy' | 'Security' | 'print' | 'newDocu' | 'save' | 'sort' | 'history' | 'clip' | 'upload' | 'desktop' | 'mobile' | 'home' | 'download' | 'page' | 'closedEye' | 'setting' | 'setting-fill' +} + +export function Icons({ Property1 }: IconsProps) { + return ( + <> + {Property1 === "plus" && ( + + )} + {Property1 === "minus" && ( + + )} + {Property1 === "left" && ( + + )} + {Property1 === "right" && ( + + )} + {Property1 === "bottom" && ( + + )} + {Property1 === "top" && ( + + )} + {Property1 === "leftSkip" && ( + + )} + {Property1 === "rightSkip" && ( + + )} + {Property1 === "write" && ( + + )} + {Property1 === "globe" && ( + + )} + {Property1 === "language" && ( + + )} + {Property1 === "drag" && ( + + )} + {Property1 === "search" && ( + + )} + {Property1 === "delete" && ( + + )} + {Property1 === "page" && ( + + )} + {Property1 === "document" && ( + + )} + {Property1 === "newDocu" && } + {Property1 === "caution" && ( + + )} + {Property1 === "folder" && ( + + )} + {Property1 === "folderPlus" && } + {Property1 === "folderMinus" && ( + + )} + {Property1 === "import" && ( + + )} + {Property1 === "export" && ( + + )} + {Property1 === "image" && ( + + )} + {Property1 === "imagePlus" && ( + + )} + {Property1 === "hamburger" && ( + + )} + {Property1 === "close" && ( + + )} + {Property1 === "addCircle" && ( + + )} + {Property1 === "meatball" && } + {Property1 === "code" && ( + + )} + {Property1 === "info" && ( + + )} + {Property1 === "public" && ( + + )} + {Property1 === "publicOutline" && ( + + )} + {Property1 === "lock" && ( + + )} + {Property1 === "unlock" && ( + + )} + {Property1 === "comment" && ( + + )} + {Property1 === "comment-fill" && ( + + )} + {Property1 === "copy" && ( + + )} + {Property1 === "duplicate" && ( + + )} + {Property1 === "paste" && ( + + )} + {Property1 === "cut" && ( + + )} + {Property1 === "privacy" && ( + + )} + {Property1 === "Security" && } + {Property1 === "closedEye" && ( + + )} + {Property1 === "print" && ( + + )} + {Property1 === "save" && ( + + )} + {Property1 === "history" && } + {Property1 === "sort" && ( + + )} + {Property1 === "clip" && ( + + )} + {Property1 === "upload" && ( + + )} + {Property1 === "desktop" && } + {Property1 === "mobile" && } + {Property1 === "home" && ( + + )} + {Property1 === "download" && ( + + )} + {Property1 === "setting" && ( + + )} + {Property1 === "setting-fill" && ( + + )} + + ) +} + +export function Pagination() { + return ( +
+ + 1 + +
+ ) +} + +export interface ThemeButtonProps { + property1: 'Dark' | 'Light' +} + +export function ThemeButton({ property1 }: ThemeButtonProps) { + return ( + + + + + + ) +} + +export interface FooterProps { + property1: 'desktop' | 'mobile' | 'tablet' +} + +export function Footer({ property1 }: FooterProps) { + return ( + + {property1 === "desktop" && ( + + + + {/* */} + + + + + + + + + 라온피플(주) + + + 대표이사 : 이석중 주소 : 13840 경기 과천시 과천대로7나길 60 과천어반허브, C동 5층/6층
TEL : 1899-3058
FAX : 02-3318-3351
이메일 : sales@laonpeople.com{" "} +
+
+
+ + + SWING EZ + + + VTrack + + + Notice + + + Contact + + +
+ )} + {property1 === "tablet" && ( + + + + SWING EZ + + + VTrack + + + Notice + + + Contact + + + + + + + 라온피플(주) + + + 대표이사 : 이석중 주소 : 13840 경기 과천시 과천대로7나길 60 과천어반허브, C동 5층/6층
TEL : 1899-3058
FAX : 02-3318-3351
이메일 : sales@laonpeople.com{" "} +
+
+
+
+ )} + {property1 === "mobile" && ( + + + + SWING EZ + + + VTrack + + + Notice + + + Contact + + + +
+ {/* */} + + + + + +
+ + + 라온피플(주) + + + 대표이사 : 이석중 주소 : 13840 경기 과천시 과천대로7나길 60 과천어반허브,
C동 5층/6층
TEL : 1899-3058
FAX : 02-3318-3351
이메일 : sales@laonpeople.com{" "} +
+
+
+
+ )} +
+ ) +} diff --git a/fixtures/plugin-answers/notice/pure.tsx b/fixtures/plugin-answers/notice/pure.tsx new file mode 100644 index 00000000..eeb71751 --- /dev/null +++ b/fixtures/plugin-answers/notice/pure.tsx @@ -0,0 +1,299 @@ + + + + + + Notice + + + + + 공지사항 + + + + + + + + + + +
+ + SWING EZ + +
+
+ + VTrack + +
+
+ + Notice + +
+
+ + Contact + +
+
+
+ + + + KOR + + + +
+
+ + + + + + + 전체 + + + + + + 공지 + + + + + 이벤트 + + + + + 업데이트 + + + + + 매뉴얼 + + + + + + 라멘집 + + + + + +
+ + + + + ‘라멘집’ + + + {" "}검색 결과가 없습니다. + + + + 검색어가 올바른지 확인해주세요. + + +
+
+ +
+ + 1 + +
+
+
+
+ + + + + + + + + + + + + + + + + 라온피플(주) + + + 대표이사 : 이석중 주소 : 13840 경기 과천시 과천대로7나길 60 과천어반허브, C동 5층/6층
TEL : 1899-3058
FAX : 02-3318-3351
이메일 : sales@laonpeople.com{" "} +
+
+
+ + + SWING EZ + + + VTrack + + + Notice + + + Contact + + +
+
+
diff --git a/fixtures/plugin-answers/notice/responsive.tsx b/fixtures/plugin-answers/notice/responsive.tsx new file mode 100644 index 00000000..b0afc011 --- /dev/null +++ b/fixtures/plugin-answers/notice/responsive.tsx @@ -0,0 +1,246 @@ +import { Box, Center, Flex, Image, Text, VStack } from '@devup-ui/react' +import { Footer } from '@/components/Footer' +import { Header } from '@/components/Header' +import { Pagination } from '@/components/Pagination' +import { Tab } from '@/components/Tab' + +export default function NoticePage() { + return ( + + + + + + Notice + + + + + 공지사항 + + + + + + + + + Notice + + + + + 공지사항 + + + + + + + + + + +
+ + + + + + + + + + + + + + 라멘집 + + + + + +
+ + + + + ‘라멘집’ + + + {" "}검색 결과가 없습니다. + + + + 검색어가 올바른지 확인해주세요. + + +
+
+ + + +
+
+ + + + + + 라멘집 + + + + + + + + + + + + +
+ + + + + ‘라멘집’ + + + {" "}검색 결과가 없습니다. + + + + 검색어가 올바른지 확인해주세요. + + +
+
+ + + + + +
+
+
+ + ) +} diff --git a/fixtures/plugin-answers/notice/with-components.tsx b/fixtures/plugin-answers/notice/with-components.tsx new file mode 100644 index 00000000..ca50e136 --- /dev/null +++ b/fixtures/plugin-answers/notice/with-components.tsx @@ -0,0 +1,104 @@ + + + + + + Notice + + + + + 공지사항 + + + + + +
+ + + + + + + + + + + + + + 라멘집 + + {/* */} + + + + +
+ + + + + ‘라멘집’ + + + {" "}검색 결과가 없습니다. + + + + 검색어가 올바른지 확인해주세요. + + +
+
+ + + +
+
+
+ diff --git a/fixtures/plugin-answers/popup/pure-desktop.tsx b/fixtures/plugin-answers/popup/pure-desktop.tsx new file mode 100644 index 00000000..ff7a92f6 --- /dev/null +++ b/fixtures/plugin-answers/popup/pure-desktop.tsx @@ -0,0 +1,55 @@ + + + + + + + + 안내 + + + + + 어려운 글을 쉽게 바꿔 주는 서비스 {"'"}온글{"'"}은
2026년 하반기부터 정식 서비스로 찾아올
예정입니다.{" "} +
+ + 온글의 소식을 가장 먼저 받아보고 싶다면
아래 버튼을 눌러 주세요. +
+
+
+
+ + 서비스 소식 알림 받기 + +
+
+ + + + 오늘 하루 그만 보기 + + + + + + 닫기 + + + +
+
diff --git a/fixtures/plugin-answers/popup/pure-mobile.tsx b/fixtures/plugin-answers/popup/pure-mobile.tsx new file mode 100644 index 00000000..f368e8bd --- /dev/null +++ b/fixtures/plugin-answers/popup/pure-mobile.tsx @@ -0,0 +1,67 @@ + + + + + + + + 안내 + + + + + 어려운 글을 쉽게 바꿔 주는 서비스 {"'"}온글{"'"}은
2026년 하반기부터 정식 서비스로 찾아올
예정입니다.{" "} +
+ + 온글의 소식을 가장 먼저 받아보고 싶다면
아래 버튼을 눌러 주세요. +
+
+
+
+ + 서비스 소식 알림 받기 + +
+
+ + + + 오늘 하루 그만 보기 + + + + + + 닫기 + + + +
+
diff --git a/fixtures/plugin-answers/popup/pure-tablet.tsx b/fixtures/plugin-answers/popup/pure-tablet.tsx new file mode 100644 index 00000000..65b34a21 --- /dev/null +++ b/fixtures/plugin-answers/popup/pure-tablet.tsx @@ -0,0 +1,55 @@ + + + + + + + + 안내 + + + + + 어려운 글을 쉽게 바꿔 주는 서비스 {"'"}온글{"'"}은
2026년 하반기부터 정식 서비스로 찾아올
예정입니다.{" "} +
+ + 온글의 소식을 가장 먼저 받아보고 싶다면
아래 버튼을 눌러 주세요. +
+
+
+
+ + 서비스 소식 알림 받기 + +
+
+ + + + 오늘 하루 그만 보기 + + + + + + 닫기 + + + +
+
diff --git a/fixtures/plugin-answers/popup/responsive.tsx b/fixtures/plugin-answers/popup/responsive.tsx new file mode 100644 index 00000000..edf680bb --- /dev/null +++ b/fixtures/plugin-answers/popup/responsive.tsx @@ -0,0 +1,154 @@ +import { Box, Center, Flex, Image, Text, VStack } from '@devup-ui/react' + +export default function PopupPage() { + return ( + + + + + + + + 안내 + + + + + 어려운 글을 쉽게 바꿔 주는 서비스 {"'"}온글{"'"}은
2026년 하반기부터 정식 서비스로 찾아올
예정입니다.{" "} +
+ + 온글의 소식을 가장 먼저 받아보고 싶다면
아래 버튼을 눌러 주세요. +
+
+
+
+ + 서비스 소식 알림 받기 + +
+
+ + + + 오늘 하루 그만 보기 + + + + + + 닫기 + + + +
+
+ ) +} diff --git a/fixtures/plugin-answers/report/pure.tsx b/fixtures/plugin-answers/report/pure.tsx new file mode 100644 index 00000000..a14b4d95 --- /dev/null +++ b/fixtures/plugin-answers/report/pure.tsx @@ -0,0 +1,128 @@ +
+ + + + + 상세한 분석 리포트 + + + 과학적 데이터를 기반으로 한 맞춤형 분석 결과를 제공합니다 + + + + + +
+ +
+ + ADHD 성향 분석 + +
+ + 주의력, 충동성, 과잉행동 등 주요 영역별 점수를 시각화하여 제공합니다. + +
+ + +
+ +
+ + 일상생활 패턴 + +
+ + 업무, 학업, 대인관계 등 일상 영역별 영향도를 분석합니다. + +
+ + +
+ +
+ + 맞춤형 전략 제안 + +
+ + 개인의 특성을 고려한 구체적인 대처 전략을 제시합니다. + +
+
+
+
diff --git a/harness/render/.gitignore b/harness/render/.gitignore new file mode 100644 index 00000000..f6758313 --- /dev/null +++ b/harness/render/.gitignore @@ -0,0 +1,13 @@ +# Generated by scripts/acquire.py from the live Figma file; never committed. +src/screens/ +public/icons/ +public/images/ +devup.json +themes/ +targets.json +# Build and render outputs. +dist/ +out/ +node_modules/ +df/ +__pycache__/ diff --git a/harness/render/index.html b/harness/render/index.html new file mode 100644 index 00000000..0f04e951 --- /dev/null +++ b/harness/render/index.html @@ -0,0 +1,30 @@ + + + + + + devup-mcp render harness + + + + + + + + +
+ + + diff --git a/harness/render/package-lock.json b/harness/render/package-lock.json new file mode 100644 index 00000000..cf54e0b2 --- /dev/null +++ b/harness/render/package-lock.json @@ -0,0 +1,1988 @@ +{ + "name": "devup-mcp-render-harness", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "devup-mcp-render-harness", + "dependencies": { + "@devup-ui/react": "^1.0.41", + "@devup-ui/reset-css": "^1.0.28", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@devup-ui/vite-plugin": "^1.0.41", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.4.0", + "playwright": "^1.54.0", + "pngjs": "^7.0.0", + "typescript": "^5.8.0", + "vite": "^6.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@devup-ui/plugin-utils": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@devup-ui/plugin-utils/-/plugin-utils-1.0.14.tgz", + "integrity": "sha512-XM/gW3K5L9DvRqdMdXLO7E6RKTTKflu2H4bRSw56nvcUP8owH2iYxd3j/D7W8774so5WPCn1QmhTf7XYfznS3w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@devup-ui/react": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@devup-ui/react/-/react-1.0.41.tgz", + "integrity": "sha512-RX8swgCcGRQN0WMQDtfgxMeis9iH7ALfQROJ9v5XdGzB1gLfrORPBsWHFzG4Qz1ZB6ULVp/Z0L0cTnW1UxP4Hw==", + "license": "Apache-2.0", + "dependencies": { + "csstype-extra": "latest", + "react": "^19.2" + }, + "peerDependencies": { + "csstype-extra": "*", + "react": "*" + } + }, + "node_modules/@devup-ui/reset-css": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@devup-ui/reset-css/-/reset-css-1.0.28.tgz", + "integrity": "sha512-81dZ0KEsY++tzBUTVJIbTNiTIdVKOFeTUhe32MiN66Y4u1Xm7zNrfCBpV0oXkBcictRjXLXwEDn0N27Uv7VdCA==", + "license": "Apache-2.0", + "dependencies": { + "@devup-ui/react": "^1.0.40" + }, + "peerDependencies": { + "@devup-ui/react": "^1.0.40" + } + }, + "node_modules/@devup-ui/vite-plugin": { + "version": "1.0.69", + "resolved": "https://registry.npmjs.org/@devup-ui/vite-plugin/-/vite-plugin-1.0.69.tgz", + "integrity": "sha512-HW+YjO53cSmu+qWQ2r6Tx5LmUb7XYPaJ9A3e/sH5Pdtg16lUMAAfbeMf5lSc1Vp9HU2l5mXE97vYCsTbwbnSbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@devup-ui/plugin-utils": "^1.0.13", + "@devup-ui/wasm": "^1.0.78" + }, + "peerDependencies": { + "@devup-ui/wasm": "*", + "vite": "*" + } + }, + "node_modules/@devup-ui/wasm": { + "version": "1.0.79", + "resolved": "https://registry.npmjs.org/@devup-ui/wasm/-/wasm-1.0.79.tgz", + "integrity": "sha512-YHjFFx7LhANJbB96WhtNqzIb1IiT8hX+oi9YxITq+UxYYrqSOBfQAy0pYMo7KqZ1l2SAzNeZ5NmzbO9QQz2IUA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype-extra": { + "version": "0.1.35", + "resolved": "https://registry.npmjs.org/csstype-extra/-/csstype-extra-0.1.35.tgz", + "integrity": "sha512-pEJ+ZP1nE0TT5E5XXRuNBiU44O2CCwgm1zd0ogXOX/We8GxX++T7KhThrQaSO2V2Yvdmh2OsqBWLH0+OQTUXKA==", + "license": "Apache-2.0", + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/harness/render/package.json b/harness/render/package.json new file mode 100644 index 00000000..e49e108d --- /dev/null +++ b/harness/render/package.json @@ -0,0 +1,26 @@ +{ + "name": "devup-mcp-render-harness", + "private": true, + "type": "module", + "scripts": { + "acquire": "python scripts/acquire.py", + "build": "vite build", + "render": "node scripts/render.mjs" + }, + "dependencies": { + "@devup-ui/react": "^1.0.41", + "@devup-ui/reset-css": "^1.0.28", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@devup-ui/vite-plugin": "^1.0.41", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.4.0", + "playwright": "^1.54.0", + "pngjs": "^7.0.0", + "typescript": "^5.8.0", + "vite": "^6.3.0" + } +} diff --git a/harness/render/probe_manifest_gap.py b/harness/render/probe_manifest_gap.py new file mode 100644 index 00000000..1c087605 --- /dev/null +++ b/harness/render/probe_manifest_gap.py @@ -0,0 +1,57 @@ +"""Every asset a module points at, against the manifest that says where they are. + +The generated code refers to an asset by path; the manifest is what tells a +caller to export it. Anything the code points at that the manifest does not +list can never be written, and the screen renders with a hole where the +picture should be. +""" + +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "scripts")) +from acquire import HARNESS, Server, url_of # noqa: E402 + + +def referenced(module_path): + source = open(os.path.join(HARNESS, module_path), encoding="utf-8").read() + return sorted(set(re.findall(r"/(?:icons|images)/[^\"')]+", source))) + + +def main(): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + manifest = json.load(open(os.path.join(HARNESS, "targets.json"), encoding="utf-8")) + wanted = sys.argv[1:] + server = Server() + try: + for target in manifest["targets"]: + if wanted and not any(target["name"].startswith(name) for name in wanted): + continue + body = server.export({"url": url_of(target["frame"]), "outputs": ["assetManifest"], + "scope": "node", "delivery": "inline"}, allow_error=True) + if body.get("error"): + print(f"{target['name']}: manifest refused: {body['error']}") + continue + assets = (body.get("assetManifest") or {}).get("assets", []) + listed = {asset.get("path") for asset in assets if asset.get("path")} + by_status = {} + for asset in assets: + by_status[asset.get("status")] = by_status.get(asset.get("status"), 0) + 1 + points_at = referenced(target["module"]) + unlisted = [path for path in points_at if path not in listed] + absent = [path for path in points_at if not os.path.exists(os.path.join(HARNESS, "public" + path))] + print(f"{target['name']}: code points at {len(points_at)}, manifest lists {len(listed)} {by_status}") + for path in unlisted: + print(f" not in the manifest: {path}") + for path in absent: + if path not in unlisted: + print(f" listed but not on disk: {path}") + finally: + server.close() + + +if __name__ == "__main__": + main() diff --git a/harness/render/probe_refusal.py b/harness/render/probe_refusal.py new file mode 100644 index 00000000..f0d1f96a --- /dev/null +++ b/harness/render/probe_refusal.py @@ -0,0 +1,61 @@ +"""What the response actually carried for the asset export that was refused.""" + +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "scripts")) +from acquire import Server, TARGETS, url_for # noqa: E402 + +ASSET = sys.argv[1] if len(sys.argv) > 1 else "793:8340:fills:0" +FRAME = sys.argv[4] if len(sys.argv) > 4 else TARGETS["landing"]["frames"][0] +FORMAT = sys.argv[2] if len(sys.argv) > 2 else "png" +SCALE = int(sys.argv[3]) if len(sys.argv) > 3 else 1 + + +def main(): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + target = TARGETS["landing"] + server = Server() + try: + response = server.call_raw("tools/call", { + "name": "devup_figma_export", + "arguments": { + "url": url_for(target, FRAME), "outputs": ["assetManifest"], "scope": "node", + "assetRequests": [{"assetId": ASSET, "format": FORMAT, "scale": SCALE}], "refresh": True, + "delivery": "inline", "includeDiagnostics": True, + }, + }) + finally: + server.close() + if "error" in response: + error = response["error"] + print(" message:", error.get("message")) + data = error.get("data") or {} + print(" data:", json.dumps(data, ensure_ascii=False, indent=2)[:3000]) + return + dump = os.path.join(os.environ.get("TEMP", "."), "opencode", "probe.json") + with open(dump, "w", encoding="utf-8") as handle: + json.dump(response, handle, ensure_ascii=False) + print(" raw response saved to", dump) + result = response["result"] + print(" isError:", result.get("isError"), "content kinds:", [p.get("type") for p in result.get("content", [])]) + text = "".join(p.get("text", "") for p in result.get("content", []) if p.get("type") == "text") + try: + body = json.loads(text) + except json.JSONDecodeError: + print(" text is not JSON:", text[:1500]) + return + print(" body keys:", list(body.keys())) + manifest = body.get("assetManifest") or {} + assets = manifest.get("assets", []) + print(" assets:", len(assets), "diagnostics:", json.dumps(body.get("diagnostics"), ensure_ascii=False)[:1500]) + for asset in assets: + print(" -", asset.get("assetId"), asset.get("status"), "bytes:", asset.get("byteLength"), "mime:", asset.get("mimeType"), "error:", asset.get("errorCode"), "path:", asset.get("path"), "hasData:", bool(asset.get("dataBase64"))) + stats = body.get("stats") or manifest.get("stats") + print(" stats:", json.dumps(stats, ensure_ascii=False)[:800]) + + +if __name__ == "__main__": + main() diff --git a/harness/render/probe_theme.py b/harness/render/probe_theme.py new file mode 100644 index 00000000..c5353f70 --- /dev/null +++ b/harness/render/probe_theme.py @@ -0,0 +1,50 @@ +"""What `$primary` resolves to for one frame, at node scope and at file scope. + +The harness renders with a file-scope theme so that every typography token a +screen uses is defined. This file holds several brands' collections, though, +and more than one defines `primary` - so file scope has to pick one. This +prints what each scope resolves, and what it reported while doing it. +""" + +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "scripts")) +from acquire import HARNESS, Server, url_of # noqa: E402 + +FRAME = sys.argv[1] if len(sys.argv) > 1 else "422:6865" +TOKENS = ("primary", "containerBackground", "background", "text", "border") + + +def main(): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + server = Server() + try: + for scope in ("node", "file"): + out = f"out/theme-{scope}-{FRAME.replace(':', '-')}.json" + body = server.export({"url": url_of(FRAME), "outputs": ["devupJson"], "scope": scope, + "outputPaths": {"devupJson": out}, "includeDiagnostics": True}) + path = os.path.join(HARNESS, out) + if not os.path.exists(path): + print(f"{scope}: no theme written ({body.get('status')})") + continue + colors = json.load(open(path, encoding="utf-8"))["theme"].get("colors", {}) + modes = list(colors) + first = colors.get(modes[0], {}) if modes else {} + print(f"{scope} scope: status={body.get('status')} modes={modes}") + print(f" counts={body.get('themeCounts')}") + print(" " + " ".join(f"{name}={first.get(name)}" for name in TOKENS)) + conflicts = [d for d in (body.get("diagnostics") or []) + if "CONFLICT" in (d.get("code") or "")] + shown = [d for d in conflicts if "primary" in json.dumps(d, ensure_ascii=False)] + print(f" conflicts={len(conflicts)} mentioning primary={len(shown)}") + for diagnostic in shown[:4]: + print(f" {diagnostic.get('message')}") + finally: + server.close() + + +if __name__ == "__main__": + main() diff --git a/harness/render/scripts/acquire.py b/harness/render/scripts/acquire.py new file mode 100644 index 00000000..c47c74b6 --- /dev/null +++ b/harness/render/scripts/acquire.py @@ -0,0 +1,289 @@ +"""Acquire what the render harness needs from a running devup-mcp. + +For each target in TARGETS: the generated module, the theme, every asset the +module refers to, and Figma's own PNG of each frame at its drawn size. All of +it lands under the harness, none of it is committed. + + python scripts/acquire.py [target-name ...] + +The server is `~/.cargo/bin/devup-mcp.exe` over stdio, banking its Figma +calls in `fixtures/local-call-bank` so a re-run costs nothing already paid +for. Write roots: the server only writes under its current directory, so it +is started in the harness directory and every output path is relative to it. +""" + +import json +import os +import subprocess +import sys +import threading +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +HARNESS = os.path.dirname(HERE) +REPO = os.path.dirname(os.path.dirname(HARNESS)) +EXE = os.path.expanduser(r"~\.cargo\bin\devup-mcp.exe") +BANK = os.path.join(REPO, "fixtures", "local-call-bank") +FILE_KEY = "f1AJyo27afkkr6U9PhnWSu" + +# name: the module to render, the frames whose PNGs it is compared against +# at their own widths, and which output the module is. +# "responsiveTsx": one module for every width, taken from the first frame +# "tsx": one module per frame, each compared at its own width +TARGETS = { + "popup": {"output": "responsiveTsx", "frames": ["422:5682", "422:5705", "422:5728"]}, + "keyframes": {"output": "tsx", "frames": ["458:2021"]}, + "grid": {"output": "tsx", "frames": ["429:1966"]}, + "report": {"output": "tsx", "frames": ["446:1971"]}, + "notice": {"output": "tsx", "frames": ["422:6914", "422:7088", "422:6865"]}, + "about": {"output": "tsx", "frames": ["422:3376", "422:3180", "422:2987"]}, + # The devup-ui.com landing page, in its own file: one brand, one design + # system, and a deployed implementation to compare against as well as the + # plugin's answers. Mobile / tablet / PC, in ascending width as `about` + # and `notice` are. + "landing": {"output": "tsx", "frames": ["833:3640", "833:3322", "832:2975"], + "file": "JVj6yCOUnF45JQAPvXLA4p", "name": "Devup-UI"}, +} + + +class Server: + def __init__(self): + environment = dict(os.environ) + environment["DEVUP_FIGMA_CALL_CACHE"] = BANK + self.proc = subprocess.Popen( + [EXE], cwd=HARNESS, env=environment, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, encoding="utf-8", bufsize=1, + ) + self.lines = [] + self.next_id = 1 + threading.Thread(target=self._read, daemon=True).start() + self.call_raw("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "render-harness", "version": "0"}}) + self._send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + + def _read(self): + for line in self.proc.stdout: + self.lines.append(line) + + def _send(self, message): + self.proc.stdin.write(json.dumps(message) + "\n") + self.proc.stdin.flush() + + def call_raw(self, method, params, limit=1800): + request_id = self.next_id + self.next_id += 1 + self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + deadline = time.time() + limit + seen = 0 + while time.time() < deadline: + while seen < len(self.lines): + raw = self.lines[seen].strip() + seen += 1 + if not raw: + continue + try: + message = json.loads(raw) + except json.JSONDecodeError: + continue + if message.get("id") == request_id: + return message + if self.proc.poll() is not None: + raise SystemExit("devup-mcp exited") + time.sleep(0.2) + raise SystemExit(f"no response to {method} within {limit}s") + + def export(self, arguments, allow_error=False): + # Asset naming is left to the server's own default, so what is + # measured here is what a caller actually receives rather than + # something this harness asked for. + response = self.call_raw("tools/call", {"name": "devup_figma_export", "arguments": arguments}) + if "error" in response: + message = response["error"].get("message") + if allow_error: + return {"error": message} + raise SystemExit(f"export refused: {message}") + text = "".join(part.get("text", "") for part in response["result"].get("content", []) + if part.get("type") == "text") + return json.loads(text) + + def close(self): + try: + self.proc.kill() + self.proc.wait(timeout=10) + except Exception: + pass + + +def family_of(screen): + """Which target a screen was acquired for, or `None` for a screen this + does not produce - a hand-made comparison screen such as `popup-answer`, + which re-acquiring `popup` must leave alone.""" + if screen in TARGETS: + return screen + head = screen.rsplit("-", 2)[0] if screen.count("-") >= 2 else screen + return head if head in TARGETS else None + + +def url_of(node_id, file_key=FILE_KEY, file_name="devup-Test"): + return f"https://www.figma.com/design/{file_key}/{file_name}?node-id={node_id.replace(':', '-')}" + + +def url_for(target, node_id): + """The URL of a frame in whichever file its target lives in.""" + return url_of(node_id, target.get("file", FILE_KEY), target.get("name", "devup-Test")) + + +def asset_path(name, asset): + """Where the generated module expects the asset: `/icons/.svg` for + a vector, `/images/.png` for an image fill (`-` past the + first fill), as `codegen::style` names them.""" + if asset["sourceKind"] == "vector-node": + return f"public/icons/{name}.svg", "svg" + index = asset["field"].rsplit("/", 1)[-1] + suffix = "" if index in ("0", "fills") else f"-{index}" + return f"public/images/{name}{suffix}.png", "png" + + +def acquire_theme(server, frame): + """The theme for every screen: `devup.json` at file scope, so a token any + screen uses is defined. A node-scope theme holds only the tokens of the + node it was asked for, and a screen rendered with another node's theme + loses every typography token it does not share - which is how the report + section's heading came out with no size and its titles without weight.""" + body = server.export({"url": url_of(frame), "outputs": ["devupJson"], "scope": "file", + "outputPaths": {"devupJson": "devup.json"}, "includeDiagnostics": True}) + print(f" theme (file scope): status={body.get('status')} quality={body.get('quality', {}).get('theme')} " + f"counts={body.get('themeCounts')}", flush=True) + + +def acquire(server, name, target, manifest): + output = target["output"] + frames = target["frames"] + for index, frame in enumerate(frames): + module_name = name if output == "responsiveTsx" else f"{name}-{frame.replace(':', '-')}" + module_path = f"src/screens/{module_name}.tsx" + reference_path = f"out/{module_name}-{frame.replace(':', '-')}.reference.png" if output == "responsiveTsx" else f"out/{module_name}.reference.png" + wants_module = output == "tsx" or index == 0 + scratch = f"out/{module_name}-{frame.replace(':', '-')}" + # Every output goes to a file: a file has no size limit, where an + # inline answer is capped at 1 MiB and a larger one is delivered as + # resources this does not read. + outputs = ["referencePng", "rawSnapshot"] + paths = {"referencePng": reference_path, "rawSnapshot": f"{scratch}.snapshot.json"} + theme_path = f"themes/{module_name}.json" + if wants_module: + outputs.append(output) + paths[output] = module_path + # The screen's own theme, at node scope. A file-scope theme holds + # every collection in the file, and this file holds several + # brands: more than one defines `primary`, so one of them wins and + # the rest render in the wrong brand's colour - the notice screen + # came out violet where Figma draws it blue. Scoped to the node, + # the same token resolves to that screen's own value and nothing + # conflicts. + outputs.append("devupJson") + paths["devupJson"] = theme_path + body = server.export({"url": url_for(target, frame), "outputs": outputs, "scope": "node", + "outputPaths": paths, "includeDiagnostics": True}) + print(f" {frame}: status={body.get('status')} quality={body.get('quality')}", flush=True) + # The module refers to assets by layer name; the manifest by node id. + with open(os.path.join(HARNESS, paths["rawSnapshot"]), encoding="utf-8") as handle: + snapshot = json.load(handle) + # The manifest is the one output that cannot be written to a file; + # alone it is small enough to arrive inline. + asset_manifest = server.export({"url": url_for(target, frame), "outputs": ["assetManifest"], "scope": "node", + "delivery": "inline"}).get("assetManifest") or {} + nodes = snapshot.get("nodes") or {} + requests = [] + for asset in asset_manifest.get("assets", []): + if asset.get("status") != "available": + continue + # The manifest says where the code refers to the asset; the + # fallback re-derives it from the layer name for a manifest that + # does not. + if asset.get("path"): + path = "public" + asset["path"] + fmt = "svg" if path.endswith(".svg") else "png" + else: + node = nodes.get(asset["nodeId"]) or {} + layer = (node.get("fields") or {}).get("name") or "Asset" + path, fmt = asset_path(layer, asset) + # Done when the file is there; a record without the file is stale. + if path in manifest["assets"] and os.path.exists(os.path.join(HARNESS, path)): + continue + manifest["assets"][path] = asset["assetId"] + requests.append({"assetId": asset["assetId"], "format": fmt, "scale": 1, "outputPath": path}) + for start in range(0, len(requests), 16): + batch = requests[start:start + 16] + # An artifact holds only the assets its own collection exported, + # so the bytes are asked for by URL; the node reads are replayed + # from the bank and only the export itself is new. + # The bytes go to their files whatever the delivery; the answer + # itself may be too large to inline, and is not read. + # `refresh`: the collection the process cached for this URL holds + # no exports, and the server asks for one that does. The node + # reads replay from the bank; only the export itself is new. + body = server.export({"url": url_for(target, frame), "outputs": ["assetManifest"], "scope": "node", + "assetRequests": batch, "refresh": True}, allow_error=True) + if body.get("error"): + # One node the server will not export refuses the whole call, + # and the other fifteen are lost with it. Asked for one at a + # time, the refusal is confined to the node that caused it and + # says which one that is. + print(f" assets {start + 1}-{start + len(batch)}: {body['error']}", flush=True) + refused = [] + for entry in batch: + one = server.export({"url": url_for(target, frame), "outputs": ["assetManifest"], "scope": "node", + "assetRequests": [entry], "refresh": True}, allow_error=True) + if one.get("error"): + refused.append(entry["assetId"]) + manifest["assets"].pop(entry["outputPath"], None) + if refused: + print(f" refused: {', '.join(refused)}", flush=True) + body = {} + missing = [entry["outputPath"] for entry in batch if not os.path.exists(os.path.join(HARNESS, entry["outputPath"]))] + print(f" assets {start + 1}-{start + len(batch)}: quality={body.get('quality', {}).get('assets')} missing={missing}", flush=True) + manifest["targets"].append({ + # One entry per frame; a responsive module is rendered once per + # frame, at that frame's size, so the entries share a screen. + "name": f"{module_name}-{frame.replace(':', '-')}" if output == "responsiveTsx" else module_name, + "screen": module_name, + "module": module_path, + "frame": frame, + "reference": reference_path, + "responsive": output == "responsiveTsx", + "theme": theme_path, + }) + + +def main(): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + wanted = sys.argv[1:] or list(TARGETS) + os.makedirs(os.path.join(HARNESS, "src", "screens"), exist_ok=True) + os.makedirs(os.path.join(HARNESS, "public", "icons"), exist_ok=True) + os.makedirs(os.path.join(HARNESS, "public", "images"), exist_ok=True) + os.makedirs(os.path.join(HARNESS, "out"), exist_ok=True) + manifest_path = os.path.join(HARNESS, "targets.json") + manifest = {"targets": [], "assets": {}} + if os.path.exists(manifest_path): + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + manifest["targets"] = [t for t in manifest["targets"] if family_of(t.get("screen", t["name"])) not in wanted] + server = Server() + try: + if not os.path.exists(os.path.join(HARNESS, "devup.json")) or "theme" in wanted: + wanted = [name for name in wanted if name != "theme"] + acquire_theme(server, TARGETS[wanted[0] if wanted else "popup"]["frames"][0]) + for name in wanted: + print(f"== {name}", flush=True) + acquire(server, name, TARGETS[name], manifest) + finally: + server.close() + with open(manifest_path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle, ensure_ascii=False, indent=2) + print(f"targets: {len(manifest['targets'])}, assets: {len(manifest['assets'])}") + + +if __name__ == "__main__": + main() diff --git a/harness/render/scripts/answer_diff.py b/harness/render/scripts/answer_diff.py new file mode 100644 index 00000000..16e239b9 --- /dev/null +++ b/harness/render/scripts/answer_diff.py @@ -0,0 +1,71 @@ +import re +import sys +from collections import Counter + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +KNOWN = [ + # asset names: per-node suffixes and variant names, settled with the author + (r'(src|maskImage)="[^"]*"', "asset-name"), + (r"maskImage=\"url\('[^']*'\)\"", "asset-name"), + # zero shorthand: the pinned corpus writes 0px, the newer plugin writes 0 + (r'borderRadius="[^"]*"', "radius-zero"), + # settled in this repo's favour by the render + (r'flexShrink="0"', "flexShrink"), + (r'alignSelf="flex-start"', "alignSelf"), + (r'\{" "\}(Config|FOUC)(
|\{" "\})', "headline-break"), + (r'Building the Future of CSS-in-JS', "subhead-break"), +] + + +def classify(line): + for pattern, tag in KNOWN: + if re.search(pattern, line): + return tag + return None + + +def lines(path): + out = [] + for raw in open(path, encoding="utf-8"): + s = raw.strip() + if not s or s.startswith("import ") or s.startswith("export function"): + continue + if s in ("return (", ")", "}", ");"): + continue + out.append(s) + return Counter(out) + + +def compare(ours_path, plug_path, label): + ours, plug = lines(ours_path), lines(plug_path) + only_ours, only_plug = ours - plug, plug - ours + print(f"== {label}: ours={sum(ours.values())} plugin={sum(plug.values())} " + f"onlyOurs={sum(only_ours.values())} onlyPlugin={sum(only_plug.values())}") + known = Counter() + for side, bag in (("ours", only_ours), ("plugin", only_plug)): + for line, n in bag.items(): + tag = classify(line) + if tag: + known[tag] += n + print(" known/settled:", dict(known)) + print(" -- ours only (unexplained):") + for line, n in sorted(only_ours.items()): + if not classify(line): + print(f" {n}x {line[:140]}") + print(" -- plugin only (unexplained):") + for line, n in sorted(only_plug.items()): + if not classify(line): + print(f" {n}x {line[:140]}") + print() + + +TARGETS = { + "pc": ("landing-832-2975", "pure-pc"), + "tablet": ("landing-833-3322", "pure-tablet"), + "mobile": ("landing-833-3640", "pure-mobile"), +} +for name in (sys.argv[1:] or TARGETS): + screen, answer = TARGETS[name] + compare(f"harness/render/src/screens/{screen}.tsx", + f"fixtures/plugin-answers/devup-ui-landing/{answer}.tsx", name) diff --git a/harness/render/scripts/bands.mjs b/harness/render/scripts/bands.mjs new file mode 100644 index 00000000..d41d0ddf --- /dev/null +++ b/harness/render/scripts/bands.mjs @@ -0,0 +1,77 @@ +// Where a screen's difference actually is. +// +// node scripts/bands.mjs [bandHeight] +// +// Compares the reference and the capture row band by row band and prints the +// bands that differ most, then writes the worst ones as a side-by-side crop +// (reference left, capture right) so the cause can be seen rather than +// guessed at. A tall screen shrunk to fit a screenshot shows nothing; a band +// of it at full size shows everything. + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { PNG } from "pngjs"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const TOLERANCE = 24; + +function changedAt(reference, actual, index) { + for (const channel of [0, 1, 2]) { + if (Math.abs(reference.data[index + channel] - actual.data[index + channel]) > TOLERANCE) return true; + } + return false; +} + +function main() { + const [name, bandArgument, cropArgument] = process.argv.slice(2); + if (!name) throw new Error("usage: node scripts/bands.mjs [bandHeight] [crops]"); + const band = Number(bandArgument ?? 100); + const crops = Number(cropArgument ?? 2); + const reference = PNG.sync.read(readFileSync(join(HARNESS, "out", `${name}.reference.png`))); + const actual = PNG.sync.read(readFileSync(join(HARNESS, "out", `${name}.actual.png`))); + if (reference.width !== actual.width || reference.height !== actual.height) { + console.log(`sizes differ: reference ${reference.width}x${reference.height}, actual ${actual.width}x${actual.height}`); + } + const height = Math.min(reference.height, actual.height); + const width = Math.min(reference.width, actual.width); + + const bands = []; + for (let top = 0; top < height; top += band) { + const bottom = Math.min(top + band, height); + let changed = 0; + for (let y = top; y < bottom; y += 1) { + for (let x = 0; x < width; x += 1) { + if (changedAt(reference, actual, (y * reference.width + x) * 4)) changed += 1; + } + } + bands.push({ top, bottom, ratio: changed / ((bottom - top) * width) }); + } + + const total = bands.reduce((sum, entry) => sum + entry.ratio * (entry.bottom - entry.top), 0) / height; + console.log(`${name}: ${width}x${height}, changed ${(total * 100).toFixed(2)}%`); + for (const entry of [...bands].sort((left, right) => right.ratio - left.ratio).slice(0, 12)) { + const bar = "#".repeat(Math.round(entry.ratio * 40)); + console.log(` y ${String(entry.top).padStart(5)}-${String(entry.bottom).padStart(5)} ${(entry.ratio * 100).toFixed(2).padStart(6)}% ${bar}`); + } + + for (const entry of [...bands].sort((left, right) => right.ratio - left.ratio).slice(0, crops)) { + const tall = entry.bottom - entry.top; + const sheet = new PNG({ width: width * 2 + 8, height: tall }); + sheet.data.fill(255); + for (let y = 0; y < tall; y += 1) { + for (let x = 0; x < width; x += 1) { + const from = ((entry.top + y) * reference.width + x) * 4; + for (const [source, offset] of [[reference, 0], [actual, width + 8]]) { + const to = (y * sheet.width + x + offset) * 4; + for (const channel of [0, 1, 2, 3]) sheet.data[to + channel] = source.data[from + channel]; + } + } + } + const out = join(HARNESS, "out", `${name}.band-${entry.top}.png`); + writeFileSync(out, PNG.sync.write(sheet)); + console.log(` wrote out/${name}.band-${entry.top}.png (reference | capture)`); + } +} + +main(); diff --git a/harness/render/scripts/boxes.mjs b/harness/render/scripts/boxes.mjs new file mode 100644 index 00000000..28cacde5 --- /dev/null +++ b/harness/render/scripts/boxes.mjs @@ -0,0 +1,84 @@ +// The DOM boxes down the top of a screen, to compare against Figma's own. +// +// node scripts/boxes.mjs [maxY] + +import { spawn, spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = 4206; + +async function main() { + const [name, maxArgument] = process.argv.slice(2); + const maxY = Number(maxArgument ?? 700); + const manifest = JSON.parse(readFileSync(join(HARNESS, "targets.json"), "utf8")); + const target = manifest.targets.find((entry) => entry.name === name || entry.name.startsWith(`${name}-`)); + if (!target) throw new Error(`no target ${name}`); + if (target.theme && existsSync(join(HARNESS, target.theme))) { + writeFileSync(join(HARNESS, "devup.json"), readFileSync(join(HARNESS, target.theme))); + } + const build = spawnSync("npx", ["vite", "build"], { cwd: HARNESS, encoding: "utf8", shell: process.platform === "win32" }); + if (build.status !== 0) throw new Error(`vite build failed:\n${build.stderr}`); + const preview = spawn("npx", ["vite", "preview", "--port", String(PORT), "--strictPort"], { cwd: HARNESS, shell: process.platform === "win32", stdio: "ignore" }); + const browser = await chromium.launch(); + try { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + if ((await fetch(`http://localhost:${PORT}/`)).ok) break; + } catch {} + await new Promise((done) => setTimeout(done, 200)); + } + const bytes = readFileSync(join(HARNESS, target.reference)); + const page = await browser.newPage({ + viewport: { width: bytes.readUInt32BE(16), height: Math.min(bytes.readUInt32BE(20), 2000) }, + }); + const screen = target.screen ?? target.name; + await page.goto(`http://localhost:${PORT}/?screen=${encodeURIComponent(screen)}`, { waitUntil: "networkidle" }); + await page.waitForSelector("body[data-ready]", { timeout: 30000 }); + const rows = await page.evaluate((limit) => { + const out = []; + const walk = (element, depth) => { + const rect = element.getBoundingClientRect(); + const top = rect.y + window.scrollY; + if (top > limit) return; + const style = getComputedStyle(element); + out.push({ + depth, + tag: element.tagName.toLowerCase(), + box: `${Math.round(rect.width)}x${Math.round(rect.height)}`, + at: `${Math.round(rect.x)},${Math.round(top)}`, + display: style.display, + position: style.position, + padding: style.padding, + justify: style.justifyContent, + align: style.alignItems, + text: (element.childElementCount === 0 ? (element.textContent ?? "").trim().slice(0, 18) : ""), + }); + for (const child of element.children) walk(child, depth + 1); + }; + walk(document.getElementById("root"), 0); + return out; + }, maxY); + console.log(`${target.name}: boxes down to y=${maxY}`); + for (const row of rows) { + console.log( + ` ${" ".repeat(row.depth)}${row.tag} ${row.box} @${row.at} ${row.display}/${row.position}` + + `${row.padding !== "0px" ? ` pad=${row.padding}` : ""}` + + `${row.justify !== "normal" ? ` just=${row.justify}` : ""}` + + `${row.align !== "normal" ? ` align=${row.align}` : ""}` + + `${row.text ? ` "${row.text}"` : ""}`, + ); + } + } finally { + await browser.close(); + if (preview.pid) spawnSync("taskkill", ["/PID", String(preview.pid), "/T", "/F"], { stdio: "ignore" }); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/harness/render/scripts/crop.mjs b/harness/render/scripts/crop.mjs new file mode 100644 index 00000000..b319d22b --- /dev/null +++ b/harness/render/scripts/crop.mjs @@ -0,0 +1,37 @@ +// A named slice of a screen, reference beside capture. +// +// node scripts/crop.mjs [height] + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { PNG } from "pngjs"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function main() { + const [name, topArgument, heightArgument] = process.argv.slice(2); + const top = Number(topArgument ?? 0); + const tall = Number(heightArgument ?? 200); + const reference = PNG.sync.read(readFileSync(join(HARNESS, "out", `${name}.reference.png`))); + const actual = PNG.sync.read(readFileSync(join(HARNESS, "out", `${name}.actual.png`))); + const width = Math.min(reference.width, actual.width); + const height = Math.min(tall, reference.height - top, actual.height - top); + if (height <= 0) throw new Error(`nothing to crop at y=${top}`); + const sheet = new PNG({ width: width * 2 + 8, height }); + sheet.data.fill(255); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + for (const [source, offset] of [[reference, 0], [actual, width + 8]]) { + const from = ((top + y) * source.width + x) * 4; + const to = (y * sheet.width + x + offset) * 4; + for (const channel of [0, 1, 2, 3]) sheet.data[to + channel] = source.data[from + channel]; + } + } + } + const out = join(HARNESS, "out", `${name}.crop-${top}.png`); + writeFileSync(out, PNG.sync.write(sheet)); + console.log(`wrote out/${name}.crop-${top}.png (${width}x${height}, reference | capture)`); +} + +main(); diff --git a/harness/render/scripts/drift.mjs b/harness/render/scripts/drift.mjs new file mode 100644 index 00000000..c245c68e --- /dev/null +++ b/harness/render/scripts/drift.mjs @@ -0,0 +1,70 @@ +// How far a capture has drifted from the reference, band by band. +// +// node scripts/drift.mjs [bandHeight] [maxOffset] +// +// For each band of rows, finds the vertical offset that best lines the +// capture up with the reference. A band that matches at offset 0 is in the +// right place; a band that only matches at -24 is sitting 24px too high. The +// first band that drifts is where the height went wrong - everything below +// it inherits the same shift, which a plain pixel ratio reports as a whole +// page of differences. + +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { PNG } from "pngjs"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const STEP = 3; + +function scoreAt(reference, actual, top, bottom, offset, width) { + let changed = 0; + let counted = 0; + for (let y = top; y < bottom; y += STEP) { + const from = y + offset; + if (from < 0 || from >= actual.height) continue; + for (let x = 0; x < width; x += STEP) { + const referenceIndex = (y * reference.width + x) * 4; + const actualIndex = (from * actual.width + x) * 4; + counted += 1; + for (const channel of [0, 1, 2]) { + if (Math.abs(reference.data[referenceIndex + channel] - actual.data[actualIndex + channel]) > 24) { + changed += 1; + break; + } + } + } + } + return counted === 0 ? 1 : changed / counted; +} + +function main() { + const [name, bandArgument, maxArgument] = process.argv.slice(2); + const band = Number(bandArgument ?? 100); + const maxOffset = Number(maxArgument ?? 60); + const reference = PNG.sync.read(readFileSync(join(HARNESS, "out", `${name}.reference.png`))); + const actual = PNG.sync.read(readFileSync(join(HARNESS, "out", `${name}.actual.png`))); + const width = Math.min(reference.width, actual.width); + const height = Math.min(reference.height, actual.height); + + console.log(`${name}: reference ${reference.width}x${reference.height}, capture ${actual.width}x${actual.height}`); + console.log(" band best at at 0 verdict"); + let previous = 0; + for (let top = 0; top < height; top += band) { + const bottom = Math.min(top + band, height); + let best = { offset: 0, ratio: Infinity }; + for (let offset = -maxOffset; offset <= maxOffset; offset += 1) { + const ratio = scoreAt(reference, actual, top, bottom, offset, width); + if (ratio < best.ratio) best = { offset, ratio }; + } + const atZero = scoreAt(reference, actual, top, bottom, 0, width); + const moved = best.offset !== previous ? ` <- drift ${previous} to ${best.offset}` : ""; + const verdict = best.ratio < 0.02 ? "aligned" : best.ratio < 0.1 ? "close" : "differs"; + console.log( + ` ${String(top).padStart(5)}-${String(bottom).padStart(5)} ${(best.ratio * 100).toFixed(1).padStart(5)}% ${String(best.offset).padStart(4)} ${(atZero * 100).toFixed(1).padStart(5)}% ${verdict}${moved}`, + ); + previous = best.offset; + } +} + +main(); diff --git a/harness/render/scripts/elements.mjs b/harness/render/scripts/elements.mjs new file mode 100644 index 00000000..8973f3b3 --- /dev/null +++ b/harness/render/scripts/elements.mjs @@ -0,0 +1,94 @@ +// What size each picture actually comes out at, against the box Figma drew. +// +// node scripts/elements.mjs [theme] +// +// Builds the harness with the screen's own theme, opens it at the reference +// width and reports every and every element painted with a background +// image: where it sits, how big it came out, and - for an - the +// intrinsic size the browser fell back on. An image given only a height takes +// its width from the file, which is the box of whichever width's node the +// file happened to be exported from, not the box on this page. + +import { spawn, spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = 4199; + +async function main() { + const [name] = process.argv.slice(2); + const manifest = JSON.parse(readFileSync(join(HARNESS, "targets.json"), "utf8")); + const target = manifest.targets.find((entry) => entry.name === name || entry.name.startsWith(`${name}-`)); + if (!target) throw new Error(`no target ${name}`); + const theme = target.theme && existsSync(join(HARNESS, target.theme)) ? target.theme : null; + if (theme) writeFileSync(join(HARNESS, "devup.json"), readFileSync(join(HARNESS, theme))); + const build = spawnSync("npx", ["vite", "build"], { cwd: HARNESS, encoding: "utf8", shell: process.platform === "win32" }); + if (build.status !== 0) throw new Error(`vite build failed:\n${build.stderr}`); + const preview = spawn("npx", ["vite", "preview", "--port", String(PORT), "--strictPort"], { cwd: HARNESS, shell: process.platform === "win32", stdio: "ignore" }); + const browser = await chromium.launch(); + try { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + if ((await fetch(`http://localhost:${PORT}/`)).ok) break; + } catch {} + await new Promise((done) => setTimeout(done, 200)); + } + const bytes = readFileSync(join(HARNESS, target.reference)); + const size = { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; + const context = await browser.newContext({ viewport: size, deviceScaleFactor: 1, locale: "ko-KR" }); + const page = await context.newPage(); + const screen = target.screen ?? target.name; + await page.goto(`http://localhost:${PORT}/?screen=${encodeURIComponent(screen)}`, { waitUntil: "networkidle" }); + await page.waitForSelector("body[data-ready]", { timeout: 30000 }); + const found = await page.evaluate(() => { + const rows = []; + for (const element of document.querySelectorAll("img")) { + const rect = element.getBoundingClientRect(); + rows.push({ + kind: "img", + source: element.getAttribute("src"), + box: `${Math.round(rect.width)}x${Math.round(rect.height)}`, + at: `${Math.round(rect.x)},${Math.round(rect.y + window.scrollY)}`, + intrinsic: `${element.naturalWidth}x${element.naturalHeight}`, + objectFit: getComputedStyle(element).objectFit, + parent: (() => { + const parentRect = element.parentElement?.getBoundingClientRect(); + return parentRect ? `${Math.round(parentRect.width)}x${Math.round(parentRect.height)}` : "?"; + })(), + }); + } + for (const element of document.querySelectorAll("*")) { + const style = getComputedStyle(element); + if (!style.backgroundImage.includes("url(")) continue; + const rect = element.getBoundingClientRect(); + rows.push({ + kind: "bg", + source: (style.backgroundImage.match(/url\("?([^")]+)"?\)/) ?? [])[1], + box: `${Math.round(rect.width)}x${Math.round(rect.height)}`, + at: `${Math.round(rect.x)},${Math.round(rect.y + window.scrollY)}`, + intrinsic: style.backgroundSize, + objectFit: style.backgroundPosition, + parent: "-", + }); + } + return rows; + }); + console.log(`${target.name} at ${size.width}x${size.height}`); + console.log(` ${"kind".padEnd(4)} ${"box".padEnd(11)} ${"at".padEnd(11)} ${"intrinsic/size".padEnd(16)} ${"fit/pos".padEnd(18)} source`); + for (const row of found) { + console.log(` ${row.kind.padEnd(4)} ${row.box.padEnd(11)} ${row.at.padEnd(11)} ${row.intrinsic.padEnd(16)} ${String(row.objectFit).padEnd(18)} ${decodeURIComponent(row.source ?? "")}`); + } + await context.close(); + } finally { + await browser.close(); + if (preview.pid) spawnSync("taskkill", ["/PID", String(preview.pid), "/T", "/F"], { stdio: "ignore" }); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/harness/render/scripts/probe_stack.mjs b/harness/render/scripts/probe_stack.mjs new file mode 100644 index 00000000..18c662fb --- /dev/null +++ b/harness/render/scripts/probe_stack.mjs @@ -0,0 +1,74 @@ +// Which element is on top at a point, and the stacking-relevant styles of an +// and its ancestors. A one-off probe for a picture that is placed but +// not seen. +// +// node scripts/probe_stack.mjs + +import { spawn, spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = 4213; + +async function main() { + const [name, fragment, xArg, yArg] = process.argv.slice(2); + const manifest = JSON.parse(readFileSync(join(HARNESS, "targets.json"), "utf8")); + const target = manifest.targets.find((entry) => entry.name === name); + if (!target) throw new Error(`no target ${name}`); + if (target.theme && existsSync(join(HARNESS, target.theme))) { + writeFileSync(join(HARNESS, "devup.json"), readFileSync(join(HARNESS, target.theme))); + } + const build = spawnSync("npx", ["vite", "build"], { cwd: HARNESS, encoding: "utf8", shell: process.platform === "win32" }); + if (build.status !== 0) throw new Error(`vite build failed:\n${build.stderr}`); + const preview = spawn("npx", ["vite", "preview", "--port", String(PORT), "--strictPort"], { cwd: HARNESS, shell: process.platform === "win32", stdio: "ignore" }); + const browser = await chromium.launch(); + try { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { if ((await fetch(`http://localhost:${PORT}/`)).ok) break; } catch {} + await new Promise((r) => setTimeout(r, 200)); + } + const bytes = readFileSync(join(HARNESS, target.reference)); + const size = { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; + const context = await browser.newContext({ viewport: size, deviceScaleFactor: 1, locale: "ko-KR" }); + const page = await context.newPage(); + await page.goto(`http://localhost:${PORT}/?screen=${encodeURIComponent(target.screen ?? target.name)}`, { waitUntil: "networkidle" }); + await page.waitForSelector("body[data-ready]", { timeout: 30000 }); + const report = await page.evaluate(([fragment, x, y]) => { + const img = [...document.querySelectorAll("img")].find((i) => i.getAttribute("src")?.includes(fragment)); + if (!img) return { error: "img not found" }; + const chain = []; + let node = img; + while (node && node !== document.body) { + const cs = getComputedStyle(node); + chain.push({ + tag: node.tagName.toLowerCase(), + position: cs.position, zIndex: cs.zIndex, opacity: cs.opacity, + overflow: cs.overflow, isolation: cs.isolation, transform: cs.transform, + bg: cs.backgroundColor, display: cs.display, + }); + node = node.parentElement; + } + window.scrollTo(0, Math.max(0, y - 300)); + const rect = img.getBoundingClientRect(); + const probeX = rect.left + rect.width / 2; + const probeY = rect.top + rect.height / 2; + const top = document.elementFromPoint(probeX, probeY); + return { + imgRect: { x: rect.left + window.scrollX, y: rect.top + window.scrollY, w: rect.width, h: rect.height }, + naturalSize: { w: img.naturalWidth, h: img.naturalHeight, complete: img.complete }, + topAtCentre: top ? { tag: top.tagName.toLowerCase(), cls: top.className, isImg: top === img, isAncestor: top.contains(img) } : null, + chain, + }; + }, [fragment, Number(xArg ?? 0), Number(yArg ?? 0)]); + console.log(JSON.stringify(report, null, 2)); + } finally { + await browser.close(); + if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(preview.pid), "/T", "/F"], { stdio: "ignore" }); + else preview.kill(); + } +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/harness/render/scripts/render.mjs b/harness/render/scripts/render.mjs new file mode 100644 index 00000000..c6511ba9 --- /dev/null +++ b/harness/render/scripts/render.mjs @@ -0,0 +1,248 @@ +// Render every acquired screen at its frame's size and compare it with the +// PNG Figma drew of the same frame. +// +// node scripts/render.mjs [name ...] +// +// Follows docs/visual-renderer-contract.md: the viewport is the reference +// PNG's size, the page is captured only after fonts are loaded, images are +// decoded and the app has said it is ready, animations are off, and the +// comparison is left to `devup-mcp-visual`. A screen whose page errors is +// reported as `environment-invalid`, never as a pixel result. + +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { PNG } from "pngjs"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const REPO = resolve(HARNESS, "..", ".."); +const PORT = 4173; +// Where cargo puts the binary: the workspace may point its target directory +// elsewhere (this one shares one under ~/.cargo). +function visualBinary() { + const metadata = spawnSync("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: REPO, encoding: "utf8", shell: process.platform === "win32" }); + const targetDirectory = metadata.status === 0 ? JSON.parse(metadata.stdout).target_directory : join(REPO, "target"); + return join(targetDirectory, "release", process.platform === "win32" ? "devup-mcp-visual.exe" : "devup-mcp-visual"); +} +const VISUAL = visualBinary(); + +function pngSize(path) { + const bytes = readFileSync(path); + if (bytes.readUInt32BE(12) !== 0x49484452) throw new Error(`${path}: not a PNG`); + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { cwd: HARNESS, stdio: "pipe", encoding: "utf8", shell: process.platform === "win32", ...options }); + return result; +} + +// Figma's `get_screenshot` flattens a frame's own transparency onto a canvas +// colour it does not report - a 70% black backdrop came back an opaque +// (21, 21, 21), which is neither black nor the page's #1E1E1E. The capture +// here keeps the transparency, so before comparing it is flattened onto the +// same colour, recovered from the reference at the capture's most +// transparent pixel: reference = alpha * ours + (1 - alpha) * canvas. A +// capture that is opaque everywhere is left as it is. +function flattenLikeFigma(referencePath, actualPath) { + const reference = PNG.sync.read(readFileSync(referencePath)); + const actual = PNG.sync.read(readFileSync(actualPath)); + if (reference.width !== actual.width || reference.height !== actual.height) return null; + let probe = -1; + let lowest = 255; + for (let index = 3; index < actual.data.length; index += 4) { + if (actual.data[index] < lowest) { + lowest = actual.data[index]; + probe = index - 3; + } + } + if (probe < 0 || lowest === 255) return null; + const alpha = lowest / 255; + const canvas = [0, 1, 2].map((channel) => { + const value = (reference.data[probe + channel] - alpha * actual.data[probe + channel]) / (1 - alpha); + return Math.max(0, Math.min(255, Math.round(value))); + }); + for (let index = 0; index < actual.data.length; index += 4) { + const a = actual.data[index + 3] / 255; + for (const channel of [0, 1, 2]) { + actual.data[index + channel] = Math.round(a * actual.data[index + channel] + (1 - a) * canvas[channel]); + } + actual.data[index + 3] = 255; + } + writeFileSync(actualPath, PNG.sync.write(actual)); + return { canvas, alpha: lowest }; +} + +// `spawn` with a shell starts a shell that starts vite, so killing the child +// kills the shell and leaves vite holding the port. The next run then fails +// on `--strictPort`, or worse waits on a server serving the previous build. +// The whole tree has to go. +function stopPreview(preview) { + if (!preview.pid) return; + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(preview.pid), "/T", "/F"], { stdio: "ignore" }); + } else { + try { + process.kill(-preview.pid, "SIGKILL"); + } catch { + preview.kill("SIGKILL"); + } + } +} + +async function waitForServer(url, attempts = 50) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`preview server did not answer at ${url}`); +} + +// The theme a screen is rendered with: its own, written by the acquisition +// at node scope. An answer screen borrows the theme of the screen it answers +// for, so the two are compared under the same colours. +function themeFor(target) { + const screen = target.screen ?? target.name; + const candidates = [target.theme, `themes/${screen}.json`, `themes/${screen.replace(/-answer$/, "")}.json`]; + return candidates.find((candidate) => candidate && existsSync(join(HARNESS, candidate))) ?? null; +} + +async function main() { + const wanted = process.argv.slice(2); + const manifest = JSON.parse(readFileSync(join(HARNESS, "targets.json"), "utf8")); + const targets = manifest.targets.filter((target) => wanted.length === 0 || wanted.some((name) => target.name === name || target.name.startsWith(`${name}-`))); + if (targets.length === 0) throw new Error("no targets; run scripts/acquire.py first"); + + if (!existsSync(VISUAL)) { + const cargo = run("cargo", ["build", "-p", "devup-mcp-visual", "--release"], { cwd: REPO }); + if (cargo.status !== 0) throw new Error(`cargo build failed:\n${cargo.stderr}`); + } + + // This file holds several brands' collections, and more than one defines + // `primary`; a theme covering all of them resolves one brand's value for + // every screen, which drew the notice screen violet where Figma draws it + // blue. Devup UI bakes the theme into the CSS at build time, so screens are + // grouped by the theme they need and each group is built once. + const groups = new Map(); + for (const target of targets) { + const theme = themeFor(target); + const key = theme ? createHash("sha256").update(readFileSync(join(HARNESS, theme))).digest("hex").slice(0, 12) : "as-configured"; + if (!groups.has(key)) groups.set(key, { theme, members: [] }); + groups.get(key).members.push(target); + } + + const browser = await chromium.launch(); + const report = []; + try { + for (const [index, [key, group]] of [...groups].entries()) { + if (group.theme) writeFileSync(join(HARNESS, "devup.json"), readFileSync(join(HARNESS, group.theme))); + console.log(`theme ${key}${group.theme ? ` from ${group.theme}` : " (as configured)"}: ${group.members.map((member) => member.name).join(", ")}`); + const build = run("npx", ["vite", "build"]); + if (build.status !== 0) { + console.error(build.stdout, build.stderr); + throw new Error("vite build failed"); + } + // A port of its own per group: a preview killed on Windows does not + // always let go of its port before the next one asks for it. + const port = PORT + index; + const preview = spawn("npx", ["vite", "preview", "--port", String(port), "--strictPort"], { cwd: HARNESS, shell: process.platform === "win32", stdio: "ignore" }); + try { + await renderGroup(browser, group.members, report, port); + } finally { + stopPreview(preview); + } + } + } finally { + await browser.close(); + } + mkdirSync(join(HARNESS, "out"), { recursive: true }); + writeFileSync(join(HARNESS, "out", "report.json"), JSON.stringify(report, null, 2)); + reportAgainstThresholds(report); +} + +// Every figure won here was won against a specific defect, and nothing stops +// the next change from giving one back. Each screen carries the most it may +// differ; a run that exceeds it fails, and a run that comes in under it says +// so, which is the cue to tighten the figure. +function reportAgainstThresholds(report) { + const path = join(HARNESS, "thresholds.json"); + if (!existsSync(path)) return; + const { tolerance = 0.05, screens = {} } = JSON.parse(readFileSync(path, "utf8")); + const worse = []; + const better = []; + for (const entry of report) { + const allowed = screens[entry.name]; + if (allowed === undefined || !entry.metrics) continue; + const measured = entry.metrics.changedRatio * 100; + if (measured > allowed + tolerance) worse.push(`${entry.name}: ${measured.toFixed(2)}% > ${allowed}%`); + else if (measured < allowed - tolerance) better.push(`${entry.name}: ${measured.toFixed(2)}% < ${allowed}%`); + } + for (const line of better) console.log(`improved, tighten thresholds.json — ${line}`); + if (worse.length > 0) { + console.error(`\n${worse.length} screen(s) drifted further from Figma:`); + for (const line of worse) console.error(` ${line}`); + process.exitCode = 1; + } +} + +async function renderGroup(browser, targets, report, port) { + { + await waitForServer(`http://localhost:${port}/`); + for (const target of targets) { + const reference = join(HARNESS, target.reference); + const size = pngSize(reference); + const actual = join(HARNESS, "out", `${target.name}.actual.png`); + const diff = join(HARNESS, "out", `${target.name}.diff.png`); + const context = await browser.newContext({ viewport: size, deviceScaleFactor: 1, locale: "ko-KR", timezoneId: "Asia/Seoul", reducedMotion: "reduce" }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + const screen = target.screen ?? target.name; + await page.goto(`http://localhost:${port}/?screen=${encodeURIComponent(screen)}`, { waitUntil: "networkidle" }); + await page.waitForSelector("body[data-ready]", { timeout: 30000 }); + const ready = await page.evaluate(() => document.body.dataset.ready); + const entry = { name: target.name, frame: target.frame, viewport: size, reference: target.reference, actual: `out/${target.name}.actual.png` }; + if (ready !== "1" || errors.length > 0) { + entry.environmentStatus = "environment-invalid"; + entry.errors = errors.concat(ready !== "1" ? [await page.evaluate(() => document.body.dataset.error ?? "not ready")] : []); + report.push(entry); + console.log(`${target.name}: environment-invalid ${JSON.stringify(entry.errors)}`); + await context.close(); + continue; + } + // The page's own size, so a screen that comes out taller or shorter + // than Figma drew it is reported as such and not only clipped. + entry.rendered = await page.evaluate(() => { + const root = document.getElementById("root"); + const rect = root.getBoundingClientRect(); + return { width: Math.round(rect.width), height: Math.round(document.documentElement.scrollHeight) }; + }); + // Figma exports a frame with its own transparency - a 70% black + // backdrop is (0, 0, 0, 178) in the PNG - so the page is captured + // without a background too, and the same pixel comes out the same. + await page.screenshot({ path: actual, clip: { x: 0, y: 0, width: size.width, height: size.height }, fullPage: true, omitBackground: true }); + await context.close(); + entry.flattened = flattenLikeFigma(reference, actual); + const compare = spawnSync(VISUAL, ["compare", "--reference", reference, "--actual", actual, "--diff", diff, "--channel-tolerance", "24"], { cwd: HARNESS, encoding: "utf8" }); + entry.environmentStatus = "valid"; + entry.compare = { exit: compare.status, stdout: compare.stdout.trim(), stderr: compare.stderr.trim() }; + try { entry.metrics = JSON.parse(compare.stdout); } catch {} + report.push(entry); + const summary = entry.metrics ? `${entry.metrics.status} changed ${(entry.metrics.changedRatio * 100).toFixed(2)}% (max channel delta ${entry.metrics.maxChannelDelta})` : (compare.stdout.trim() || compare.stderr.trim()); + const canvas = entry.flattened ? ` on canvas rgb(${entry.flattened.canvas.join(",")})` : ""; + console.log(`${target.name}: ${size.width}x${size.height} rendered ${entry.rendered.width}x${entry.rendered.height}${canvas} -> ${summary}`); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/harness/render/scripts/reset-check.mjs b/harness/render/scripts/reset-check.mjs new file mode 100644 index 00000000..ddf1f92d --- /dev/null +++ b/harness/render/scripts/reset-check.mjs @@ -0,0 +1,70 @@ +// Whether the reset the generated code is written against is actually in +// force in the page, and what it settles. +// +// node scripts/reset-check.mjs [screen] + +import { spawn, spawnSync } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = 4205; + +async function main() { + const screen = process.argv[2] ?? "about-422-3376"; + const build = spawnSync("npx", ["vite", "build"], { cwd: HARNESS, encoding: "utf8", shell: process.platform === "win32" }); + if (build.status !== 0) throw new Error(`vite build failed:\n${build.stderr}`); + const preview = spawn("npx", ["vite", "preview", "--port", String(PORT), "--strictPort"], { cwd: HARNESS, shell: process.platform === "win32", stdio: "ignore" }); + const browser = await chromium.launch(); + try { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + if ((await fetch(`http://localhost:${PORT}/`)).ok) break; + } catch {} + await new Promise((done) => setTimeout(done, 200)); + } + const page = await browser.newPage({ viewport: { width: 360, height: 800 } }); + await page.goto(`http://localhost:${PORT}/?screen=${encodeURIComponent(screen)}`, { waitUntil: "networkidle" }); + await page.waitForSelector("body[data-ready]", { timeout: 30000 }); + const found = await page.evaluate(() => { + const sheets = [...document.styleSheets].map((sheet) => sheet.href ?? "inline"); + let resetRules = 0; + for (const sheet of document.styleSheets) { + let rules; + try { + rules = sheet.cssRules; + } catch { + continue; + } + for (const rule of rules) { + if (rule.cssText.includes("tab-size") || rule.cssText.includes(":where(:root)")) resetRules += 1; + } + } + const image = document.querySelector("img"); + return { + sheets, + resetRules, + rootLineHeight: getComputedStyle(document.documentElement).lineHeight, + rootFontSize: getComputedStyle(document.documentElement).fontSize, + bodyMargin: getComputedStyle(document.body).margin, + imageDisplay: image ? getComputedStyle(image).display : "no image", + imageVerticalAlign: image ? getComputedStyle(image).verticalAlign : "-", + imageObjectFit: image ? getComputedStyle(image).objectFit : "-", + }; + }); + console.log(`screen ${screen}`); + for (const [key, value] of Object.entries(found)) { + console.log(` ${key}: ${Array.isArray(value) ? value.length + " sheets" : value}`); + } + for (const sheet of found.sheets) console.log(` sheet: ${sheet}`); + } finally { + await browser.close(); + if (preview.pid) spawnSync("taskkill", ["/PID", String(preview.pid), "/T", "/F"], { stdio: "ignore" }); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/harness/render/scripts/sections.mjs b/harness/render/scripts/sections.mjs new file mode 100644 index 00000000..4bb8cd0c --- /dev/null +++ b/harness/render/scripts/sections.mjs @@ -0,0 +1,105 @@ +// Section heights, DOM against Figma, walking the screen's root and its +// direct children level by level. Where a section is taller than Figma drew +// it, the page below it drifts by that much; this says which one and by how +// much, before any crop is looked at. +// +// node scripts/sections.mjs [depth] + +import { spawn, spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = 4214; + +async function main() { + const [name, depthArg] = process.argv.slice(2); + const depth = Number(depthArg ?? 2); + const manifest = JSON.parse(readFileSync(join(HARNESS, "targets.json"), "utf8")); + const target = manifest.targets.find((entry) => entry.name === name); + if (!target) throw new Error(`no target ${name}`); + if (target.theme && existsSync(join(HARNESS, target.theme))) { + writeFileSync(join(HARNESS, "devup.json"), readFileSync(join(HARNESS, target.theme))); + } + const frame = target.frame; + const snapshot = JSON.parse(readFileSync(join(HARNESS, `out/${target.name}-${frame.replace(":", "-")}.snapshot.json`), "utf8")); + const nodes = snapshot.nodes; + const rootBox = nodes[frame].fields.absoluteBoundingBox; + + const build = spawnSync("npx", ["vite", "build"], { cwd: HARNESS, encoding: "utf8", shell: process.platform === "win32" }); + if (build.status !== 0) throw new Error(`vite build failed:\n${build.stderr}`); + const preview = spawn("npx", ["vite", "preview", "--port", String(PORT), "--strictPort"], { cwd: HARNESS, shell: process.platform === "win32", stdio: "ignore" }); + const browser = await chromium.launch(); + try { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { if ((await fetch(`http://localhost:${PORT}/`)).ok) break; } catch {} + await new Promise((r) => setTimeout(r, 200)); + } + const bytes = readFileSync(join(HARNESS, target.reference)); + const size = { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; + const context = await browser.newContext({ viewport: size, deviceScaleFactor: 1, locale: "ko-KR" }); + const page = await context.newPage(); + await page.goto(`http://localhost:${PORT}/?screen=${encodeURIComponent(target.screen ?? target.name)}`, { waitUntil: "networkidle" }); + await page.waitForSelector("body[data-ready]", { timeout: 30000 }); + // The DOM tree of element boxes, depth-limited, in document order. + const dom = await page.evaluate((depth) => { + const root = document.getElementById("root").firstElementChild; + const walk = (el, level) => { + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + const out = { + level, tag: el.tagName.toLowerCase(), + y: Math.round((r.top + window.scrollY) * 10) / 10, h: Math.round(r.height * 10) / 10, + w: Math.round(r.width * 10) / 10, + pos: cs.position, text: (el.innerText || "").trim().slice(0, 24).replace(/\s+/g, " "), + children: [], + }; + if (level < depth) for (const child of el.children) out.children.push(walk(child, level + 1)); + return out; + }; + return walk(root, 0); + }, depth); + + // Figma's tree, same shape, y relative to the root frame. + const figma = (id, level) => { + const node = nodes[id]; + const f = node.fields; + const b = f.absoluteBoundingBox ?? { x: 0, y: 0, width: 0, height: 0 }; + const out = { + level, id, type: node.type, name: f.name, + y: Math.round((b.y - rootBox.y) * 10) / 10, h: Math.round(b.height * 10) / 10, w: Math.round(b.width * 10) / 10, + pos: f.layoutPositioning, visible: f.visible !== false, + children: [], + }; + if (level < depth) for (const child of f.childrenIds ?? []) if (nodes[child]) out.children.push(figma(child, level + 1)); + return out; + }; + const fig = figma(frame, 0); + + // Print side by side by index at each level: the code emits one element + // per visible in-flow node, so index order is the only join available. + const print = (d, f, indent) => { + const pad = " ".repeat(indent); + const dy = d && f ? (d.y - f.y).toFixed(1) : ""; + const dh = d && f ? (d.h - f.h).toFixed(1) : ""; + const flag = d && f && Math.abs(d.h - f.h) > 1 ? " <-- height differs" : ""; + const left = d ? `${d.tag.padEnd(4)} y=${String(d.y).padStart(7)} h=${String(d.h).padStart(7)}` : " ".repeat(27); + const right = f ? `${(f.type ?? "").padEnd(9)} y=${String(f.y).padStart(7)} h=${String(f.h).padStart(7)} ${f.name}` : "(no figma node)"; + console.log(`${pad}${left} | ${right} Δy=${dy} Δh=${dh}${flag}`); + const dc = d?.children ?? []; + const fc = (f?.children ?? []).filter((c) => c.visible); + const n = Math.max(dc.length, fc.length); + for (let i = 0; i < n; i += 1) print(dc[i], fc[i], indent + 1); + }; + console.log(`${target.name}: DOM | Figma`); + print(dom, fig, 0); + } finally { + await browser.close(); + if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(preview.pid), "/T", "/F"], { stdio: "ignore" }); + else preview.kill(); + } +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/harness/render/scripts/text-check.mjs b/harness/render/scripts/text-check.mjs new file mode 100644 index 00000000..dd869e98 --- /dev/null +++ b/harness/render/scripts/text-check.mjs @@ -0,0 +1,136 @@ +// Does the screen print the words the design says? +// +// node scripts/text-check.mjs [target ...] +// +// The generator writes a text node's characters into JSX, and JSX has rules +// of its own about whitespace: a run of text broken across two source lines +// is joined with a space. Where the design has no space there, the screen +// prints a word that is not in the design, and the paragraph wraps somewhere +// Figma never wraps it. +// +// Reading the JSX to work out what it renders means implementing those rules, +// and guessing at them is how a check invents faults that are not there. The +// browser already implements them exactly, so this asks the rendered page +// instead: every `characters` string of every text node under the frame has +// to appear in what the page prints. + +import { spawn, spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = 4208; + +/// Whitespace the same way on both sides, so only a real difference shows. +/// Figma separates lines with U+2028; the generator writes those as breaks, +/// which the page prints as newlines. +function normalise(text) { + return text + .replace(/[\u2028\u2029\r\n]+/g, "\n") + .replace(/[ \t\u00a0]+/g, " ") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join("\n") + .trim(); +} + +/// Every text a node under `root` holds, in the frame the screen renders. +function charactersUnder(snapshot, root) { + const nodes = snapshot.nodes ?? {}; + const found = []; + const pending = [root]; + const seen = new Set(); + while (pending.length > 0) { + const id = pending.pop(); + if (seen.has(id)) continue; + seen.add(id); + const node = nodes[id]; + if (!node) continue; + const fields = node.fields ?? {}; + if (typeof fields.characters === "string" && fields.characters.trim().length > 0) { + found.push({ id, text: fields.characters }); + } + for (const child of fields.childrenIds ?? []) pending.push(child); + } + return found; +} + +async function main() { + const wanted = process.argv.slice(2); + const manifest = JSON.parse(readFileSync(join(HARNESS, "targets.json"), "utf8")); + const targets = manifest.targets.filter( + (target) => wanted.length === 0 || wanted.some((name) => target.name === name || target.name.startsWith(`${name}-`)), + ); + if (targets.length === 0) throw new Error("no targets; run scripts/acquire.py first"); + + const browser = await chromium.launch(); + let checked = 0; + let missing = 0; + try { + for (const [index, target] of targets.entries()) { + const snapshotPath = join( + HARNESS, + "out", + `${target.screen ?? target.name}-${target.frame.replace(":", "-")}.snapshot.json`, + ); + if (!existsSync(snapshotPath)) { + console.log(`${target.name}: no snapshot on disk; skipping`); + continue; + } + const snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")); + const texts = charactersUnder(snapshot, target.frame); + if (texts.length === 0) continue; + + if (target.theme && existsSync(join(HARNESS, target.theme))) { + writeFileSync(join(HARNESS, "devup.json"), readFileSync(join(HARNESS, target.theme))); + } + const build = spawnSync("npx", ["vite", "build"], { cwd: HARNESS, encoding: "utf8", shell: process.platform === "win32" }); + if (build.status !== 0) throw new Error(`vite build failed:\n${build.stderr}`); + const port = PORT + (index % 8); + const preview = spawn("npx", ["vite", "preview", "--port", String(port), "--strictPort"], { cwd: HARNESS, shell: process.platform === "win32", stdio: "ignore" }); + try { + let serving = false; + for (let attempt = 0; attempt < 150 && !serving; attempt += 1) { + try { + serving = (await fetch(`http://localhost:${port}/`)).ok; + } catch {} + if (!serving) await new Promise((done) => setTimeout(done, 200)); + } + if (!serving) throw new Error(`preview did not answer on ${port}`); + const bytes = readFileSync(join(HARNESS, target.reference)); + const page = await browser.newPage({ + viewport: { width: bytes.readUInt32BE(16), height: Math.min(bytes.readUInt32BE(20), 2000) }, + }); + await page.goto(`http://localhost:${port}/?screen=${encodeURIComponent(target.screen ?? target.name)}`, { waitUntil: "networkidle" }); + await page.waitForSelector("body[data-ready]", { timeout: 30000 }); + const printed = normalise(await page.evaluate(() => document.getElementById("root")?.innerText ?? "")); + await page.close(); + + const absent = texts.filter(({ text }) => !printed.includes(normalise(text))); + checked += texts.length; + missing += absent.length; + console.log(`${target.name}: ${texts.length} texts, ${absent.length} not printed as the design says`); + for (const { id, text } of absent.slice(0, 4)) { + const wanted = normalise(text); + // The first place the two part company, to keep the report short. + let prefix = wanted; + while (prefix.length > 12 && !printed.includes(prefix)) prefix = prefix.slice(0, -1); + console.log(` ${id}: design has ...${wanted.slice(Math.max(0, prefix.length - 14), prefix.length + 14)}...`); + } + } finally { + if (preview.pid) spawnSync("taskkill", ["/PID", String(preview.pid), "/T", "/F"], { stdio: "ignore" }); + } + } + } finally { + await browser.close(); + } + console.log(`\n${missing} of ${checked} texts are not printed as the design says`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/harness/render/src/main.tsx b/harness/render/src/main.tsx new file mode 100644 index 00000000..3beeb46c --- /dev/null +++ b/harness/render/src/main.tsx @@ -0,0 +1,54 @@ +// The reset the generated code is written against. Figma's code assumes one +// is in place, and which one decides where every box sits: this sets the root +// line-height to 1.5 and leaves images inline on the baseline, neither of +// which a browser does on its own. +import "@devup-ui/reset-css"; +import { StrictMode, type ComponentType } from "react"; +import { createRoot } from "react-dom/client"; + +// Every generated screen under `src/screens`, by file name. `acquire.py` +// writes them; they are not committed. +const screens = import.meta.glob>("./screens/*.tsx"); + +async function main() { + const name = new URLSearchParams(location.search).get("screen"); + const loader = name ? screens[`./screens/${name}.tsx`] : undefined; + const root = document.getElementById("root"); + if (!root) throw new Error("no root"); + if (!loader) { + root.textContent = `no such screen: ${name}. known: ${Object.keys(screens).join(", ")}`; + document.body.dataset.ready = "error"; + return; + } + const module = await loader(); + // A responsive module is a default export; a single frame is the one + // named export the server gives it. + const component = (module.default ?? + Object.values(module).find((value) => typeof value === "function")) as + | ComponentType + | undefined; + if (!component) throw new Error(`no component in ${name}`); + const Screen = component; + createRoot(root).render( + + + , + ); + await document.fonts.ready; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await Promise.all( + Array.from(document.images).map((image) => + image.complete ? Promise.resolve() : new Promise((resolve) => { + image.addEventListener("load", resolve, { once: true }); + image.addEventListener("error", resolve, { once: true }); + }), + ), + ); + document.body.dataset.ready = "1"; +} + +main().catch((error) => { + console.error(error); + document.body.dataset.ready = "error"; + document.body.dataset.error = String(error); +}); diff --git a/harness/render/thresholds.json b/harness/render/thresholds.json new file mode 100644 index 00000000..646ccf05 --- /dev/null +++ b/harness/render/thresholds.json @@ -0,0 +1,24 @@ +{ + "note": "The most each screen may differ from Figma's own PNG, as a percentage. Written from a measured run; `render.mjs` fails when a screen exceeds its own figure, so a change that makes a screen worse cannot pass unnoticed. Tighten a figure whenever a run comes in under it. The `popup-answer-*` entries are the plugin's own answer rendered the same way - a baseline to compare against, not something to improve.", + "tolerance": 0.05, + "screens": { + "popup-422-5682": 3.59, + "popup-422-5705": 2.19, + "popup-422-5728": 0.83, + "popup-answer-422-5682": 12.02, + "popup-answer-422-5705": 7.14, + "popup-answer-422-5728": 21.06, + "keyframes-458-2021": 6.71, + "grid-429-1966": 2.96, + "report-446-1971": 1.87, + "notice-422-6914": 7.35, + "notice-422-7088": 3.24, + "notice-422-6865": 2.15, + "about-422-3376": 11.25, + "about-422-3180": 6.93, + "about-422-2987": 4.42, + "landing-833-3640": 11.41, + "landing-833-3322": 3.1, + "landing-832-2975": 1.84 + } +} diff --git a/harness/render/tsconfig.json b/harness/render/tsconfig.json new file mode 100644 index 00000000..23cb454c --- /dev/null +++ b/harness/render/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/harness/render/vite.config.ts b/harness/render/vite.config.ts new file mode 100644 index 00000000..aab00044 --- /dev/null +++ b/harness/render/vite.config.ts @@ -0,0 +1,13 @@ +import { DevupUI } from "@devup-ui/vite-plugin"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), DevupUI()], + // The renderer loads the built site over a local preview server, so the + // asset paths the generated code uses — `/icons/x.svg` — resolve as they + // would in a consumer. + base: "/", + build: { outDir: "dist", emptyOutDir: true }, + preview: { port: 4173, strictPort: true }, +});