diff --git a/.changepacks/changepack_log_screen_plan_first.json b/.changepacks/changepack_log_screen_plan_first.json new file mode 100644 index 00000000..458af69e --- /dev/null +++ b/.changepacks/changepack_log_screen_plan_first.json @@ -0,0 +1,8 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor", + "crates/devup-mcp-devup-ui/Cargo.toml": "Minor" + }, + "note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen under the linked node is mapped to an evidence-backed URL and confirmed in questions asked up front, never during implementation; keep the plan inside the linked scope and out of trash, archive, draft, copy and hidden areas (listed and confirmed, overridable with excludeKeywords/includeKeywords); ask cross-section and other structural ambiguities explicitly with a fix-the-Figma option whose frames are reported, not built; surface layer-name guesses; honour .devup/screen-plan.config.json; gate page-writing exports on the confirmed plan and refuse excluded or design-fix frames; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill. Add a site-completeness step: the plan records the defaults the agent fills in (interaction states from existing tokens, one shared transition, reduced motion, metadata, lang, alt, not-found) and asks only for files and facts (favicon, share image, site name and URL, analytics, motion); status reports site completeness and devup_ui_validate warns on stateless controls, pages without metadata and a missing app icon. Add the /devup entry point, installed as a skill (Claude Code) and an OpenCode command. Keep multi-colour icons as instead of a single-colour mask, warn multicolor-mask in devup_ui_validate when a maskImage would flatten a multi-colour SVG, and compact export job polls (honour responseFields on them, explain pending files). Replace the flat interaction default with a restrained recipe per element role, add view transitions by the mechanism the project's Next.js and React versions support, let draft take the agent's own extraQuestions, end the workflow with a submission checklist, and widen devup_ui_validate with focus-style, too-much-motion, icon-button-label (warnings) and button-active, motion-reduce, nav-current (info). Recognise vinext apps (no next dependency) across bun workspaces and report frameworks per app, decide view transitions for vinext with React 19.3 from the installed versions, run the dependency update (bunx @dependency-check-updates/cli -d -u --rm) before implementing and report it, start new projects from dev-five-git/service-template, ask once whether a next-CLI app migrates to vinext, and align devfive-frontend with service-template's AGENTS.md. Turn view transitions on for every project (opt out with viewTransitions: false) and prove them with a new runtime-audit view-transition check, and pick the target app of a multi-app repository by meaning - public site vs admin console, per screen in a mixed file - instead of name order; add devup_screen_plan auditPlan, which writes one runtime-audit plan per URL checking page health, overflow and clipped text at every width and the page, each section and each asset against its Figma render plus their size and position at the drawn widths, with hover, focus and a view-transition check ordered by code; classify every audit failure with its Figma node, code file and fix hint, track fix attempts up to maxFixAttempts before recording it unresolved with its evidence, and add defer for decisions met after confirm; new runtime-audit check kinds page-health, bounds, no-clipped-text, interaction and capture; exports into a project mark page, section, card and asset elements with data-figma-node-id; devup_visual_compare can write its diff PNG to diffPath; app selection takes the public app unless the frames clearly say admin; check geometry exactly (half a pixel of rounding, no tolerance) and classify a mismatch as code-drift against the export record each export writes to .devup/exports, or as a converter-bug with a repro in .devup/converter-bugs that is reported and never retried; mark pages merged from one frame per width with data-figma-node-ids per width and check elements a width does not draw are hidden; audit dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; strip the ids from production builds with a Vite plugin unless DEVUP_AUDIT=1.", + "date": "2026-10-17T00:00:00+09:00" +} diff --git a/.gitignore b/.gitignore index b8d20e7f..332d51df 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ /docs/ /feedback.txt /devup-mcp-feedback/ + +# Installed by `npm ci` in harness/runtime-audit (and plugin/) to run the +# executor locally; never part of the tree. +node_modules/ diff --git a/README.md b/README.md index e611d0a0..5639d7dd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Rust-native MCP server that reads Figma designs and generates DevupUI artifacts. ## 도구 -Figma 쪽 4개, 프로젝트 쪽 6개, 스킬 1개, 모두 11개입니다. +Figma 쪽 5개, 프로젝트 쪽 7개, 스킬 1개, 모두 13개입니다. - `devup_figma_export`: Figma를 한 번 수집해 요청한 `outputs`만 투영합니다. TSX가 산출물이고, `componentTsx`·`responsiveTsx`·`devup.json`·source map·asset manifest·reference PNG를 같은 수집에서 함께 얻거나, `cache.artifactId`로 재수집 없이 추가 투영할 수 있습니다. raw snapshot·raw payload는 구현이 아니라 진단에 쓰는 것이라 `debug: true`로만 열립니다. - `devup_figma_search`: page, section, frame, component를 이름으로 탐색. URL에 `node-id`가 있으면 **그 노드와 그 아래로 범위를 좁히고**, 없으면 파일 전체를 검색합니다. 둘 중 무엇을 했는지는 응답의 `scope`가 알려줍니다 @@ -19,6 +19,7 @@ Figma 쪽 4개, 프로젝트 쪽 6개, 스킬 1개, 모두 11개입니다. - `devup_visual_compare`: 소비자가 만든 `actual.png`를 reference PNG 하나와 비교. **스스로 렌더하지 않고 브라우저를 띄우지도 명령을 실행하지도 않습니다** — 경로는 allowlist 안이어야 합니다. 기본 threshold는 0.005(0.5%). renderer 환경 manifest가 없거나 불완전하거나 유효하지 않으면 `visual.passed`가 true여도 판정은 `inconclusive`입니다 - `devup_feature_trace`: 명시적 anchor(`routePath`, `figmaNodeId`/`artifactId`, `operationId` 또는 `apiPath`+`method`, `componentPath`, `tableName`)에서 출발해 화면→API→라우트→모델→컬럼 슬라이스와 acceptance matrix를 읽어냄. **요구사항 산문만 주면 거절합니다.** 각 hop은 근거가 있거나 `UNVERIFIED`로 표시되고, 생성 산출물 소유권·UI 재사용 순위·잘림 상한을 함께 보고합니다 - `devup_runtime_audit`: 생성한 화면을 여러 viewport 폭에서 재 볼 **브라우저 실행 계획**을 검증해 쓰고(`plan`), 소비자 프로젝트의 실행기(`harness/runtime-audit`)가 쓴 보고서를 그 계획에 묶어 검증합니다(`ingest`). **서버는 브라우저를 띄우지도 명령을 실행하지도 않습니다** — 계획은 선언형 데이터뿐이고, 계획 id와 파일 해시에 묶이지 않은 보고서는 거절하며 모든 개수를 항목에서 다시 셉니다 +- `devup_screen_plan`: 페이지를 쓰기 **전에** Figma 화면마다 어느 URL이 될지 계획하고(`draft`), 열린 결정을 구현 전에 질문 라운드로 연달아 모두 물은 뒤 확정하고(`confirm`), 확정된 계획을 실제 라우트와 runtime audit 보고서에 대조해 URL × 폭 표로 보고(`status`). 라우트는 근거(그 프레임으로 만든 기존 페이지, 기존 href에 묶인 nav 라벨, `navigationTargets`, 프로젝트 설정)로만 제안하고, 레이어 이름으로 추정한 것은 `name-match`로 드러내 묻고, 나머지는 `UNVERIFIED` 질문이 됩니다. 확정 뒤에는 묻지 않고 가정(`assume`)으로 기록하며, `auditPlan`으로 URL마다 페이지·섹션·에셋·위치·상태를 모두 검증하는 audit 계획을 쓰고 `defer`로 결정을 미뤄 두고 멈추지 않습니다 `devup_figma_to_ui`와 `devup_figma_to_json`은 각각 `devup_figma_export`에 `outputs: ["tsx"]`, `outputs: ["devupJson"]`을 넘긴 것과 같아서 제거했습니다. 도구가 셋이면 모든 클라이언트가 세 개의 스키마를 컨텍스트에 싣고도 어느 것을 부를지 매번 판단해야 했습니다. @@ -1047,6 +1048,58 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 360·768·1200·1440 폭 × 화면 2개 × 검사 8개 계획에서 `plan`은 4.5 ms(structured content 9.2 KB), 스크린샷 8장을 확인하는 `ingest`는 18.9 ms(12.5 KB)였습니다. 중단된 실행 이어가기(resume), 부수효과가 있는 단계, 데모 녹화·TTS·자막, 인계 패키지는 구현하지 않았습니다. +### `devup_screen_plan` — 화면 계획 먼저, 확인은 한 번, 구현은 그 다음 + +"이 디자인을 구현해줘"를 받은 에이전트가 곧바로 페이지를 쓰면, 어느 Figma 화면이 어느 URL이 됐는지 사용자는 끝내 모릅니다. 그래서 순서를 고정합니다. + +1. `draft` — Section/frame 링크(또는 브리지 선택, 또는 앞선 `devup_figma_explore`의 `screens`)와 `projectRoot`를 받아, 이름이 같은 프레임을 그룹으로 모으고(한 페이지인지 여러 페이지인지는 그룹마다 묻되, 같은 Section 안의 여러 폭이면 "One responsive page", 다른 Section이면 "Separate pages"를 추천), 화면마다 `proposedRoute`·`routeEvidence`·`existingFile`·`sharedParts`·`openDecisions`를 담은 `.devup/screen-plan.json`과 같은 내용의 `.devup/screen-plan.md`를 씁니다. 근거 없는 라우트는 `proposedRoute: null`, `routeEvidence: "UNVERIFIED"`이고 질문이 됩니다. 응답의 `questions`는 질문 도구(OpenCode `question`, Claude Code `AskUserQuestion`)에 그대로 넣는 모양 — `id`, 30자 이하 `header`, `question`, 추천을 맨 앞에 두고 " (Recommended)"를 붙인 `options[{label, description}]`, `multiple` — 이고, 한 번에 최대 4개씩 나눈 `questionRounds`와 `askInstruction`이 함께 옵니다. 라운드는 구현 전에 연달아 모두 묻습니다. `when`이 붙은 질문(예: "Separate pages"를 고른 그룹의 프레임별 라우트, "Review individually"를 고른 이름 추정 항목)은 그 답일 때만 묻습니다. 레이어 이름으로 추정한 라우트는 "Accept all (Recommended)" / "Review individually" 한 질문으로 묶어 확인합니다. +2. `confirm` — 해당하는 질문 id별 답을 **한 번에** 받습니다. 하나라도 빠지면 빠진 id를 이름으로 대며 거절하고, 아무것도 바꾸지 않습니다. 확정 뒤 구현 중에는 사용자에게 아무것도 묻지 않습니다. 새로 생긴 결정은 추천안이나 가장 보수적인 선택을 따르고 `assume`으로 계획의 `assumptions`에 남기며, `status`와 최종 보고에 함께 나옵니다. +3. `status` — 확정된 계획을 `devup_project_context scope:"routes"`의 실제 페이지와, 선택적으로 `devup_runtime_audit`의 plan/report에 대조합니다. URL마다 구현 여부와 폭별 `pass`·`fail`(가로 넘침, 페이지 오류 등 이유)·`not-run`을 내고, 요약 한 줄(`7 URLs: 5 implemented, 3 verified at all widths, /about overflows at 390`)과 `URL | Figma screen | file | status | widths verified | screenshots` 표를 줍니다. audit 항목이 없는 폭은 `not-run`이지 `pass`가 아닙니다. + +`devup_figma_export`는 `projectRoot`가 있고 페이지를 쓰는 호출(`pageScaffold` write, `app/` 아래 `outputPaths`, `outputPaths`가 있는 여러 프레임·`allScreens`)인데 그 프레임을 덮는 확정 계획이 없으면 쓰지 않고 `status: "plan_required"`와 `devup_screen_plan draft`로 가는 `nextAction`을 돌려줍니다. 컴포넌트 하나, 쓰지 않는 미리보기는 막지 않습니다. 확정 계획이 있으면 `pageScaffold.route`는 계획에서 가져오고, 다른 라우트를 넘기면 `routeMismatch`로 거절합니다. 전체 흐름은 `devup_skills`로 설치하는 `devup-implementation-plan` 스킬에 있습니다. + +디자이너마다 이름 짓는 방식이 달라서, 프로젝트는 선택적으로 `.devup/screen-plan.config.json`에 `modalKeywords`(모달로 볼 레이어 이름 단어, 기본 목록을 대체), `routeAliases`(레이어 이름 → 라우트), `widthGrouping`(한 Section 안의 여러 폭: `responsive` | `separate` | `ask`, 기본 `ask`), `excludeKeywords`(구현 대상이 아닌 영역을 표시하는 단어, 기본 목록을 대체), `includeKeywords`(이 단어가 든 이름은 제외하지 않음)를 둘 수 있습니다. 있는 항목은 근거로 쓰이고 그에 해당하는 질문을 대신합니다. + +범위는 링크가 가리킨 노드 아래로만 한정합니다. 파일 전체나 다른 페이지로 넓히지 않고(`outOfScope`에 기록), 이름이 휴지통/trash, 아카이브/archive, 구버전/old, 사용안함/deprecated, 백업/backup, 작업중/draft/WIP, 테스트/test, sandbox, playground, 사본/copy인 페이지·Section·프레임과 숨겨진 프레임은 짓지 않습니다. 제외한 것은 `excluded`에 이유와 함께 남기고 `.md`와 확인 질문("These N were excluded ... Accept the exclusions (Recommended) / Review individually")에 보여 줍니다. 링크 자체가 그런 영역 안을 가리키면 제외하지 않고 한 번 묻습니다. + +같은 이름의 프레임이 서로 다른 Section에 있으면(PC/Mobile처럼 기기 이름 Section 포함) 서버가 정하지 않습니다. Section 이름·프레임 이름·폭·nodeId와 관련 있어 보이는 이유를 적은 질문으로 "Separate pages: the sections are different pages", "One responsive page: the sections only split by device"(모든 Section 이름이 기기 이름일 때만 추천), "The Figma structure is wrong: I will fix it" 중에서 묻습니다. 한 화면이 여러 Section에 나뉜 경우, Section 밖에 홀로 있는 프레임, 한 Section 안의 같은 이름·같은 폭 중복도 같은 "고치겠다" 선택지로 묻습니다. 고치겠다고 한 프레임은 짓지 않고 `status`와 최종 보고의 "Design fixes requested"에 옮기거나 이름을 바꿀 프레임과 Section을 정확히 적습니다. 에이전트는 기다리지 않고 나머지를 계속합니다. export gate는 제외되었거나 디자인 수정 대상인 프레임의 페이지 쓰기를 가정(`assume`)이 있어도 거절합니다. + +사이트 완성도: Figma가 그린 것만으로 만든 사이트는 hover·focus·전환·metadata·아이콘이 없어 "죽어" 보입니다. 그래서 계획은 둘로 나눕니다. **에이전트가 묻지 않고 채우는 것**(`defaults`에 기록, 최종 보고에 표시): 모든 링크·버튼·카드·탭·입력의 `_hover`·`_focusVisible`·`_active`·`_disabled`·`cursor`(Figma 상태 variant 우선, 없으면 기존 테마 토큰으로만 만든 작은 대체 — 새 색은 만들지 않음), 한 번만 정의한 약 0.2s `transition`, `_motionReduce`, 루트·페이지별 `metadata`(openGraph 포함, devfive-frontend 규칙), ``, alt, 이미지 크기, not-found 페이지. **같은 사전 질문 라운드에서 묻는 것**: 파비콘·공유(OG) 이미지(기존 파일, 범위 안의 Figma 로고/og 노드, 직접 제공 중에서 — 직접 그리거나 생성하지 않음), 프로젝트에 없을 때만 사이트 이름·운영 URL, 흔적이 있을 때만 분석/인증 태그, hover 이상의 모션("None" 추천). `.devup/screen-plan.config.json`의 `favicon`·`ogImage`·`siteName`·`siteUrl`·`motion`으로 미리 답할 수 있습니다. `status`는 파비콘, URL별 metadata, OG 이미지, hover/focus 적용 비율, reduced-motion을 filled / asked-and-answered / missing으로 보고하고, `devup_ui_validate`는 상태 없는 상호작용 요소(`interactive-states`), transition 없는 `_hover`(`hover-transition`), metadata 없는 page/layout(`page-metadata`), projectRoot에 아이콘이 없는 앱(`app-icon`)을 알립니다. + +상호작용은 역할별 레시피로 채웁니다(버튼·보조 버튼·텍스트 링크·링크 카드·내비/탭·아이콘 버튼·입력·아코디언·모바일 메뉴마다 hover, pressed, focus 링, 현재 페이지(`aria-current`), 펼침(`aria-expanded`), disabled, loading). 절제가 규칙입니다: Figma 상태 variant 우선, 기존 토큰만, 상태당 속성 2개 이하, 150–250ms 한 번 정의, bounce·무한 애니메이션 금지, 이동 4px·scale 0.97–1.05 이내, `_motionReduce`에서 모두 끔, 상호작용 없는 요소는 hover 없음. 페이지 전환(View Transitions)은 항상 켭니다: 의존성 업데이트가 먼저 돌아 항상 최신 Next·React이므로 버전을 보지 않고 `import { ViewTransition } from 'react'`를 각 `page.tsx`에 씁니다(짧은 cross-fade, reduced-motion에서 끔). 설정 파일의 `viewTransitions: false`나, `updateDependencies: false`로 React 19.3 미만에 머문 경우에만 건너뜁니다. 동작은 의심하지 않고 검증합니다: runtime audit의 `view-transition` 검사가 링크를 따라가 `document.startViewTransition` 호출(또는 cross-document `pagereveal`)을 확인하고, 실패는 고칠 버그입니다. 저장소에 앱이 여럿이면 이름 순서가 아니라 의미로 고릅니다: 마케팅 화면은 공개 사이트(`apps/front`), "Admin - 회원관리" 같은 화면은 `apps/admin`, 섞인 파일은 화면마다 앱을 정합니다(route 표의 `app` 열). 기준은 "관리자라는 분명한 근거가 없으면 공개 사이트"라서, 단서 없는 화면도 공개 사이트로 가고 공개 사이트로 보이는 앱이 하나도 없을 때만 묻습니다. 에이전트 자신의 질문은 `extraQuestions`로 같은 사전 라운드에 넣고, 끝은 제출 체크리스트(모든 URL 구현, build·lint·typecheck 통과, validate 오류 없음, 아래 audit의 실패 0, 사이트 완성도 채움, 최종 보고서)입니다. + +#### 모든 것을 시각으로 검증하고, 멈추지 않는다 + +규칙은 셋입니다: **모든 화면·섹션·에셋을 그려진 모든 폭에서 시각으로 검증한다**, **기다리느라 멈추지 않고 작업 순서를 바꾼다**, **완료 = 실패 0, 아니면 증거와 함께 unresolved로 남긴다**. + +- `devup_screen_plan` `action: "auditPlan"`(확정 뒤)은 확정된 URL마다 runtime audit 계획을 `.devup/runtime-audit//plan.json`에 쓰고 실행기를 `.devup/runtime-audit/runner/`에 한 번 씁니다. `artifacts`에 그 계획 프레임들의 `devup_figma_export` artifactId를 모두 넘깁니다. 폭은 그려진 폭(뷰포트 높이 = 프레임 높이) + 1440/1100/858/390입니다. 모든 폭에서 page health(페이지 오류·콘솔 오류·"This page couldn't load"/"Application error" 텍스트 없음), 가로 넘침, 잘린 텍스트, 내비 잘림을 보고, 그려진 폭에서는 페이지 전체를 referencePng와, 프레임의 최상위 자식(섹션)과 에셋을 각자의 native-node 렌더와 비교하고, 섹션·카드·에셋의 크기와 **위치**(`bounds` 검사; 카드 순서가 틀리면 실패)를 봅니다. 링크·버튼 전체의 hover/focus와 다음 URL로의 view-transition 검사도 들어가며, view-transition은 코드가 맨 뒤로 보내고 실행기가 끝나면 원래 URL로 돌아옵니다. 빠진 referencePng·렌더·프레임은 `missingReferences`에 만드는 export 호출과 함께 나옵니다 — 조용히 건너뛰지 않습니다. +- 섹션·카드·에셋은 `data-figma-node-id`로 찾습니다. `projectRoot`가 있는 export는 페이지 루트, 섹션, 그 자식, 에셋에 이 속성을 씁니다(projectRoot 없는 export는 플러그인과 같은 출력 그대로). 끄려면 설정 `figmaNodeIds: false`, 프로덕션에서 빼려면 Next의 `compiler.reactRemoveProperties: { properties: ["^data-figma-node-id$"] }`. +- `status`에 `auditPlanPath`·`auditReportPath`를 주면 모든 실패를 분류합니다: `runtime-error`, `overflow`, `layout-position`, `layout-size`, `asset-mismatch`(멀티컬러 SVG에 mask, 잘못된 파일, crop/objectFit 순으로 원인 제시), `section-visual`, `page-visual`, `interaction`, `view-transition` — 각각 Figma 노드, 코드 파일, 구체적인 수정 힌트를 담습니다. 반환된 `visualComparisons` 호출(`threshold`·`diffPath` 포함)을 돌리고 결과를 `visualResults`로 넘깁니다. 안티앨리어싱은 실패가 아닙니다: 바뀐 픽셀 비율 허용치가 페이지·섹션 3%, 에셋 5%이고, 설정 `visualThresholds`(`page`, `section`, `asset`, 노드별 `nodes`)로 조정합니다. +- 같은 finding은 새 실행마다 한 번만 시도로 셉니다. `maxFixAttempts`(설정, 기본 3)번 고치고도 실패하면 스크린샷·diff 경로와 함께 `unresolved`로 기록하고 작업 큐에서 뺍니다. `workQueue`는 URL마다 build → audit → fix → compare 순이고 막힌 일은 뒤로 갑니다. +- 확정 뒤 생긴 결정은 `action: "defer"`(`decision: {id, about, question, options, recommended, destructive}`)로 남기고 계속 일합니다. 마지막에 `assume`(`about`: 결정 id)으로 추천안을 택하고, 파괴적인 결정은 만들지 않은 채 보고서에 남습니다. 실행기를 설치할 수 없으면 `toolingUnavailable`로 그 URL의 audit를 inconclusive로 두고 계속합니다. +- `status`의 `finalReport`: URL × 폭 표(health, geometry, drift / converter bugs, page, section, asset, interaction, 스크린샷, diff), converter bug와 repro 파일, unresolved finding과 증거, 처리된 보류 결정, 가정, 디자인 수정 요청, 제외, 의존성 줄. + +#### 레이아웃은 정확히 맞아야 한다 + +Figma 프레임은 정적으로 변환되므로, 그려진 폭에서 크기·위치·padding·gap은 "대충 맞는가"가 아니라 맞거나 틀리거나입니다. 그래서 `bounds` 검사에는 설정 가능한 허용치가 없고 반올림 0.5px만 허용합니다(Chromium의 getBoundingClientRect는 1/64px LayoutUnit을 DPR 1에서 픽셀에 맞추지 않고 돌려주고, Figma의 소수 px는 변환기가 반올림해 씁니다). 계획된 폰트가 먼저 로드되므로 텍스트 박스도 정확해야 하며, 폰트가 없으면 레이아웃 실패가 아니라 환경 오류입니다. 이미지·픽셀 비교만 안티앨리어싱 임계값(페이지·섹션 3%, 에셋 5%)을 유지합니다. 그려진 폭 사이에는 디자인이 없으므로 health, 넘침, 포함, 잘린 텍스트, 내비만 봅니다. + +불일치는 승인하지 않고 원인으로 분류합니다. `projectRoot`가 있는 export는 노드마다 쓴 내용을 `.devup/exports/<노드>.json`에 기록하고, `status`가 실패한 요소의 코드를 그 기록과 비교합니다. + +- `code-drift`: export가 쓴 속성이 빠졌거나 바뀌었거나, export가 쓰지 않은 레이아웃 속성이 더해졌습니다(핸들러·hover 스타일·링크 추가는 drift가 아닙니다). 해법은 export 값 복원이며, 레이아웃을 손으로 조정하지 않습니다. +- `converter-bug`: 코드가 export 그대로인데 브라우저 박스가 Figma와 다릅니다. devup-mcp 결함이므로 재현 정보(file key, node id, Figma 박스, DOM 박스, 생성된 props, sourceMap 항목)를 `.devup/converter-bugs/<노드>.json`에 쓰고 `converterBugs`와 최종 보고서에 남깁니다. 결정적인 결과라 재시도하지 않으며 `maxFixAttempts`도 쓰지 않습니다. 사이트를 마치려고 로컬 override를 둘 수는 있지만 repro 파일을 가리키는 주석이 있어야 합니다. + +폭마다 프레임이 따로 있는 페이지를 `responsiveTsx`로 합치면 각 요소가 `data-figma-node-id`(가장 넓은 폭의 노드)와 `data-figma-node-ids="1440:12:34 390:56:78"`(그려진 폭마다 `폭:노드`, 공백 구분, `[data-figma-node-ids~="390:56:78"]`로 선택)를 가집니다. audit 계획은 폭마다 그 id로 섹션·카드·에셋을 찾고, 어떤 폭에서 그리지 않은 요소는 그 폭에서 숨겨져 있는지(`hidden` 검사) 확인합니다. + +동적 라우트(`[slug]`, `[id]`, catch-all)는 `notAudited`로 넘기지 않고 실제 URL로 검사합니다: 라우트의 `generateStaticParams`(리터럴, 또는 읽는 로컬 데이터 파일) → 이미 검사한 페이지의 `a[href]`(실행기가 페이지마다 모으고 다음 `auditPlan`이 사용) → 매핑된 Figma 상세 프레임의 제목으로 만든 샘플(가정으로 기록) 순서이며, 10개 이하면 전부 검사합니다. 셋 다 없을 때만 이유와 함께 `notAudited`입니다. + +`data-figma-node-id(s)`는 프로덕션에 남지 않습니다. vinext와 devup-ui Vite 플러그인에는 속성을 지우는 옵션이 없어서, `auditPlan`이 각 앱의 vite.config 옆에 작은 Vite 플러그인(`vite-plugin-strip-figma-node-ids.mjs`)을 둡니다. `enforce: 'pre'`, `apply`가 `vite build`일 때만이며 `DEVUP_AUDIT=1`이면 남깁니다. Rolldown은 transform 훅 뒤에 TSX/JSX를 변환하므로 이 단계에서는 속성이 그대로 JSX 텍스트입니다. `plugins: [stripFigmaNodeIds(), vinext()]`로 연결하고, audit는 dev 서버나 `DEVUP_AUDIT=1 bun run build`로 돌리며, 제출 체크리스트는 빌드 출력에서 `grep -rl data-figma-node-id dist`가 아무것도 찾지 않아야 통과합니다. `next` CLI에서는 `compiler.reactRemoveProperties`를 씁니다. + +최신 스택이 기본입니다: Next App Router를 vinext(Vite)로 실행하고, bun workspaces(`apps/*`), devup-ui, oxlint(eslint-plugin-devup), 최신 버전. `vinext` 의존성·`vinext` 스크립트·vinext를 import하는 `vite.config`가 있으면 `next` 의존성이 없어도 App Router 앱으로 보고, `devup_project_context`의 ui/routes가 앱별 `frameworks`를 알려 줍니다. 구현 전에는 묻지 않고 저장소 루트에서 `bunx @dependency-check-updates/cli -d -u --rm`(-d: 루트 아래 모든 manifest, -u: 복구 가능한 일괄 적용, --rm: 각 manifest 옆의 lockfile·설치 디렉터리 삭제 — Cargo.lock·target/ 포함) → `bun install` → build·lint·typecheck를 하고, 라이브러리 API는 기억이 아니라 현재 문서를 봅니다. 결과는 `assume`의 `dependencyUpdate`로 남기고 `status`에 dependencies 줄로 나옵니다. 앱이 없으면 `dev-five-git/service-template`에서 시작하고(이름·구성만 질문), `next` CLI 프로젝트에는 "Migrate to vinext (Recommended) / Keep next"를 한 번 묻습니다. 설정 파일의 `framework`·`updateDependencies: false`로 미리 답할 수 있습니다. + +명시적 시작점 `/devup [figma url] [notes]`: `devup_skills install`이 `devup` 스킬을 다른 스킬과 함께 쓰고(Claude Code는 프로젝트 스킬을 같은 이름의 슬래시 명령으로 제공하므로 `.claude/skills/devup/SKILL.md`가 곧 `/devup`), OpenCode에는 `.opencode/command/devup.md`도 씁니다. 명령 위치가 검증되지 않은 다른 런타임은 지원하지 않는다고 보고합니다. `devup_skills status`의 `command`가 설치 여부를 알려 줍니다. `/devup` 없이도 Figma로 페이지나 사이트를 만들어 달라는 요청이면 같은 흐름을 시작합니다. + +질문 도구에 호출당 개수 제한이 없으면(OpenCode `question`) 평평한 `questions` 목록을 한 번에 묻고, 제한이 있으면(Claude Code `AskUserQuestion`: 4) `maxQuestionsPerRound`(기본 4)로 나눈 `questionRounds`를 연달아 묻습니다. + ## 읽기 전용·개인정보 보호 - upstream 호출은 `get_metadata`, `get_variable_defs`, `get_design_context`, `get_code_connect_map`, `get_screenshot`과 내장된 read-only `use_figma` script로 닫혀 있습니다. @@ -1116,7 +1169,7 @@ absolute 자식이 있는 프레임은 생성 코드가 containing block을 만 `overflowDirection`은 캡처 manifest에 포함됩니다. `NONE`은 명시적 비스크롤, JSON `null`은 수집 시 원본 속성이 없음, 키 부재는 이전 수집기의 미수집, `fieldErrors.overflowDirection`은 읽기 실패입니다. 세로/가로/양방향 스크롤은 각각 `overflowY="auto"`/`overflowX="auto"`/`overflow="auto"`로 생성합니다. `clipsContent=true`는 스크롤을 취소하지 않으며, 한 축 스크롤에서는 다른 축을 clip합니다. ABSOLUTE FIXED 높이는 파생 padding이 있어도 명시적 높이로 보존합니다. FILL/HUG를 px로 고정하지 않습니다. -일반 화면 export도 수집이 길어지면 약 1초의 초기 대기 후 `exportJob`을 반환합니다(`assetJob`은 호환 별칭). `jobId`로 상태를 조회하고 paused 상태는 `jobAction:"resume"`으로 재개합니다. `calls`의 frame/root ID·pagination·elapsedMs와 projection 시간을 확인할 수 있습니다. 예산은 frame-output unit당 5–20초의 계획용 추정이며 보장이 아닙니다. 요청 한도 6 frames/12 units와 별개로 upstream 지연은 job으로 처리합니다. 지연 프레임을 분리하려면 한 프레임씩 호출하십시오. 작업은 서버 프로세스 안에서 30분, 완료 응답은 5분 보존되며 재시작을 넘겨 보존되지 않습니다. +일반 화면 export도 수집이 길어지면 약 1초의 초기 대기 후 `exportJob`을 반환합니다(`assetJob`은 `exportJob`을 가리키는 호환 참조이며 본문을 반복하지 않습니다). `jobId`로 상태를 조회하고(자산 행은 assetId·status·byteLength·sha256·fileState 위주로 간결하게 반환되며, 전체 행은 조회에 `responseFields:["exportJob.assets"]`를 붙여 선택합니다) paused 상태는 `jobAction:"resume"`으로 재개합니다. `calls`의 frame/root ID·pagination·elapsedMs와 projection 시간을 확인할 수 있습니다. 예산은 frame-output unit당 5–20초의 계획용 추정이며 보장이 아닙니다. 요청 한도 6 frames/12 units와 별개로 upstream 지연은 job으로 처리합니다. 지연 프레임을 분리하려면 한 프레임씩 호출하십시오. 작업은 서버 프로세스 안에서 30분, 완료 응답은 5분 보존되며 재시작을 넘겨 보존되지 않습니다. artifact의 `artifactState`는 `expired`(확인된 TTL 만료), `evicted`(확인된 캐시 제거), `issued-by-another-process`(다른 devup-mcp 프로세스가 발급한 id — 재시작 전의 서버나 다른 세션의 서버), `unknown`(현재 서버에서 원인 판단 불가)을 구분합니다. artifactId의 앞 6바이트가 발급한 프로세스의 표식이라 디스크에 아무것도 쓰지 않고 구분하며, id는 그대로 43자입니다. 최근 제거 64개 이내의 복구 metadata가 있으면 canonical URL과 선택을 `nextAction.arguments`로 제공합니다. URL 원문의 파일 제목은 저장하지 않아도 같은 대상을 가리키는 canonical URL을 구성합니다. metadata가 없으면 `recoveryState:"unrecoverable"`로 명시하며, 서버 재시작이라고 단정하지 않습니다. diff --git a/crates/devup-mcp-devup-ui/src/codegen/compat.rs b/crates/devup-mcp-devup-ui/src/codegen/compat.rs index 09d961a1..d9de675a 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/compat.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/compat.rs @@ -330,9 +330,14 @@ pub fn render_viewport_component(input: &Value) -> Option { props.push((key.as_str(), prop, boolean)); } let (_, prop, boolean) = props.first()?; - let asset_components = children + // A mask fills the shape with one colour, so only icons painted in one + // flat colour throughout may become one; anything else keeps its pixels. + let mask_color = children .iter() - .all(|child| child.get("isAsset").and_then(Value::as_bool) == Some(true)); + .all(|child| child.get("isAsset").and_then(Value::as_bool) == Some(true)) + .then(|| single_solid_color(children)) + .flatten(); + let asset_components = mask_color.is_some(); let component = if asset_components { "Box" } else { "Image" }; let mut variants = Vec::new(); for child in children { @@ -356,18 +361,9 @@ pub fn render_viewport_component(input: &Value) -> Option { } else { prop.clone() }; - let extra = if asset_components { - let paint = children - .first()? - .get("children")? - .as_array()? - .first()? - .get("fills")? - .as_array()? - .first()?; + let extra = if let Some(color) = mask_color { format!( - " bg=\"{}\"\n maskImage={{{{\n{variant_lines}\n }}[{index}]}}\n maskPos=\"center\"\n maskRepeat=\"no-repeat\"\n maskSize=\"contain\"", - color_hex(paint)? + " bg=\"{color}\"\n maskImage={{{{\n{variant_lines}\n }}[{index}]}}\n maskPos=\"center\"\n maskRepeat=\"no-repeat\"\n maskSize=\"contain\"" ) } else { format!(" src={{{{\n{variant_lines}\n }}[{index}]}}") @@ -563,6 +559,43 @@ fn normalize_prop_name(value: &str) -> String { /// 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. +/// The one flat colour every visible fill and stroke under `nodes` shares, +/// or `None` when there is no paint, a paint is not `SOLID`, or two differ. +fn single_solid_color(nodes: &[Value]) -> Option { + let mut color = None; + let mut pending = nodes.iter().collect::>(); + while let Some(node) = pending.pop() { + for field in ["fills", "strokes"] { + for paint in node + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if paint.get("visible").and_then(Value::as_bool) == Some(false) { + continue; + } + if paint.get("type").and_then(Value::as_str) != Some("SOLID") { + return None; + } + let hex = color_hex(paint)?; + match &color { + None => color = Some(hex), + Some(current) if *current != hex => return None, + Some(_) => {} + } + } + } + pending.extend( + node.get("children") + .and_then(Value::as_array) + .into_iter() + .flatten(), + ); + } + color +} + fn color_hex(paint: &Value) -> Option { let color = paint.get("color")?; let channel = diff --git a/crates/devup-mcp-devup-ui/src/codegen/mod.rs b/crates/devup-mcp-devup-ui/src/codegen/mod.rs index a7ebe375..e1c10b4c 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/mod.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/mod.rs @@ -4,6 +4,7 @@ mod component; mod evidence; mod image; mod layout; +pub mod node_ids; mod property_derivations; pub(crate) use property_derivations::property_derivations; pub mod responsive; diff --git a/crates/devup-mcp-devup-ui/src/codegen/node_ids.rs b/crates/devup-mcp-devup-ui/src/codegen/node_ids.rs new file mode 100644 index 00000000..8146c7f5 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/node_ids.rs @@ -0,0 +1,179 @@ +//! Stable Figma node identity on the rendered DOM, so a runtime audit can +//! find a section or an asset by the node it was generated from instead of +//! by a guessed selector. +use std::collections::BTreeSet; + +use crate::codegen::CodegenOutput; +use crate::provenance::attributes::opening_elements; + +pub const NODE_ID_ATTRIBUTE: &str = "data-figma-node-id"; +/// On a page merged from one frame per width: `":"` for each +/// drawn width the element is shown at, widest first, separated by spaces, +/// so `[data-figma-node-ids~="390:56:78"]` selects it at one width. +pub const NODE_IDS_ATTRIBUTE: &str = "data-figma-node-ids"; + +/// Writes both attributes on every element of a merged page that a width +/// draws: `data-figma-node-id` is the node at the widest width it is shown +/// at, `data-figma-node-ids` the map. Returns each element written, with the +/// widths it is drawn hidden at. +pub fn annotate_merged( + screen: &mut crate::codegen::responsive::MergedScreen, +) -> Vec { + let elements = opening_elements(&screen.tsx); + if elements.len() != screen.elements.len() { + return Vec::new(); + } + let mut inserts = Vec::new(); + let mut written = Vec::new(); + for (element, merged) in elements.iter().zip(&screen.elements) { + let Some((_, widest)) = merged.ids.first() else { + continue; + }; + let Some(opening) = screen.tsx.get(element.start..element.end) else { + continue; + }; + if merged.in_text || opening.contains(NODE_ID_ATTRIBUTE) { + continue; + } + let name_end = opening[1..] + .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '.' | ':' | '-' | '$'))) + .map_or(opening.len(), |offset| offset + 1); + let map = merged + .ids + .iter() + .map(|(width, id)| format!("{width}:{id}")) + .collect::>() + .join(" "); + inserts.push(( + element.start + name_end, + format!(" {NODE_ID_ATTRIBUTE}=\"{widest}\" {NODE_IDS_ATTRIBUTE}=\"{map}\""), + )); + written.push(serde_json::json!({ + "ids": merged.ids.iter().map(|(width, id)| serde_json::json!({"width": width, "nodeId": id})).collect::>(), + "hiddenAt": merged.hidden_at, + })); + } + inserts.sort_by_key(|insert| std::cmp::Reverse(insert.0)); + for (at, text) in inserts { + screen.tsx.insert_str(at, &text); + } + written +} + +/// Writes `data-figma-node-id=""` on the opening element generated for +/// each of `node_ids`, found through the source map, and returns the ids it +/// wrote. An element that already carries the attribute is left alone. The +/// source map's internal ranges are shifted with the text, so later checks +/// against this output still line up. +pub fn annotate_node_ids(output: &mut CodegenOutput, node_ids: &BTreeSet) -> Vec { + let elements = opening_elements(&output.tsx); + let mut targets = Vec::<(usize, String)>::new(); + for entry in &output.source_map.entries { + let (Some(node_id), Some(ordinal)) = (&entry.node_id, entry.element_index) else { + continue; + }; + if !node_ids.contains(node_id) || targets.iter().any(|(_, id)| id == node_id) { + continue; + } + if targets.iter().any(|(taken, _)| *taken == ordinal) { + continue; + } + targets.push((ordinal, node_id.clone())); + } + let mut inserts = Vec::new(); + for (ordinal, node_id) in &targets { + let Some(element) = elements.iter().find(|element| element.ordinal == *ordinal) else { + continue; + }; + let Some(opening) = output.tsx.get(element.start..element.end) else { + continue; + }; + if opening.contains(NODE_ID_ATTRIBUTE) || !opening.starts_with('<') { + continue; + } + let name_end = opening[1..] + .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '.' | ':' | '-' | '$'))) + .map_or(opening.len(), |offset| offset + 1); + if name_end <= 1 { + continue; + } + inserts.push(( + element.start + name_end, + format!(" {NODE_ID_ATTRIBUTE}=\"{}\"", node_id.replace('"', "")), + node_id.clone(), + )); + } + inserts.sort_by_key(|insert| std::cmp::Reverse(insert.0)); + let mut written = Vec::new(); + for (at, text, node_id) in inserts { + output.tsx.insert_str(at, &text); + for entry in &mut output.source_map.entries { + if let Some(range) = &mut entry.generated_range { + if range.start >= at { + range.start += text.len(); + } + if range.end >= at { + range.end += text.len(); + } + } + } + written.push(node_id); + } + written.sort(); + written +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::codegen::{CodegenOptions, generate_component}; + use devup_mcp_figma::Snapshot; + use serde_json::json; + + #[test] + fn the_root_and_each_section_get_their_node_id_and_ranges_follow() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey": "ids", "roots": ["p"], "diagnostics": [], "nodes": { + "p": {"id": "p", "type": "FRAME", "fields": {"layoutMode": "VERTICAL", "width": 400, "height": 300, + "childrenIds": ["a", "b"]}}, + "a": {"id": "a", "type": "FRAME", "fields": {"parentId": "p", "layoutMode": "HORIZONTAL", + "width": 400, "height": 100, "childrenIds": ["at"]}}, + "at": {"id": "at", "type": "TEXT", "fields": {"parentId": "a", "characters": "Hello", + "fontSize": 16, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}}, + "b": {"id": "b", "type": "FRAME", "fields": {"parentId": "p", "layoutMode": "HORIZONTAL", + "width": 400, "height": 200, "childrenIds": ["bt"]}}, + "bt": {"id": "bt", "type": "TEXT", "fields": {"parentId": "b", "characters": "World", + "fontSize": 16, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}} + } + })) + .unwrap(); + let mut output = generate_component(&snapshot, "p", &CodegenOptions::default()).unwrap(); + let before = output.tsx.clone(); + let wanted = ["p", "a", "b"].map(String::from).into_iter().collect(); + let written = annotate_node_ids(&mut output, &wanted); + assert_eq!(written, ["a", "b", "p"], "{before}"); + for id in ["p", "a", "b"] { + assert!( + output + .tsx + .contains(&format!("{NODE_ID_ATTRIBUTE}=\"{id}\"")), + "{}", + output.tsx + ); + } + let elements = opening_elements(&output.tsx); + for entry in &output.source_map.entries { + if let (Some(range), Some(ordinal)) = (&entry.generated_range, entry.element_index) { + let element = elements.iter().find(|e| e.ordinal == ordinal).unwrap(); + assert!( + element.start <= range.start && range.end <= element.end, + "{entry:?}" + ); + } + } + assert!( + annotate_node_ids(&mut output, &wanted).is_empty(), + "a second pass writes nothing" + ); + } +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs index 3a9f34e2..11b19938 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs @@ -1326,6 +1326,20 @@ pub struct MergedScreen { pub verified: bool, /// Where a width and the module part company, when they do. pub gaps: Vec, + /// Each opening element of `tsx`, in document order: the node every + /// drawn width made it from, and the widths it is drawn hidden at. + pub elements: Vec, +} + +/// One merged element and where it came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergedElement { + /// Inside another element's text (a `
`), not a node of its own. + pub in_text: bool, + /// (frame width, node id) for each width that draws it, widest first. + pub ids: Vec<(u32, String)>, + /// The drawn widths it is present but hidden at. + pub hidden_at: Vec, } /// Everything `project_tree` can name. Anything else the merged tree mentions @@ -1400,6 +1414,7 @@ pub fn merge_breakpoints( let mut by_slot: BySlot = std::array::from_fn(|_| None); let mut roots: BySlot = std::array::from_fn(|_| None); let mut precedence: Precedence = [usize::MAX; SLOTS]; + let mut slot_widths: BySlot = [None; SLOTS]; let mut slots = Vec::new(); for (position, breakpoint) in found.iter().enumerate() { let Some(node) = snapshot.nodes.get(&breakpoint.node_id) else { @@ -1422,6 +1437,14 @@ pub fn merge_breakpoints( by_slot[slot] = Some(tree); roots[slot] = Some(breakpoint.node_id.clone()); precedence[slot] = position; + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a frame is a small positive number of pixels wide" + )] + { + slot_widths[slot] = Some(width.max(0.0).round() as u32); + } } } slots.sort_unstable(); @@ -1433,7 +1456,9 @@ pub fn merge_breakpoints( collect_components(&tree, &mut components); let tsx = render_merged(&tree, 2); let (contract, gaps) = verify_merge(snapshot, options, &roots, &tree, &tsx); + let elements = merged_elements(&tree, &slot_widths); Ok(Some(MergedScreen { + elements, verified: gaps.is_empty() && contract.iter().all(|entry| entry["verified"] == true), tsx, components, @@ -1444,6 +1469,46 @@ pub fn merge_breakpoints( })) } +/// Where each element of the merged module came from, in the order +/// `render_merged` writes the opening elements. +fn merged_elements(tree: &Tree, slot_widths: &BySlot) -> Vec { + let mut placed = Vec::new(); + place(tree, None, [true; SLOTS], &mut placed); + placed + .iter() + .map(|placed| { + if placed.in_text { + return MergedElement { + in_text: true, + ids: Vec::new(), + hidden_at: Vec::new(), + }; + } + let mut ids = Vec::new(); + let mut hidden_at = Vec::new(); + for (slot, width) in slot_widths.iter().enumerate() { + let Some(width) = width else { + continue; + }; + match placed.tree.slots.get(slot).cloned().flatten() { + Some((node, _)) if placed.shown[slot] => ids.push((*width, node)), + _ if !placed.shown[slot] => hidden_at.push(*width), + // Shown with no node of its own at that width: nothing + // in that frame to compare it with. + _ => {} + } + } + ids.sort_by_key(|(width, _)| std::cmp::Reverse(*width)); + hidden_at.sort_by_key(|width| std::cmp::Reverse(*width)); + MergedElement { + in_text: false, + ids, + hidden_at, + } + }) + .collect() +} + /// One opening element of the merged module, in document order. struct Placed<'a> { tree: &'a Tree, diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index 5202bd62..ad4abd80 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -202,6 +202,11 @@ fn svg_asset_kind(snapshot: &Snapshot, node: &RawNode, nested: bool) -> AssetKin /// 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. +/// +/// A paint that is not one flat colour (a gradient, an image, a solid with +/// no readable colour) is `False`, not `Null`. A mask keeps only the shape +/// and fills it with one colour, so letting such a paint stay neutral merged +/// a gradient face and a solid outline into one solid blob. #[derive(Debug, Clone, PartialEq, Eq)] enum SameColor { Null, @@ -209,11 +214,15 @@ enum SameColor { Color(String), } -/// The plugin's `mergeSameColor`. +/// The plugin's `mergeSameColor`, except that a `Null` (a subtree with no +/// paint) is neutral wherever it falls. Taken literally, a `Null` after the +/// first colour spoiled it while one before was skipped, so the order of the +/// layers decided whether an icon became a mask. fn merge_same_color(current: SameColor, next: SameColor) -> SameColor { match (current, next) { (_, SameColor::False) => SameColor::False, (SameColor::Null, next) => next, + (current, SameColor::Null) => current, (current, next) if current == next => current, _ => SameColor::False, } @@ -223,20 +232,27 @@ fn merge_same_color(current: SameColor, next: SameColor) -> SameColor { enum OwnColor { /// No visible paint at all. None, - /// A paint that is not a flat colour. - Null, - /// Two flat colours that differ. + /// Two flat colours that differ, or a paint that is not a flat colour. False, Color(String), } +impl OwnColor { + fn same_color(self) -> SameColor { + match self { + Self::None => SameColor::Null, + Self::False => SameColor::False, + Self::Color(color) => SameColor::Color(color), + } + } +} + fn own_same_color( node: &RawNode, variable_tokens: Option<&std::collections::BTreeMap>, ) -> OwnColor { let view = node.typed_view(); 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; @@ -245,12 +261,11 @@ fn own_same_color( 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; + return OwnColor::False; } let Some(color) = paint_string(paint, variable_tokens) else { - return OwnColor::Null; + return OwnColor::False; }; match &target { None => target = Some(color), @@ -259,13 +274,7 @@ fn own_same_color( } } } - if !has_paints { - return OwnColor::None; - } - match target { - Some(color) => OwnColor::Color(color), - None => OwnColor::Null, - } + target.map_or(OwnColor::None, OwnColor::Color) } /// A solid paint as the plugin's `solidToString` spells it: the variable it @@ -308,10 +317,7 @@ fn same_color( 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, - }; + let own = || own_same_color(node, variable_tokens).same_color(); if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") || view .value("inferredAutoLayout") @@ -365,7 +371,8 @@ fn same_color( && fill_type(fill) == Some("IMAGE") && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") }) { - return SameColor::Null; + // A picture inside an icon has colours a mask cannot keep. + return SameColor::False; } if node_fills.iter().all(|fill| { fill.get("visible").and_then(Value::as_bool) == Some(true) @@ -373,11 +380,7 @@ fn same_color( }) { 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, - }; + return own(); } if nested && node_fills.is_some_and(|fills| { @@ -409,10 +412,8 @@ fn same_color( } 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, + own => own.same_color(), }; for child in child_ids .into_iter() diff --git a/crates/devup-mcp-devup-ui/src/ui_structure.rs b/crates/devup-mcp-devup-ui/src/ui_structure.rs index f4daa838..f2831004 100644 --- a/crates/devup-mcp-devup-ui/src/ui_structure.rs +++ b/crates/devup-mcp-devup-ui/src/ui_structure.rs @@ -82,6 +82,12 @@ pub fn validate_bundle( let mut report = validate_source(&file.content, theme, strict, source_type(&file.path)); if !report.violations.iter().any(|v| v.rule == "invalid-syntax") { report.violations.extend(structural(file, boundary)); + report + .violations + .extend(super::site::interaction_findings(&file.path, &file.content)); + report + .violations + .extend(super::site::metadata_finding(&file.path, &file.content)); } for finding in &mut report.violations { finding diff --git a/crates/devup-mcp-devup-ui/src/ui_validate.rs b/crates/devup-mcp-devup-ui/src/ui_validate.rs index e07f764a..13280cda 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -35,6 +35,8 @@ use std::collections::BTreeSet; #[path = "ui_validate/blur_layers.rs"] mod blur_layers; +#[path = "ui_validate/site.rs"] +pub mod site; use oxc_allocator::Allocator; use oxc_ast::ast::{ diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/site.rs b/crates/devup-mcp-devup-ui/src/ui_validate/site.rs new file mode 100644 index 00000000..2522185d --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/site.rs @@ -0,0 +1,721 @@ +//! Site completeness: what makes a page built from Figma feel alive and ship. +//! +//! The generator emits what Figma draws, and Figma draws a resting state. A +//! site built from it alone had no hover, focus or pressed feedback, no +//! transitions and no metadata, and nothing said so. These checks name each +//! gap in the code a caller supplies; they open no file. +//! +//! Interactive elements are sorted into roles - button, icon button, link, +//! nav item, card, input, disclosure - because each role owes different +//! feedback (the devup-implementation-plan skill's recipe table), and because +//! "too much" is as real a failure as "nothing": +//! +//! - `interactive-states` (warning): neither a hover nor a focus style. +//! - `focus-style` (warning): a hover but no focus style. Hover alone leaves +//! a keyboard user unable to see where they are. +//! - `button-active` (info): a button with a hover but no pressed state. +//! - `hover-transition` (info): a `_hover` with no `transition`, so it snaps. +//! - `too-much-motion` (warning): a transition or animation over 400ms, a +//! scale above 1.05, or an `infinite` animation. +//! - `motion-reduce` (info): a transform or animation with no `_motionReduce`. +//! - `icon-button-label` (warning): a button with no text and no `aria-label`. +//! - `nav-current` (info): a link in a nav with no `aria-current` handling. +//! - `page-metadata` (warning): an App Router `page` or `layout` that exports +//! neither `metadata` nor `generateMetadata`. + +use super::{Severity, Violation}; +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType, Span}; + +/// Interactive elements found in one source, and how many carry states. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct InteractionCoverage { + pub interactive: usize, + pub with_states: usize, + pub hover_without_transition: usize, +} + +/// What an interactive element is, which decides the feedback it owes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Role { + Button, + IconButton, + Link, + NavItem, + Card, + Input, + Disclosure, + Clickable, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Self::Button => "buttons", + Self::IconButton => "icon buttons", + Self::Link => "links", + Self::NavItem => "nav items", + Self::Card => "cards", + Self::Input => "inputs", + Self::Disclosure => "disclosures", + Self::Clickable => "click targets", + } + } + + /// The states a complete element of this role carries, as status names + /// them. + pub fn requirement(self) -> &'static str { + match self { + Self::Button => "hover+active+focus", + Self::IconButton => "hover+focus+aria-label", + Self::NavItem => "hover+focus+aria-current", + Self::Input => "hover+focus", + Self::Disclosure => "focus+aria-expanded", + Self::Link | Self::Card | Self::Clickable => "hover+focus", + } + } +} + +/// How many elements of one role a source has, and how many are complete. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RoleCoverage { + pub role: Role, + pub total: usize, + pub complete: usize, +} + +struct Element { + span: Span, + name: String, + role: Role, + hover: bool, + focus: bool, + active: bool, + transition: bool, + motion_reduce: bool, + aria_label: bool, + aria_current: bool, + /// The opening tag as written, for the motion checks. + text: String, +} + +impl Element { + fn complete(&self) -> bool { + match self.role { + Role::Button => self.hover && self.active && self.focus, + Role::IconButton => self.hover && self.focus && self.aria_label, + Role::NavItem => self.hover && self.focus && self.aria_current, + Role::Disclosure => self.focus, + Role::Input | Role::Link | Role::Card | Role::Clickable => self.hover && self.focus, + } + } +} + +struct Collector<'s> { + source: &'s str, + nav_depth: usize, + elements: Vec, +} + +fn attribute_name<'a>(item: &'a JSXAttributeItem<'a>) -> Option<&'a str> { + match item { + JSXAttributeItem::Attribute(attribute) => match &attribute.name { + JSXAttributeName::Identifier(name) => Some(name.name.as_str()), + JSXAttributeName::NamespacedName(_) => None, + }, + JSXAttributeItem::SpreadAttribute(_) => None, + } +} + +/// The literal or identifier `as` names, if any: `as="a"`, `as={Link}`. +fn as_value(node: &JSXOpeningElement<'_>) -> Option { + node.attributes.iter().find_map(|item| { + let JSXAttributeItem::Attribute(attribute) = item else { + return None; + }; + if !matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "as") { + return None; + } + match &attribute.value { + Some(JSXAttributeValue::StringLiteral(value)) => Some(value.value.to_string()), + Some(JSXAttributeValue::ExpressionContainer(container)) => { + match &container.expression { + JSXExpression::Identifier(id) => Some(id.name.to_string()), + _ => None, + } + } + _ => None, + } + }) +} + +fn element_name(node: &JSXOpeningElement<'_>) -> String { + match &node.name { + JSXElementName::Identifier(id) => id.name.to_string(), + JSXElementName::IdentifierReference(id) => id.name.to_string(), + _ => String::new(), + } +} + +/// Whether any descendant is an image, which makes a link a card. +fn has_image(children: &[JSXChild<'_>]) -> bool { + children.iter().any(|child| match child { + JSXChild::Element(element) => { + matches!( + element_name(&element.opening_element).as_str(), + "Image" | "img" + ) || has_image(&element.children) + } + JSXChild::Fragment(fragment) => has_image(&fragment.children), + _ => false, + }) +} + +/// Whether the element shows text of its own: literal text, or an +/// expression that may render text. An icon button shows neither. +fn has_text(children: &[JSXChild<'_>]) -> bool { + children.iter().any(|child| match child { + JSXChild::Text(text) => !text.value.trim().is_empty(), + JSXChild::ExpressionContainer(container) => { + !matches!(container.expression, JSXExpression::EmptyExpression(_)) + } + JSXChild::Element(element) => { + !matches!( + element_name(&element.opening_element).as_str(), + "Image" | "img" | "svg" + ) && has_text(&element.children) + } + JSXChild::Fragment(fragment) => has_text(&fragment.children), + JSXChild::Spread(_) => true, + }) +} + +impl<'a> Visit<'a> for Collector<'_> { + fn visit_jsx_element(&mut self, node: &JSXElement<'a>) { + let opening = &node.opening_element; + let tag = element_name(opening); + let as_tag = as_value(opening); + let names = opening + .attributes + .iter() + .filter_map(attribute_name) + .collect::>(); + let has = |name: &str| names.contains(&name); + let effective = as_tag.clone().unwrap_or_else(|| tag.clone()); + let nav = effective == "nav" + || opening.attributes.iter().any(|item| { + matches!(item, JSXAttributeItem::Attribute(attribute) + if matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "role") + && matches!(&attribute.value, Some(JSXAttributeValue::StringLiteral(value)) if value.value == "navigation")) + }); + // A spread may carry the states; nothing here can see into it. + let spread = opening + .attributes + .iter() + .any(|item| matches!(item, JSXAttributeItem::SpreadAttribute(_))); + let link = matches!(effective.as_str(), "a" | "Link"); + let button = effective == "button"; + let input = matches!(effective.as_str(), "input" | "select" | "textarea"); + let role = if input { + Some(Role::Input) + } else if has("aria-expanded") { + Some(Role::Disclosure) + } else if button { + Some(if has_text(&node.children) { + Role::Button + } else { + Role::IconButton + }) + } else if link { + Some(if self.nav_depth > 0 || has("aria-current") { + Role::NavItem + } else if has_image(&node.children) { + Role::Card + } else { + Role::Link + }) + } else if has("onClick") { + Some(Role::Clickable) + } else { + None + }; + if let Some(role) = role.filter(|_| !spread) { + let span = opening.span(); + self.elements.push(Element { + span, + name: tag, + role, + hover: has("_hover"), + focus: has("_focusVisible") || has("_focus") || has("_focusWithin"), + active: has("_active"), + transition: names.iter().any(|name| name.starts_with("transition")), + motion_reduce: has("_motionReduce"), + aria_label: has("aria-label") || has("aria-labelledby"), + aria_current: has("aria-current"), + text: self + .source + .get(span.start as usize..span.end as usize) + .unwrap_or_default() + .to_owned(), + }); + } + self.nav_depth += usize::from(nav); + walk::walk_jsx_element(self, node); + self.nav_depth -= usize::from(nav); + } +} + +fn source_type(path: &str) -> SourceType { + if path.ends_with(".ts") { + SourceType::ts() + } else { + SourceType::tsx() + } +} + +fn collect(path: &str, source: &str) -> Option> { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, source_type(path)).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return None; + } + let mut collector = Collector { + source, + nav_depth: 0, + elements: Vec::new(), + }; + collector.visit_program(&parsed.program); + Some(collector.elements) +} + +/// How many interactive elements one source has, and how many carry a hover +/// or focus state. A source that does not parse counts as none. +pub fn interaction_coverage(path: &str, source: &str) -> InteractionCoverage { + let elements = collect(path, source).unwrap_or_default(); + InteractionCoverage { + interactive: elements.len(), + with_states: elements + .iter() + .filter(|element| element.hover || element.focus) + .count(), + hover_without_transition: elements + .iter() + .filter(|element| element.hover && !element.transition) + .count(), + } +} + +/// Per role, how many elements a source has and how many carry everything +/// that role owes. Roles with no element are left out. +pub fn role_coverage(path: &str, source: &str) -> Vec { + let elements = collect(path, source).unwrap_or_default(); + let mut roles = elements + .iter() + .map(|element| element.role) + .collect::>(); + roles.sort(); + roles.dedup(); + roles + .into_iter() + .map(|role| RoleCoverage { + role, + total: elements + .iter() + .filter(|element| element.role == role) + .count(), + complete: elements + .iter() + .filter(|element| element.role == role && element.complete()) + .count(), + }) + .collect() +} + +/// Milliseconds of every duration written in a tag: `0.2s`, `250ms`, and a +/// bare number on a `transitionDuration`/`animationDuration` prop, which +/// devup-ui reads as milliseconds. +fn durations(text: &str) -> Vec { + let mut found = Vec::new(); + let bytes = text.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index].is_ascii_digit() + && (index == 0 + || !(bytes[index - 1].is_ascii_alphanumeric() || bytes[index - 1] == b'.')) + { + let start = index; + while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') { + index += 1; + } + let number = text[start..index].parse::().unwrap_or(0.0); + let rest = &text[index..]; + let unit_end = |unit: &str| { + rest.starts_with(unit) + && !rest[unit.len()..] + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()) + }; + if unit_end("ms") { + found.push(number); + } else if unit_end("s") { + found.push(number * 1000.0); + } + continue; + } + index += 1; + } + for prop in ["transitionDuration={", "animationDuration={"] { + if let Some(at) = text.find(prop) { + let value = &text[at + prop.len()..]; + let end = value.find('}').unwrap_or(0); + if let Ok(number) = value[..end].trim().parse::() { + found.push(number); + } + } + } + found +} + +/// Every `scale(...)` factor in a tag. +fn scales(text: &str) -> Vec { + text.match_indices("scale(") + .filter_map(|(at, _)| { + let value = &text[at + "scale(".len()..]; + let end = value.find([')', ','])?; + value[..end].trim().parse::().ok() + }) + .collect() +} + +fn violation( + source: &str, + span: Span, + rule: &'static str, + severity: Severity, + message: String, + suggestion: &str, +) -> Violation { + let start = span.start as usize; + let prefix = source.get(..start).unwrap_or(""); + let mut context = serde_json::Map::new(); + context.insert( + "line".into(), + serde_json::json!(prefix.bytes().filter(|b| *b == b'\n').count() + 1), + ); + // The same position fields every structural finding carries. + context.insert( + "column".into(), + serde_json::json!(prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1), + ); + context.insert( + "positionEncoding".into(), + serde_json::json!("1-based Unicode scalar columns; byteRange uses UTF-8 bytes"), + ); + context.insert( + "snippet".into(), + serde_json::json!( + source + .get(start..span.end as usize) + .unwrap_or("") + .chars() + .take(120) + .collect::() + ), + ); + Violation { + rule, + severity, + byte_range: [start, span.end as usize], + context, + message, + suggestion: Some(suggestion.into()), + } +} + +/// Every interaction finding for one source: missing feedback, and too +/// much of it. +pub fn interaction_findings(path: &str, source: &str) -> Vec { + let mut found = Vec::new(); + for element in collect(path, source).unwrap_or_default() { + let name = &element.name; + let mut push = + |rule: &'static str, severity: Severity, message: String, suggestion: &str| { + found.push(violation( + source, + element.span, + rule, + severity, + message, + suggestion, + )); + }; + if !element.hover && !element.focus { + push( + "interactive-states", + Severity::Warning, + format!( + "<{name}> is interactive ({}) but has neither a hover nor a focus style; it gives no feedback and a keyboard user cannot see focus on it.", + element.role.as_str() + ), + "Apply the role's recipe from the devup-implementation-plan skill: hover, _focusVisible (2px ring with offset), _active, cursor=\"pointer\" and _disabled where it applies, from existing theme tokens, with the shared transition. On a next/link, render it as so the states are devup-ui props.", + ); + } else if !element.focus { + push( + "focus-style", + Severity::Warning, + format!( + "<{name}> has a hover but no focus style; a keyboard user cannot see focus on it." + ), + "Add _focusVisible with a visible ring (2px with offset) from an existing token. Hover alone is not enough.", + ); + } + if matches!(element.role, Role::Button) && element.hover && !element.active { + push( + "button-active", + Severity::Info, + format!("<{name}> responds to hover but not to being pressed."), + "Add _active: a pressed scale(0.98) or one step darker than the hover token.", + ); + } + if element.hover && !element.transition { + push( + "hover-transition", + Severity::Info, + format!("<{name}> changes on _hover with no transition, so the change snaps."), + "Use the shared transition (150-250ms, ease or ease-out, defined once) on the properties that change.", + ); + } + let slow = durations(&element.text).into_iter().find(|ms| *ms > 400.0); + let big = scales(&element.text) + .into_iter() + .find(|scale| *scale > 1.05); + let infinite = element.text.contains("infinite"); + if slow.is_some() || big.is_some() || infinite { + let mut why = Vec::new(); + if let Some(ms) = slow { + why.push(format!("a {ms}ms duration")); + } + if let Some(scale) = big { + why.push(format!("scale({scale})")); + } + if infinite { + why.push("an infinite animation".into()); + } + push( + "too-much-motion", + Severity::Warning, + format!("<{name}> has too much motion: {}.", why.join(", ")), + "Keep durations at 150-250ms, scale between 0.97 and 1.05, movement at 4px or less, and no infinite or attention-seeking animation.", + ); + } + let moves = ["transform", "translate", "scale(", "rotate", "animation"] + .iter() + .any(|word| element.text.contains(word)); + if moves && !element.motion_reduce { + push( + "motion-reduce", + Severity::Info, + format!("<{name}> moves, and nothing turns that off for prefers-reduced-motion."), + "Add _motionReduce={{ transform: \"none\", transition: \"none\" }}, or one globalCss _motionReduce rule for the site.", + ); + } + if matches!(element.role, Role::IconButton) && !element.aria_label { + push( + "icon-button-label", + Severity::Warning, + format!( + "<{name}> shows no text and has no aria-label, so a screen reader announces nothing." + ), + "Add aria-label naming what the button does.", + ); + } + if matches!(element.role, Role::NavItem) && !element.aria_current { + push( + "nav-current", + Severity::Info, + format!("<{name}> is in a nav but never marks the current page."), + "Set aria-current=\"page\" on the link for the current route (usePathname) and give it the Figma active style.", + ); + } + } + found +} + +/// Whether a path is an App Router `page` or `layout` file. +pub fn is_route_file(path: &str) -> bool { + let path = path.replace('\\', "/"); + let mut segments = path.split('/').collect::>(); + let Some(file) = segments.pop() else { + return false; + }; + let stem = file.split('.').next().unwrap_or_default(); + matches!(stem, "page" | "layout") + && file + .rsplit('.') + .next() + .is_some_and(|ext| matches!(ext, "tsx" | "ts" | "jsx" | "js")) + && segments.contains(&"app") +} + +/// Whether a source exports `metadata` or `generateMetadata`. +pub fn exports_metadata(source: &str) -> bool { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let named = |name: &str| matches!(name, "metadata" | "generateMetadata"); + parsed.program.body.iter().any(|statement| match statement { + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(variables) => { + variables.declarations.iter().any(|declarator| { + matches!(&declarator.id, BindingPattern::BindingIdentifier(id) if named(&id.name)) + }) + } + Declaration::FunctionDeclaration(function) => function + .id + .as_ref() + .is_some_and(|id| id.name == "generateMetadata"), + _ => false, + }, + Statement::ExportNamedDeclaration(export) => export + .specifiers + .iter() + .any(|specifier| named(&specifier.exported.to_string())), + _ => false, + }) +} + +/// `page-metadata` for one file, when it is a page or layout without one. +pub fn metadata_finding(path: &str, source: &str) -> Option { + if !is_route_file(path) || exports_metadata(source) { + return None; + } + Some(violation( + source, + Span::new(0, 0), + "page-metadata", + Severity::Warning, + format!( + "{path} exports no metadata, so the page has no title, description or share card of its own." + ), + "Export metadata with title, description and openGraph (the devfive-frontend SEO rule); the root layout also carries the title template and metadataBase. A 'use client' page cannot export it: move the client part into a component.", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_link_or_button_is_a_warning_and_a_styled_one_is_not() { + let source = r#"import Link from "next/link"; +export function Nav() { + return <> + About + Go + X + Y + + ; +}"#; + let found = interaction_findings("Nav.tsx", source); + let rules = found.iter().map(|v| v.rule).collect::>(); + assert_eq!( + rules, + [ + "interactive-states", + "interactive-states", + "focus-style", + "hover-transition" + ] + ); + assert_eq!(found[0].severity, Severity::Warning); + assert_eq!(found[2].severity, Severity::Warning); + assert_eq!(found[3].severity, Severity::Info); + let coverage = interaction_coverage("Nav.tsx", source); + assert_eq!( + coverage, + InteractionCoverage { + interactive: 4, + with_states: 2, + hover_without_transition: 1 + } + ); + } + + fn rules_of(source: &str) -> Vec<&'static str> { + interaction_findings("Page.tsx", source) + .iter() + .map(|finding| finding.rule) + .collect() + } + + #[test] + fn a_button_owes_a_pressed_state_and_an_icon_button_a_label() { + let rules = rules_of( + r#"export function Cta() { return <> + Send + +; }"#, + ); + assert_eq!(rules, ["button-active", "icon-button-label"]); + } + + #[test] + fn too_much_motion_and_unguarded_motion_are_reported() { + let rules = rules_of( + r#"export function Hero() { return <> + X + Y + Z +; }"#, + ); + assert_eq!( + rules, + [ + "too-much-motion", + "too-much-motion", + "motion-reduce", + "motion-reduce" + ] + ); + assert_eq!( + durations(r#"transition="all 0.25s, color 150ms" transitionDuration={500}"#), + [250.0, 150.0, 500.0] + ); + assert_eq!(scales("scale(1.04) scale(0.98)"), [1.04, 0.98]); + } + + #[test] + fn nav_links_should_mark_the_current_page_and_roles_are_counted() { + let source = r#"export function Header() { return ; } +export function Cards() { return A; }"#; + assert_eq!(rules_of(source), ["nav-current", "interactive-states"]); + let coverage = role_coverage("Header.tsx", source) + .into_iter() + .map(|row| (row.role.as_str(), row.total, row.complete)) + .collect::>(); + assert_eq!(coverage, [("nav items", 2, 1), ("cards", 1, 0)]); + } + + #[test] + fn a_page_or_layout_without_metadata_is_a_warning() { + let bare = "export default function Page() { return
; }"; + let finding = metadata_finding("src/app/about/page.tsx", bare).unwrap(); + assert_eq!(finding.rule, "page-metadata"); + assert_eq!(finding.severity, Severity::Warning); + for with in [ + "export const metadata = { title: 'About' };\nexport default function Page() { return
; }", + "export async function generateMetadata() { return {}; }\nexport default function Page() { return
; }", + ] { + assert!(metadata_finding("app/about/page.tsx", with).is_none()); + } + assert!(metadata_finding("src/components/About.tsx", bare).is_none()); + assert!(is_route_file("app/layout.tsx")); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/multicolor_icon_mask.rs b/crates/devup-mcp-devup-ui/tests/multicolor_icon_mask.rs new file mode 100644 index 00000000..45224b00 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/multicolor_icon_mask.rs @@ -0,0 +1,159 @@ +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 solid(r: f64, g: f64, b: f64, opacity: f64) -> Value { + json!({ + "type": "SOLID", "visible": true, "opacity": opacity, + "color": {"r": r, "g": g, "b": b} + }) +} + +fn gradient() -> Value { + json!({ + "type": "GRADIENT_LINEAR", "visible": true, + "gradientTransform": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + "gradientStops": [ + {"position": 0.0, "color": {"r": 0.957, "g": 0.953, "b": 1.0, "a": 1.0}}, + {"position": 1.0, "color": {"r": 0.906, "g": 0.902, "b": 1.0, "a": 1.0}} + ] + }) +} + +/// A 64x64 icon frame whose vectors carry the given fills, one per vector. +fn icon(fills: Vec) -> Value { + let ids = (0..fills.len()) + .map(|index| format!("1:v{index}")) + .collect::>(); + let mut nodes = vec![json!({ + "id": "1:find", "type": "FRAME", + "fields": { + "name": "find", "childrenIds": ids, + "width": 64.0, "height": 64.0 + }, + "extra": {}, "fieldErrors": {} + })]; + for (id, fill) in ids.iter().zip(fills) { + nodes.push(json!({ + "id": id, "type": "VECTOR", + "fields": { + "name": "Vector", "parentId": "1:find", "childrenIds": [], + "width": 48.0, "height": 48.0, "x": 8.0, "y": 8.0, + "fills": [fill] + }, + "extra": {}, "fieldErrors": {} + })); + } + Value::Array(nodes) +} + +const PURPLE: (f64, f64, f64) = (0.333, 0.165, 1.0); + +#[test] +fn a_gradient_face_with_a_solid_outline_stays_an_image() { + // The prime "기대 효과" icons: a gradient face under solid #552AFF + // strokes. A mask keeps only the shape and fills it with one colour, so + // the face and outline merged into one purple blob and the glyph inside + // vanished. The gradient used to count as "no colour" and was ignored. + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![gradient(), solid(r, g, b, 0.75), solid(r, g, b, 0.75)]), + ); + + assert!(tsx.contains("src=\"/icons/find.svg\""), "{tsx}"); + assert!(!tsx.contains("maskImage"), "{tsx}"); + assert!(!tsx.contains("bg="), "{tsx}"); +} + +#[test] +fn a_single_colour_icon_is_still_a_mask() { + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![solid(r, g, b, 0.75), solid(r, g, b, 0.75)]), + ); + + assert!(tsx.contains("maskImage=\"url(/icons/find.svg)\""), "{tsx}"); + assert!(tsx.contains("bg=\"#552AFFBF\""), "{tsx}"); + assert!(!tsx.contains("src="), "{tsx}"); +} + +#[test] +fn two_different_solids_stay_an_image() { + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![solid(r, g, b, 1.0), solid(1.0, 0.0, 0.0, 1.0)]), + ); + + assert!(tsx.contains("src=\"/icons/find.svg\""), "{tsx}"); + assert!(!tsx.contains("maskImage"), "{tsx}"); +} + +#[test] +fn a_gradient_stroke_spoils_the_colour_too() { + // Strokes are paints the mask would flatten just the same. + let (r, g, b) = PURPLE; + let mut nodes = icon(vec![solid(r, g, b, 1.0), solid(r, g, b, 1.0)]); + nodes[1]["fields"]["strokes"] = json!([gradient()]); + let tsx = generate("1:find", nodes); + + assert!(tsx.contains("src=\"/icons/find.svg\""), "{tsx}"); + assert!(!tsx.contains("maskImage"), "{tsx}"); +} + +#[test] +fn an_unpainted_vector_leaves_the_colour_alone() { + // No paint at all is neutral: it adds no colour for a mask to lose. + let (r, g, b) = PURPLE; + let mut nodes = icon(vec![solid(r, g, b, 1.0), solid(r, g, b, 1.0)]); + nodes[2]["fields"]["fills"] = json!([]); + let tsx = generate("1:find", nodes); + + assert!(tsx.contains("maskImage=\"url(/icons/find.svg)\""), "{tsx}"); + assert!(tsx.contains("bg=\"#552AFF\""), "{tsx}"); +} + +#[test] +fn layer_order_does_not_decide_the_mask() { + // The same icon with its gradient face on top: the answer must not move. + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![solid(r, g, b, 0.75), solid(r, g, b, 0.75), gradient()]), + ); + + assert!(tsx.contains("src=\"/icons/find.svg\""), "{tsx}"); + assert!(!tsx.contains("maskImage"), "{tsx}"); +} + +#[test] +fn an_image_paint_inside_an_icon_stays_an_image() { + let (r, g, b) = PURPLE; + let tsx = generate( + "1:find", + icon(vec![ + solid(r, g, b, 1.0), + json!({"type": "IMAGE", "visible": true, "scaleMode": "FILL", "imageHash": "h"}), + ]), + ); + + assert!(!tsx.contains("maskImage"), "{tsx}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/plugin_compat.rs b/crates/devup-mcp-devup-ui/tests/plugin_compat.rs index bc60b365..410ef7c3 100644 --- a/crates/devup-mcp-devup-ui/tests/plugin_compat.rs +++ b/crates/devup-mcp-devup-ui/tests/plugin_compat.rs @@ -124,3 +124,47 @@ fn responsive_provider_covers_sections_variants_and_pure_code_first() { assert!(merged.contains("__variantProp")); assert!(merged.contains("\"size\"")); } + +#[test] +fn viewport_icons_mask_only_when_painted_in_one_colour() { + let viewport = |second_fill: serde_json::Value| { + json!({ + "type": "COMPONENT_SET", + "name": "Icon", + "componentPropertyDefinitions": { + "viewport": {"type": "VARIANT", "variantOptions": ["mobile", "desktop"]} + }, + "children": [ + { + "name": "mobile", "isAsset": true, + "variantProperties": {"viewport": "mobile"}, + "children": [ + {"type": "VECTOR", "fills": [{"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 0.0}}]}, + {"type": "VECTOR", "fills": [second_fill]} + ] + }, + { + "name": "desktop", "isAsset": true, + "variantProperties": {"viewport": "desktop"}, + "children": [ + {"type": "VECTOR", "fills": [{"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 0.0}}]} + ] + } + ] + }) + }; + + let one = render_viewport_component(&viewport( + json!({"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 0.0}}), + )) + .expect("viewport output"); + assert!(one.contains("bg=\"#000000\""), "{one}"); + assert!(one.contains("maskImage="), "{one}"); + + let gradient = render_viewport_component(&viewport( + json!({"type": "GRADIENT_LINEAR", "gradientStops": []}), + )) + .expect("viewport output"); + assert!(gradient.contains("src="), "{gradient}"); + assert!(!gradient.contains("maskImage"), "{gradient}"); +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_contract.rs b/crates/devup-mcp-devup-ui/tests/responsive_contract.rs index 49515f1e..790dd216 100644 --- a/crates/devup-mcp-devup-ui/tests/responsive_contract.rs +++ b/crates/devup-mcp-devup-ui/tests/responsive_contract.rs @@ -365,3 +365,47 @@ fn text_that_reads_differently_at_a_width_is_not_verified() { merged.gaps ); } + +/// Every merged element names the node each width drew it from, so a +/// runtime audit finds it at every drawn width; one drawn at a single width +/// is expected hidden at the other. +#[test] +fn a_merged_page_carries_the_node_of_every_width() { + let mut screen = merged(|index, nodes| { + if index == 1 { + nodes.insert( + "promo:1".into(), + json!({"id":"promo:1","type":"FRAME","fields":{ + "name":"promo","parentId":"frame:1","width":300,"height":60, + "fills":[{"type":"SOLID","visible":true,"color":{"r":0,"g":0,"b":1}}]}}), + ); + nodes["frame:1"]["fields"]["childrenIds"] = json!(["card:1", "promo:1"]); + } + }); + let written = devup_mcp_devup_ui::codegen::node_ids::annotate_merged(&mut screen); + assert!( + screen.tsx.contains( + r#"data-figma-node-id="frame:1" data-figma-node-ids="1440:frame:1 390:frame:0""# + ), + "{}", + screen.tsx + ); + assert!( + screen.tsx.contains( + r#"data-figma-node-id="card:1" data-figma-node-ids="1440:card:1 390:card:0""# + ), + "{}", + screen.tsx + ); + assert!( + screen.tsx.contains(r#"data-figma-node-ids="1440:promo:1""#), + "{}", + screen.tsx + ); + let promo = written + .iter() + .find(|element| element["ids"][0]["nodeId"] == "promo:1") + .unwrap(); + assert_eq!(promo["hiddenAt"], json!([390])); + devup_mcp_devup_ui::validation::validate_tsx(&screen.module("Page")).unwrap(); +} diff --git a/crates/devup-mcp/src/asset_batches.rs b/crates/devup-mcp/src/asset_batches.rs index b382426f..52adbeac 100644 --- a/crates/devup-mcp/src/asset_batches.rs +++ b/crates/devup-mcp/src/asset_batches.rs @@ -130,7 +130,16 @@ pub fn merge(values: &[Value]) -> anyhow::Result { } for (items, requested) in [ (&value["assetManifest"]["assets"], false), - (&value["assetJob"]["assets"], true), + // `assetJob` is only a reference to `exportJob` now; batches saved + // before that still carry the rows under the old name. + ( + if value["exportJob"]["assets"].is_array() { + &value["exportJob"]["assets"] + } else { + &value["assetJob"]["assets"] + }, + true, + ), ] { for item in items.as_array().into_iter().flatten() { let Some(id) = item["assetId"].as_str() else { diff --git a/crates/devup-mcp/src/server/asset_jobs.rs b/crates/devup-mcp/src/server/asset_jobs.rs index 4b7db4dd..c8cbda57 100644 --- a/crates/devup-mcp/src/server/asset_jobs.rs +++ b/crates/devup-mcp/src/server/asset_jobs.rs @@ -21,6 +21,23 @@ const RESULT_RETENTION: Duration = Duration::from_secs(5 * 60); const MAX_JOBS: usize = 8; const MAX_COMPLETED_JOBS: usize = 16; +/// What a compact asset row keeps: enough to see progress, verify bytes and +/// find the file, without the per-asset diagnostics. +const COMPACT_ASSET_FIELDS: &[&str] = &[ + "assetId", + "status", + "byteLength", + "sha256", + "errorCode", + "errorDetail", + "acquisitionState", + "outputPath", + "requestedOutputPath", + "fileState", + "fileStateReason", + "suggestedFilename", +]; + #[derive(Clone, Default)] pub(super) struct AssetJobs(Arc>>>); @@ -388,7 +405,17 @@ impl AssetJob { state.finished = Some(Instant::now()); } + #[cfg(test)] fn response(&self) -> Result { + self.response_with(false) + } + + /// The job as a poll reports it. Without `full`, one compact row per + /// asset: the full rows (`exportDiagnostics`, `renderInfo`, ...) repeated + /// tens of kilobytes on every poll of a running job. A poll that selects + /// with responseFields reads them, and a completed job's asset manifest + /// carries them too. + fn response_with(&self, full: bool) -> Result { let state = self.state.lock().unwrap(); let mut calls = state.calls.clone(); for call in &mut calls { @@ -414,6 +441,27 @@ impl AssetJob { } else { "pending" }); + // Bytes are held until the whole job is projected, so an + // exported asset reads `pending` until then; say so. + if asset["fileState"] == "pending" { + asset["fileStateReason"] = json!(if asset["status"] == "exported" { + "Acquired and held in memory. Files are written together in the projection-and-write stage, after the last asset of this job is acquired." + } else { + "Not acquired yet." + }); + } + } + if !full { + return COMPACT_ASSET_FIELDS + .iter() + .filter_map(|field| { + asset + .get(*field) + .filter(|value| !value.is_null()) + .map(|value| ((*field).to_owned(), value.clone())) + }) + .collect::>() + .into(); } if state.assets.len() > 32 { asset["renderInfoSummary"] = json!({"representation":asset["renderInfo"]["representation"],"outputSize":asset["renderInfo"]["outputSize"],"fidelity":asset["renderInfo"]["fidelity"],"compression":asset["renderInfo"]["compression"]}); @@ -445,7 +493,7 @@ impl AssetJob { } else { json!({"tool":"devup_figma_export","arguments":{"jobId":self.id,"jobAction":if state.state=="paused" {"resume"} else {"status"}}}) }; - let job = json!({"jobId":self.id,"budget":self.budget,"state":state.state,"stage":state.stage,"assets":assets, + let mut job = json!({"jobId":self.id,"budget":self.budget,"state":state.state,"stage":state.stage,"assets":assets, "assetProgress":{"total":state.assets.len(),"exported":state.assets.iter().filter(|a|a["status"]=="exported").count(),"failed":state.assets.iter().filter(|a|a["status"]=="failed").count(), "pending":state.assets.iter().filter(|a|a["status"]=="pending").count(),"maxAssetsPerBatch":6,"maxRetainedAssetBytes":devup_mcp_figma::MAX_COLLECTED_ASSET_BYTES, "observations":if state.assets.len()>32 {"summarized; full observations in completed manifest/audit resources"}else{"full"}}, @@ -455,6 +503,18 @@ impl AssetJob { "assetTimeoutMs":devup_mcp_figma::asset_timeout().as_millis() as u64, "nextActionReason":if terminal && next_action.is_null() { Some("This job has finished; status polling repeats the retained result. No corrective action is available.") } else { None }, "nextAction":next_action}); + if full { + job["assetRows"] = json!("full"); + } else { + job["assetRows"] = json!("compact"); + job["assetDetail"] = json!({ + "how": "Rows carry identity, status, bytes and file state only. Poll with responseFields to read full rows (exportDiagnostics, renderInfo); a completed job also carries them in its assetManifest.", + "arguments": {"jobId": self.id, "jobAction": "status", "responseFields": ["exportJob.assets"]}, + }); + } + // `assetJob` is a compatibility name for this same object. Repeating + // the body doubled every poll, so the alias now points at it. + let alias = json!({"aliasOf": "exportJob", "jobId": self.id, "state": state.state}); // Preserve the existing error contract for code-only upstream refusals, // while returning the retained job ID needed to resume the pending read. if state.state == "paused" @@ -475,18 +535,20 @@ impl AssetJob { if !error.details.is_object() { error.details = json!({}); } - error.details["exportJob"] = job.clone(); - error.details["assetJob"] = job; + error.details["exportJob"] = job; + error.details["assetJob"] = alias; return Err(error); } None => json!({"status":if state.state=="paused" {"paused"} else {"in_progress"}}), }; - value["exportJob"] = job.clone(); - value["assetJob"] = job; + value["exportJob"] = job; + value["assetJob"] = alias; Ok(value) } - pub(super) async fn wait_briefly(&self) -> Result { + /// Waits up to a second for a running job, then answers it; `full` keeps + /// every per-asset field, for a poll that selects from them. + pub(super) async fn wait_briefly(&self, full: bool) -> Result { let deadline = Instant::now() + Duration::from_secs(1); while Instant::now() < deadline { if self.state.lock().unwrap().state != "running" { @@ -494,7 +556,7 @@ impl AssetJob { } tokio::time::sleep(Duration::from_millis(10)).await; } - self.response() + self.response_with(full) } } @@ -534,7 +596,7 @@ impl DevupServer { worker.finish(result); }); } - job.wait_briefly().await + job.wait_briefly(false).await } } @@ -639,9 +701,9 @@ mod tests { let (cached_preset, _) = jobs.acquire(&operation, &request, false).unwrap(); cached_preset.captured(&[serde_json::from_value(json!({"assetId":"cached:fills:0","nodeId":"cached","field":"fills/0","sourceKind":"image-fill", "format":"webp","scale":1,"quality":80,"lossless":false,"status":"failed","errorCode":"DEVUP_ASSET_NODE_HIDDEN"})).unwrap()]); - let cached = cached_preset.response().unwrap(); - assert_eq!(cached["assetJob"]["assets"].as_array().unwrap().len(), 1); - assert_eq!(cached["assetJob"]["assets"][0]["quality"], 80); + let cached = cached_preset.response_with(true).unwrap(); + assert_eq!(cached["exportJob"]["assets"].as_array().unwrap().len(), 1); + assert_eq!(cached["exportJob"]["assets"][0]["quality"], 80); cached_preset.state.lock().unwrap().state = "paused"; cached_preset.resume(); assert_eq!(cached_preset.state.lock().unwrap().state, "running"); @@ -652,8 +714,9 @@ mod tests { false, ))); let cancelled = cached_preset.response().unwrap_err(); + assert_eq!(cancelled.details["exportJob"]["state"], "cancelled"); assert_eq!(cancelled.details["assetJob"]["state"], "cancelled"); - assert!(cancelled.details["assetJob"]["nextAction"].is_null()); + assert!(cancelled.details["exportJob"]["nextAction"].is_null()); assert_eq!( automatic.state.lock().unwrap().assets[0]["renderMode"], "auto" @@ -664,13 +727,13 @@ mod tests { "renderMode":"fill","renderInfo":{"outputSize":{"width":32,"height":16}}, "suggestedFilename":"gallery-picture-fill.png"}]}}]}), )); - let progress = fill.response().unwrap(); + let progress = fill.response_with(true).unwrap(); assert_eq!( - progress["assetJob"]["assets"][0]["renderInfo"]["outputSize"]["width"], + progress["exportJob"]["assets"][0]["renderInfo"]["outputSize"]["width"], 32 ); assert_eq!( - progress["assetJob"]["assets"][0]["suggestedFilename"], + progress["exportJob"]["assets"][0]["suggestedFilename"], "gallery-picture-fill.png" ); } @@ -723,16 +786,16 @@ mod tests { ))); let error = job.response().unwrap_err(); assert_eq!(error.details["exportJob"]["nextAction"], next); - assert_eq!(error.details["assetJob"]["nextAction"], next); - assert_eq!(error.details["assetJob"]["assets"][0]["status"], "failed"); + assert_eq!(error.details["assetJob"]["aliasOf"], "exportJob"); + assert_eq!(error.details["exportJob"]["assets"][0]["status"], "failed"); assert_eq!( - error.details["assetJob"]["assets"][0]["acquisitionState"], + error.details["exportJob"]["assets"][0]["acquisitionState"], "not-attempted" ); - assert_eq!(error.details["assetJob"]["assetProgress"]["pending"], 0); - assert_eq!(error.details["assetJob"]["assetProgress"]["failed"], 1); + assert_eq!(error.details["exportJob"]["assetProgress"]["pending"], 0); + assert_eq!(error.details["exportJob"]["assetProgress"]["failed"], 1); assert_eq!( - error.details["assetJob"]["assetProgress"]["maxRetainedAssetBytes"], + error.details["exportJob"]["assetProgress"]["maxRetainedAssetBytes"], devup_mcp_figma::MAX_COLLECTED_ASSET_BYTES ); if !correction { @@ -785,11 +848,77 @@ mod tests { json!({"outputPaths":{"asset:a":path_text,"asset:b":path_text}}), )); let response = job.response().unwrap(); - assert_eq!(response["assetJob"]["assets"][0]["fileState"], "written"); + assert_eq!(response["exportJob"]["assets"][0]["fileState"], "written"); assert_eq!( - response["assetJob"]["assets"][1]["fileState"], + response["exportJob"]["assets"][1]["fileState"], "not-written" ); std::fs::remove_file(path).unwrap(); } + + #[test] + fn polls_answer_compact_rows_and_reference_the_alias() { + let job = AssetJob { + id: "compact".into(), + key: "compact".into(), + budget: json!({}), + started: Instant::now(), + resume: Notify::new(), + cancellation: Notify::new(), + cancelled: std::sync::atomic::AtomicBool::new(false), + paths: BTreeMap::from([ + ("a".into(), "/out/a.svg".into()), + ("b".into(), "/out/b.svg".into()), + ]), + state: Mutex::new(JobState { + state: "running", + stage: "asset-export", + calls: vec![], + completed_calls: 1, + error: None, + result: None, + finished: None, + assets: vec![ + json!({"assetId":"a","status":"exported","byteLength":10,"sha256":"0".repeat(64), + "exportDiagnostics":{"large":"x".repeat(4096)},"renderInfo":{"large":"y".repeat(4096)}}), + json!({"assetId":"b","status":"pending"}), + ], + }), + }; + + let compact = job.response().unwrap(); + let rows = compact["exportJob"]["assets"].as_array().unwrap(); + assert_eq!(compact["exportJob"]["assetRows"], "compact"); + assert!(rows[0].get("exportDiagnostics").is_none(), "{compact}"); + assert!(rows[0].get("renderInfo").is_none(), "{compact}"); + assert_eq!(rows[0]["sha256"], "0".repeat(64)); + assert_eq!(rows[0]["requestedOutputPath"], "/out/a.svg"); + // Exported but not yet written: the state says why. + assert_eq!(rows[0]["fileState"], "pending"); + assert!( + rows[0]["fileStateReason"] + .as_str() + .unwrap() + .contains("projection-and-write") + ); + assert_eq!(rows[1]["fileStateReason"], "Not acquired yet."); + assert_eq!( + compact["exportJob"]["assetDetail"]["arguments"]["responseFields"], + json!(["exportJob.assets"]) + ); + // The alias names the job; it no longer repeats it. + assert_eq!( + compact["assetJob"], + json!({"aliasOf":"exportJob","jobId":"compact","state":"running"}) + ); + assert!( + serde_json::to_vec(&compact).unwrap().len() < 4096, + "{compact}" + ); + + let full = job.response_with(true).unwrap(); + assert_eq!(full["exportJob"]["assetRows"], "full"); + assert!(full["exportJob"]["assets"][0]["renderInfo"].is_object()); + assert!(full["exportJob"].get("assetDetail").is_none()); + } } diff --git a/crates/devup-mcp/src/server/audit_plan.rs b/crates/devup-mcp/src/server/audit_plan.rs new file mode 100644 index 00000000..3f34cc55 --- /dev/null +++ b/crates/devup-mcp/src/server/audit_plan.rs @@ -0,0 +1,1772 @@ +//! `devup_screen_plan action: "auditPlan"`: one runtime-audit plan per +//! confirmed URL that checks everything the Figma frames can be compared +//! with - page health at every width, the page, each section and each asset +//! against their Figma renders at the drawn widths, size and position of +//! every section, card and asset, containment and clipped text between the +//! widths, interaction states, and the view transition to the next URL. +//! +//! A plan per URL, because a page comparison needs the viewport as tall as +//! that URL's frame, and because the work goes URL by URL. +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Arc; + +use devup_mcp_figma::{AssetFormat, AssetStatus, CollectedPayload, DevupError, ErrorCode}; +use serde_json::{Value, json}; + +use super::artifacts::ArtifactStore; +use super::output::OutputPolicy; +use super::runtime_audit::{ + self, ASSET_THRESHOLD, CheckInput, PAGE_THRESHOLD, PlanInput, SECTION_THRESHOLD, ScreenInput, + ViewportInput, +}; + +pub(super) const AUDIT_DIR: &str = ".devup/runtime-audit"; +const RUNNER_DIR: &str = ".devup/runtime-audit/runner"; +const DEFAULT_WIDTHS: [u32; 4] = [1440, 1100, 858, 390]; +const DEFAULT_HEIGHT: u32 = 900; +const MAX_VIEWPORT_SIDE: u32 = 4096; +const MAX_VIEWPORTS: usize = 8; +const MAX_CHECKS: usize = 200; +/// Most concrete URLs audited for one dynamic route. +const MAX_DYNAMIC_URLS: usize = 10; +/// Font CDNs a page commonly loads; anything else outside the app is +/// blocked and recorded, which leaves the run inconclusive. +const FONT_ORIGINS: [&str; 3] = [ + "https://fonts.googleapis.com", + "https://fonts.gstatic.com", + "https://cdn.jsdelivr.net", +]; +const NAV_WORDS: [&str; 5] = ["nav", "header", "gnb", "menu", "헤더"]; + +fn invalid(message: impl Into) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} + +/// Changed-pixel ratios from `.devup/screen-plan.config.json` +/// `visualThresholds`: `page`, `section`, `asset`, and `nodes` keyed by Figma +/// node id for one asset that needs its own. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct VisualThresholds { + #[serde(default)] + pub page: Option, + #[serde(default)] + pub section: Option, + #[serde(default)] + pub asset: Option, + #[serde(default)] + pub nodes: BTreeMap, +} + +impl VisualThresholds { + fn for_node(&self, node_id: &str, scope: &str) -> f64 { + self.nodes + .get(node_id) + .copied() + .or(match scope { + "page" => self.page, + "section" => self.section, + _ => self.asset, + }) + .unwrap_or(match scope { + "page" => PAGE_THRESHOLD, + "section" => SECTION_THRESHOLD, + _ => ASSET_THRESHOLD, + }) + } +} + +/// The export artifacts the agent holds, searched for each frame's snapshot, +/// its referencePng and the native renders of its sections and assets. +pub(super) struct Sources { + payloads: Vec<(String, Arc)>, +} + +impl Sources { + pub(super) async fn load(ids: &[String], store: &ArtifactStore) -> (Self, Vec) { + let mut payloads = Vec::new(); + let mut missing = Vec::new(); + for id in ids { + match store.get(id).await { + Some(found) => payloads.push((id.clone(), found.payload)), + None => missing.push(json!({"artifactId": id, + "issue": "missing or expired; export the frame again and pass the new artifactId"})), + } + } + (Self { payloads }, missing) + } + + #[cfg(test)] + pub(super) fn from_payloads(payloads: Vec<(String, CollectedPayload)>) -> Self { + Self { + payloads: payloads + .into_iter() + .map(|(id, payload)| (id, Arc::new(payload))) + .collect(), + } + } + + /// The artifact whose snapshot holds the frame, preferring the one + /// exported for that frame and carrying its referencePng. + fn frame(&self, node_id: &str) -> Option<(&str, &CollectedPayload)> { + self.payloads + .iter() + .filter(|(_, payload)| payload.snapshot.nodes.contains_key(node_id)) + .max_by_key(|(_, payload)| { + ( + payload.target.node_id.as_deref() == Some(node_id), + payload.reference_png.is_some(), + ) + }) + .map(|(id, payload)| (id.as_str(), payload.as_ref())) + } + + fn reference_png(&self, node_id: &str) -> Option<&str> { + self.payloads + .iter() + .find(|(_, payload)| { + payload.target.node_id.as_deref() == Some(node_id) + && payload.reference_png.is_some() + }) + .map(|(id, _)| id.as_str()) + } + + /// A native-node PNG of the node at scale 1, the only render an element + /// capture at deviceScaleFactor 1 can be compared with pixel for pixel. + fn native_render(&self, node_id: &str) -> Option<(&str, &str)> { + self.payloads.iter().find_map(|(id, payload)| { + payload + .assets + .iter() + .find(|asset| { + asset.node_id == node_id + && asset.status == AssetStatus::Exported + && asset.format == Some(AssetFormat::Png) + && asset.mime_type.as_deref() == Some("image/png") + && asset.scale == Some(1.0) + && asset + .render_info + .as_ref() + .is_some_and(|info| info["representation"] == "native-node") + }) + .map(|asset| (id.as_str(), asset.asset_id.as_str())) + }) + } + + /// Nodes the export found as images or icons. A render the agent asked + /// for (sourceKind node) says nothing about what the node is. + fn asset_nodes(&self) -> BTreeSet { + self.payloads + .iter() + .flat_map(|(_, payload)| { + payload + .assets + .iter() + .filter(|asset| asset.source_kind != "node") + .map(|asset| asset.node_id.clone()) + }) + .collect() + } +} + +#[derive(Clone, Copy)] +struct Bounds { + x: f64, + y: f64, + width: f64, + height: f64, +} + +fn bounds(payload: &CollectedPayload, node_id: &str) -> Option { + let view = payload.snapshot.nodes.get(node_id)?.typed_view(); + let bbox = view.value("absoluteBoundingBox")?; + let number = |key: &str| bbox.get(key).and_then(Value::as_f64); + Some(Bounds { + x: number("x")?, + y: number("y")?, + width: number("width")?, + height: number("height")?, + }) +} + +fn visible_children(payload: &CollectedPayload, node_id: &str) -> Vec { + payload + .snapshot + .nodes + .get(node_id) + .map(|node| { + node.typed_view() + .child_ids() + .filter(|id| { + payload + .snapshot + .nodes + .get(*id) + .is_some_and(|child| child.typed_view().bool("visible") != Some(false)) + }) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn name_of(payload: &CollectedPayload, node_id: &str) -> String { + payload + .snapshot + .nodes + .get(node_id) + .and_then(|node| node.typed_view().name()) + .unwrap_or(node_id) + .to_owned() +} + +fn node(id: &str) -> Value { + json!({"css": format!("[data-figma-node-id=\"{id}\"]")}) +} + +/// The element a frame drawn at `width` made from `id`: on a single-frame +/// page it carries the id itself, on a page merged from one frame per width +/// the id is in its `data-figma-node-ids` map under that width. +fn node_at(width: u32, id: &str) -> Value { + json!({"css": format!( + "[data-figma-node-id=\"{id}\"], [data-figma-node-ids~=\"{width}:{id}\"]" + )}) +} + +fn check(value: Value) -> CheckInput { + serde_json::from_value(value).expect("audit checks are built from the schema") +} + +/// One confirmed URL and what the plan knows about it. +pub(super) struct Target<'a> { + pub url: &'a str, + pub screen: String, + pub file: Option<&'a str>, + /// (width, frame node id, frame height) for each drawn width. + pub frames: Vec<(u32, String, Option)>, + /// The URL a view-transition check follows a link to. + pub next_url: Option<&'a str>, + /// Elements of a merged page a drawn width does not show: (width, the + /// element's id at its widest width). + pub hidden: Vec<(u32, String)>, +} + +/// The runtime-audit plan for one URL, the references it could not find, +/// and what it left out. +pub(super) struct UrlPlan { + pub plan: PlanInput, + pub missing: Vec, + pub notes: Vec, +} + +pub(super) fn url_plan( + target: &Target<'_>, + base_url: &str, + sources: &Sources, + thresholds: &VisualThresholds, +) -> Result { + let origin = url::Url::parse(base_url) + .map_err(|_| invalid(format!("baseUrl {base_url} is not a URL.")))? + .origin() + .ascii_serialization(); + let mut notes = Vec::new(); + let mut missing = Vec::new(); + let mut viewports = Vec::::new(); + for (width, node_id, height) in &target.frames { + let height = height.map(|height| height.round() as u32); + let height = match height { + Some(height) if (1..=MAX_VIEWPORT_SIDE).contains(&height) => height, + Some(height) => { + notes.push(format!( + "The {width}px frame {node_id} is {height}px tall, over the {MAX_VIEWPORT_SIDE}px a viewport can be; its page comparison stays inconclusive and its sections carry the visual check." + )); + DEFAULT_HEIGHT + } + None => DEFAULT_HEIGHT, + }; + if !viewports.iter().any(|viewport| viewport.width == *width) { + viewports.push(viewport(*width, height)); + } + } + for width in DEFAULT_WIDTHS { + if viewports.len() >= MAX_VIEWPORTS { + notes.push(format!( + "{width}px left out: a plan holds at most {MAX_VIEWPORTS} widths." + )); + continue; + } + if !viewports.iter().any(|viewport| viewport.width == width) { + viewports.push(viewport(width, DEFAULT_HEIGHT)); + } + } + viewports.sort_by_key(|viewport| std::cmp::Reverse(viewport.width)); + let widest = viewports.first().map_or(1440, |viewport| viewport.width); + let drawn = target + .frames + .iter() + .map(|(width, _, _)| *width) + .collect::>(); + + // Health and containment at every width. + let mut checks = vec![ + check(json!({"kind": "page-health", "label": "page health"})), + check(json!({"kind": "no-horizontal-overflow", "label": "no horizontal overflow"})), + check(json!({"kind": "no-clipped-text", "label": "no clipped text"})), + ]; + let mut layout = Vec::new(); + let mut parts = Vec::new(); + let mut frame_refs = Vec::new(); + let assets_everywhere = sources.asset_nodes(); + let mut nav_checked = false; + for (width, frame_id, _) in &target.frames { + let Some((artifact_id, payload)) = sources.frame(frame_id) else { + missing.push(json!({"kind": "frame", "nodeId": frame_id, "width": width, + "why": "No artifact passed holds this frame; section, asset and geometry checks for this width are missing.", + "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["tsx", "referencePng"], "projectRoot": ""}}})); + frame_refs.push(json!({"width": width, "nodeId": frame_id})); + continue; + }; + let reference = sources.reference_png(frame_id); + if reference.is_none() { + missing.push(json!({"kind": "referencePng", "nodeId": frame_id, "width": width, + "why": format!("No referencePng for the frame drawn at {width}px, so its page comparison cannot run."), + "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["referencePng"]}}})); + } + frame_refs.push( + json!({"width": width, "nodeId": frame_id, "artifactId": reference, + "threshold": thresholds.for_node(frame_id, "page")}), + ); + let _ = artifact_id; + let Some(frame_box) = bounds(payload, frame_id) else { + notes.push(format!( + "Frame {frame_id} has no absoluteBoundingBox; its geometry is not checked." + )); + continue; + }; + let sections = visible_children(payload, frame_id); + let mut renders_missing = Vec::new(); + for section in §ions { + let name = name_of(payload, section); + let is_asset = assets_everywhere.contains(section) + || payload.snapshot.nodes[section].typed_view().bool("isAsset") == Some(true); + if !nav_checked + && NAV_WORDS + .iter() + .any(|word| name.to_lowercase().contains(word)) + { + nav_checked = true; + checks.push(check( + json!({"kind": "contained", "label": "navigation not cut off", + "subject": {"css": "nav >> nth=0"}, "container": {"css": "body"}}), + )); + } + // Between the drawn widths only containment holds; at a drawn + // width the frame says exactly where the section sits. + if target.frames.len() == 1 { + layout.push(check( + json!({"kind": "contained", "label": format!("{name} inside the page"), + "subject": node(section), "container": node(frame_id), "nodeId": section}), + )); + } + if let Some(section_box) = bounds(payload, section) { + layout.push(bounds_check( + &name, + section, + frame_id, + frame_box, + section_box, + *width, + )); + if !is_asset { + for part in visible_children(payload, section) { + if let Some(part_box) = bounds(payload, &part) { + parts.push(bounds_check( + &name_of(payload, &part), + &part, + section, + section_box, + part_box, + *width, + )); + } + } + } + } + let scope = if is_asset { "asset" } else { "section" }; + match sources.native_render(section) { + Some((artifact, asset)) => layout.push(capture( + &name, + section, + scope, + artifact, + asset, + *width, + thresholds.for_node(section, scope), + )), + None => renders_missing.push(section.clone()), + } + } + // Assets below the sections, each against its own render and in + // its section. + let mut stack = sections + .iter() + .map(|section| (section.clone(), section.clone())) + .collect::>(); + let mut seen = BTreeSet::new(); + while let Some((id, section)) = stack.pop() { + if !seen.insert(id.clone()) { + continue; + } + for child in visible_children(payload, &id) { + let is_asset = assets_everywhere.contains(&child) + || payload.snapshot.nodes[&child].typed_view().bool("isAsset") == Some(true); + if !is_asset { + stack.push((child, section.clone())); + continue; + } + let name = name_of(payload, &child); + if let (Some(section_box), Some(asset_box)) = + (bounds(payload, §ion), bounds(payload, &child)) + { + layout.push(bounds_check( + &name, + &child, + §ion, + section_box, + asset_box, + *width, + )); + } + match sources.native_render(&child) { + Some((artifact, asset)) => layout.push(capture( + &name, + &child, + "asset", + artifact, + asset, + *width, + thresholds.for_node(&child, "asset"), + )), + None => renders_missing.push(child.clone()), + } + } + } + for batch in renders_missing.chunks(6) { + missing.push(json!({"kind": "nativeRender", "frame": frame_id, "width": width, "nodeIds": batch, + "why": "No native-node PNG at scale 1 for these sections or assets, so their visual comparison cannot run.", + "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["tsx"], + "assetRequests": batch.iter().map(|id| json!({"nodeId": id, "renderMode": "node", "format": "png", "scale": 1})).collect::>()}}})); + } + } + let room = MAX_CHECKS.saturating_sub(checks.len() + layout.len() + 3); + if parts.len() > room { + notes.push(format!( + "{} card and child position checks left out: a screen holds at most {MAX_CHECKS} checks.", + parts.len() - room + )); + parts.truncate(room); + } + checks.extend(layout); + checks.extend(parts); + for (width, id) in &target.hidden { + checks.push(check( + json!({"kind": "hidden", "label": format!("{id} hidden at {width}"), + "subject": node(id), "nodeId": id, "widths": [width]}), + )); + } + checks.truncate(MAX_CHECKS - 3); + checks.push(check(json!({"kind": "interaction", "label": "hover on every link and button", "state": "hover", "widths": [widest]}))); + checks.push(check(json!({"kind": "interaction", "label": "focus on every link and button", "state": "focus", "widths": [widest]}))); + match target.next_url { + Some(next) => checks.push(check( + json!({"kind": "view-transition", "label": format!("view transition to {next}"), + "subject": {"css": format!("a[href=\"{next}\"] >> nth=0")}, "widths": [widest]}), + )), + None => notes.push("A single URL: no navigation to prove a view transition on.".to_owned()), + } + let first = target + .frames + .first() + .map(|(_, id, _)| id.clone()) + .unwrap_or_default(); + let screen = ScreenInput { + id: target.screen.clone(), + route_path: Some(target.url.to_owned()), + component_path: target.file.map(str::to_owned), + steps: Vec::new(), + expect: None, + checks, + figma: (!first.is_empty()).then(|| { + serde_json::from_value( + json!({"nodeId": first, "drawnWidths": drawn, "frames": frame_refs}), + ) + .expect("figma anchor from the schema") + }), + }; + let mut allowed = vec![origin]; + allowed.extend(FONT_ORIGINS.iter().map(|origin| (*origin).to_owned())); + let mut plan = PlanInput { + base_url: base_url.to_owned(), + allowed_origins: allowed, + viewports, + screens: vec![screen], + readiness: None, + fonts: fonts(sources, &target.frames), + locale: Some("en-US".to_owned()), + timezone: Some("UTC".to_owned()), + step_timeout_ms: None, + }; + runtime_audit::order_checks(&mut plan); + runtime_audit::validate_plan(&plan)?; + Ok(UrlPlan { + plan, + missing, + notes, + }) +} + +fn viewport(width: u32, height: u32) -> ViewportInput { + ViewportInput { + width, + height, + device_scale_factor: 1.0, + mobile: false, + touch: false, + } +} + +/// Size and position against the frame, exact: the executor allows only +/// half a pixel of rounding and takes no tolerance. +fn bounds_check( + name: &str, + subject: &str, + container: &str, + container_box: Bounds, + subject_box: Bounds, + width: u32, +) -> CheckInput { + let round = |value: f64| (value * 1000.0).round() / 1000.0; + check( + json!({"kind": "bounds", "label": format!("{name} size and position"), + "subject": node_at(width, subject), "container": node_at(width, container), "nodeId": subject, + "expected": {"x": round(subject_box.x - container_box.x), "y": round(subject_box.y - container_box.y), + "width": round(subject_box.width), "height": round(subject_box.height)}, + "widths": [width]}), + ) +} + +/// The font families the frames' text uses: they must be loaded before a +/// text box is measured, and a missing one is an environment error rather +/// than a layout failure. +fn fonts(sources: &Sources, frames: &[(u32, String, Option)]) -> Vec { + let mut families = BTreeSet::new(); + for (_, frame, _) in frames { + let Some((_, payload)) = sources.frame(frame) else { + continue; + }; + let mut stack = vec![frame.clone()]; + let mut seen = BTreeSet::new(); + while let Some(id) = stack.pop() { + if !seen.insert(id.clone()) { + continue; + } + let Some(node) = payload.snapshot.nodes.get(&id) else { + continue; + }; + let view = node.typed_view(); + if let Some(family) = view + .value("fontName") + .and_then(|font| font.get("family")) + .and_then(Value::as_str) + { + families.insert(family.to_owned()); + } + stack.extend(view.child_ids().map(str::to_owned)); + } + } + families.into_iter().take(20).collect() +} + +fn capture( + name: &str, + subject: &str, + scope: &str, + artifact: &str, + asset: &str, + width: u32, + threshold: f64, +) -> CheckInput { + check( + json!({"kind": "capture", "label": format!("{name} against its Figma render"), + "subject": node_at(width, subject), "nodeId": subject, "widths": [width], + "reference": {"scope": scope, "artifactId": artifact, "assetId": asset, "threshold": threshold}}), + ) +} + +pub(super) fn slug(url: &str) -> String { + let trimmed = url.trim_matches('/'); + if trimmed.is_empty() { + return "home".to_owned(); + } + trimmed + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect() +} + +/// What auditPlan was asked for. +pub(super) struct AuditRequest<'a> { + pub root: &'a str, + pub base_url: &'a str, + pub base_urls: &'a BTreeMap, + /// Only these URLs; empty means every confirmed URL. + pub only: &'a [String], + pub artifact_ids: &'a [String], + pub thresholds: &'a VisualThresholds, +} + +/// Writes every URL's plan, the runner once, and returns what to run. +pub(super) async fn write( + plan: &Value, + request: &AuditRequest<'_>, + policy: &OutputPolicy, + store: &ArtifactStore, +) -> Result { + let AuditRequest { + root, + base_url, + base_urls, + only, + artifact_ids, + thresholds, + } = *request; + let (sources, expired) = Sources::load(artifact_ids, store).await; + let routes = plan["routes"] + .as_array() + .into_iter() + .flatten() + .filter(|route| route["implementation"] == "page" && route["url"].is_string()) + .collect::>(); + let records = super::export_record::records(Path::new(root)); + // Every route as the concrete URLs it is audited at: a dynamic route at + // the URLs its params, the site's links or its Figma frame give. + let mut concrete = Vec::<(usize, String, Option)>::new(); + let mut not_audited = Vec::new(); + for (index, route) in routes.iter().enumerate() { + let url = route["url"].as_str().unwrap_or_default(); + if !only.is_empty() && !only.iter().any(|wanted| wanted == url) { + continue; + } + if !url.contains('[') { + concrete.push((index, url.to_owned(), None)); + continue; + } + let resolved = dynamic_urls(root, route, &plan["audit"]["links"], &sources); + if resolved.urls.is_empty() { + not_audited.push(json!({"url": url, "why": resolved.why})); + continue; + } + for sample in resolved.urls { + concrete.push(( + index, + sample, + Some(json!({"pattern": url, "from": resolved.source, + "assumption": resolved.assumption})), + )); + } + } + let urls = concrete + .iter() + .map(|(_, url, _)| url.as_str()) + .collect::>(); + let several_apps = routes + .iter() + .map(|route| route["app"].as_str().unwrap_or_default()) + .collect::>() + .len() + > 1; + let heights = plan["screens"] + .as_array() + .into_iter() + .flatten() + .flat_map(|screen| screen["frames"].as_array().into_iter().flatten()) + .filter_map(|frame| { + Some(( + frame["nodeId"].as_str()?.to_owned(), + frame["height"].as_f64(), + )) + }) + .collect::>(); + let runner = Path::new(root).join(RUNNER_DIR); + let mut template = (!runner.join("run.mjs").exists()).then(|| runner.display().to_string()); + let template_state = if template.is_some() { + "written" + } else if std::fs::read(runner.join("run.mjs")).ok().as_deref() + == Some(include_bytes!("../../../../harness/runtime-audit/run.mjs").as_slice()) + { + "current" + } else { + "stale: delete .devup/runtime-audit/runner and run auditPlan again to get this server's runner" + }; + let mut written = Vec::new(); + for (index, url, dynamic) in &concrete { + let route = routes[*index]; + let url = url.as_str(); + let app = route["app"].as_str().unwrap_or_default(); + let screen = if several_apps && !app.is_empty() { + format!("{}-{}", slug(app), slug(url)) + } else { + slug(url) + }; + let frames = route["nodeIds"] + .as_array() + .into_iter() + .flatten() + .zip(route["drawnWidths"].as_array().into_iter().flatten()) + .filter_map(|(node, width)| { + let node = node.as_str()?.to_owned(); + let height = heights.get(&node).copied().flatten(); + Some((u32::try_from(width.as_u64()?).ok()?, node, height)) + }) + .collect::>(); + let position = urls.iter().position(|candidate| *candidate == url); + let next_url = position + .filter(|_| urls.len() > 1) + .map(|index| urls[(index + 1) % urls.len()]); + let hidden = hidden_elements(&records, &frames); + let target = Target { + url, + screen: screen.clone(), + file: route["file"].as_str(), + frames, + next_url, + hidden, + }; + let base = base_urls.get(app).map_or(base_url, String::as_str); + let built = url_plan(&target, base, &sources, thresholds)?; + let plan_path = Path::new(root) + .join(AUDIT_DIR) + .join(&screen) + .join("plan.json"); + let report_dir = Path::new(root).join(AUDIT_DIR).join(&screen).join("report"); + let counts = built.plan.screens[0].checks.iter().fold( + BTreeMap::::new(), + |mut counts, check| { + *counts.entry(check.kind.clone()).or_default() += 1; + counts + }, + ); + let viewports = built + .plan + .viewports + .iter() + .map(|viewport| json!({"width": viewport.width, "height": viewport.height})) + .collect::>(); + let created = runtime_audit::create_plan( + built.plan, + policy, + Some(&plan_path.to_string_lossy()), + template.take().as_deref(), + store, + ) + .await?; + written.push(json!({ + "url": url, + "screen": screen, + "planPath": created["writtenTo"], + "planId": created["planId"], + "reportDirectory": report_dir.display().to_string(), + "viewports": viewports, + "checks": counts, + "missingReferences": built.missing, + "notes": built.notes, + "dynamic": dynamic, + "run": ["node", "run.mjs", "--plan", plan_path.display().to_string(), "--out", report_dir.display().to_string()], + })); + } + let missing = written + .iter() + .flat_map(|plan| { + plan["missingReferences"] + .as_array() + .cloned() + .unwrap_or_default() + }) + .collect::>(); + let strip = production_strip(&routes, root, policy)?; + Ok(json!({ + "plans": written, + "notAudited": not_audited, + "productionStrip": strip, + "missingReferences": missing, + "expiredArtifacts": expired, + "runner": {"directory": runner.display().to_string(), "state": template_state, + "install": [["npm", "ci"], ["npx", "playwright", "install", "chromium"]], + "ifInstallFails": "Record it with devup_screen_plan action status, auditPlanPath and toolingUnavailable: the URL's audit stays inconclusive and the work goes on."}, + "nodeIdentity": "Sections, cards and assets are found by data-figma-node-id, which devup_figma_export writes into a project's TSX. A check whose element is missing reports missingNodes; re-export with projectRoot.", + "nextAction": "For each URL in order: export what missingReferences names and run auditPlan again, start the app, run the plan, then call devup_screen_plan action status with auditPlanPath and auditReportPath (report/report.json). Run each visualComparisons call, pass the results back as visualResults, fix every finding, and repeat until the URL is done.", + })) +} + +/// Elements of a merged page that a route's drawn widths do not show, from +/// the export records: each must be hidden at those widths. +fn hidden_elements(records: &[Value], frames: &[(u32, String, Option)]) -> Vec<(u32, String)> { + let drawn = frames + .iter() + .map(|(width, _, _)| *width) + .collect::>(); + let roots = frames + .iter() + .map(|(_, id, _)| id.as_str()) + .collect::>(); + let mut hidden = Vec::new(); + for record in records { + let elements = record["responsive"].as_array().cloned().unwrap_or_default(); + let ours = elements.iter().any(|element| { + element["ids"] + .as_array() + .into_iter() + .flatten() + .any(|id| id["nodeId"].as_str().is_some_and(|id| roots.contains(id))) + }); + if !ours { + continue; + } + for element in &elements { + let Some(widest) = element["ids"][0]["nodeId"].as_str() else { + continue; + }; + for width in element["hiddenAt"].as_array().into_iter().flatten() { + if let Some(width) = width.as_u64().and_then(|width| u32::try_from(width).ok()) + && drawn.contains(&width) + { + hidden.push((width, widest.to_owned())); + } + } + } + } + hidden +} + +/// The concrete URLs a dynamic route is audited at, and where they came from. +pub(super) struct Dynamic { + pub urls: Vec, + pub source: &'static str, + pub why: String, + pub assumption: Option, +} + +/// `[slug]` -> ("slug", false); `[...slug]` and `[[...slug]]` -> ("slug", true). +fn params(pattern: &str) -> Vec<(String, bool)> { + pattern + .split('/') + .filter(|segment| segment.starts_with('[') && segment.ends_with(']')) + .map(|segment| { + let inner = segment.trim_matches(|c| c == '[' || c == ']'); + match inner.strip_prefix("...") { + Some(name) => (name.to_owned(), true), + None => (inner.to_owned(), false), + } + }) + .collect() +} + +fn fill(pattern: &str, values: &BTreeMap) -> Option { + let mut out = Vec::new(); + for segment in pattern.split('/') { + if segment.starts_with('[') && segment.ends_with(']') { + let inner = segment.trim_matches(|c| c == '[' || c == ']'); + out.push(values.get(inner.trim_start_matches("...")).cloned()?); + } else { + out.push(segment.to_owned()); + } + } + Some(out.join("/")) +} + +/// Every literal value of `key` in `text`: `key: 'a'`, `"key": "a"`, or +/// `key: ['a', 'b']` (joined with `/` for a catch-all). +pub(super) fn values_of(text: &str, key: &str) -> Vec { + let mut found = Vec::new(); + let bytes = text.as_bytes(); + let mut from = 0; + while let Some(at) = text[from..].find(key) { + let start = from + at; + from = start + key.len(); + let before = start.checked_sub(1).map(|i| bytes[i] as char); + if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') { + continue; + } + let mut rest = text[from..].trim_start_matches(['"', '\'']).trim_start(); + let Some(after) = rest.strip_prefix(':') else { + continue; + }; + rest = after.trim_start(); + let quoted = |rest: &str| -> Option<(String, usize)> { + let quote = rest + .chars() + .next() + .filter(|c| matches!(c, '"' | '\'' | '`'))?; + let end = rest[1..].find(quote)?; + Some((rest[1..=end].to_owned(), end + 2)) + }; + if let Some((value, _)) = quoted(rest) { + found.push(value); + } else if let Some(list) = rest.strip_prefix('[') { + let end = list.find(']').unwrap_or(list.len()); + let parts = list[..end] + .split(',') + .filter_map(|part| quoted(part.trim()).map(|(value, _)| value)) + .collect::>(); + if !parts.is_empty() { + found.push(parts.join("/")); + } + } + } + found.dedup(); + found +} + +/// Local modules a page imports: `./x`, `../x` or `@/x`, with the names +/// imported from each. +fn local_imports(source: &str) -> Vec<(String, String)> { + let mut found = Vec::new(); + for line in source.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("import ") else { + continue; + }; + let Some((names, module)) = rest.rsplit_once(" from ") else { + continue; + }; + let module = module + .trim() + .trim_end_matches(';') + .trim_matches(['"', '\'']); + if module.starts_with("./") || module.starts_with("../") || module.starts_with("@/") { + found.push((names.to_owned(), module.to_owned())); + } + } + found +} + +fn read_module(base: &Path, module: &str) -> Option { + for suffix in [ + "", + ".ts", + ".tsx", + ".js", + ".mjs", + ".json", + "/index.ts", + "/index.js", + ] { + let path = base.join(format!("{module}{suffix}")); + if path.is_file() + && std::fs::metadata(&path).is_ok_and(|meta| meta.len() <= 512 * 1024) + && let Ok(text) = std::fs::read_to_string(&path) + { + return Some(text); + } + } + None +} + +/// `generateStaticParams` in the route's page: literal params in its body, +/// else the param's values in a local data file it reads. +pub(super) fn static_params( + root: &Path, + app_dir: &str, + page_file: &str, + pattern: &str, +) -> Vec { + let page = root.join(page_file); + let Ok(source) = std::fs::read_to_string(&page) else { + return Vec::new(); + }; + let Some(at) = source.find("generateStaticParams") else { + return Vec::new(); + }; + let body = &source[at..]; + let body = body[1..] + .find("\nexport ") + .map_or(body, |end| &body[..=end]); + let names = params(pattern); + let collect = |text: &str| { + let lists = names + .iter() + .map(|(name, _)| values_of(text, name)) + .collect::>(); + let count = lists.iter().map(Vec::len).min().unwrap_or(0); + (0..count) + .filter_map(|index| { + let values = names + .iter() + .zip(&lists) + .map(|((name, _), list)| (name.clone(), list[index].clone())) + .collect::>(); + fill(pattern, &values) + }) + .collect::>() + }; + let literal = collect(body); + if !literal.is_empty() { + return literal; + } + let page_dir = page.parent().unwrap_or(root); + for (imported, module) in local_imports(&source) { + let used = imported + .split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$')) + .filter(|name| !name.is_empty() && *name != "as") + .any(|name| body.contains(name)); + if !used { + continue; + } + let text = match module.strip_prefix("@/") { + Some(rest) => read_module(&root.join(app_dir).join("src"), rest) + .or_else(|| read_module(&root.join(app_dir), rest)), + None => read_module(page_dir, &module), + }; + if let Some(text) = text { + let from_data = collect(&text); + if !from_data.is_empty() { + return from_data; + } + } + } + Vec::new() +} + +/// The first text of a frame, depth first: a detail page's title. +fn first_text(sources: &Sources, frame: &str) -> Option { + let (_, payload) = sources.frame(frame)?; + let mut stack = vec![frame.to_owned()]; + while let Some(id) = stack.pop() { + let node = payload.snapshot.nodes.get(&id)?; + let view = node.typed_view(); + if let Some(text) = view + .string("characters") + .filter(|text| !text.trim().is_empty()) + { + return Some(text.to_owned()); + } + let mut children = view.child_ids().map(str::to_owned).collect::>(); + children.reverse(); + stack.extend(children); + } + None +} + +fn sample_segment(text: &str) -> String { + let words = text + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .take(6) + .map(str::to_lowercase) + .collect::>(); + if words.is_empty() { + "sample".to_owned() + } else { + words.join("-") + } +} + +/// A dynamic route's concrete URLs: its `generateStaticParams`, else the +/// links the audited pages hold into it, else a sample from its Figma detail +/// frame (an assumption). At most ten. +pub(super) fn dynamic_urls(root: &str, route: &Value, links: &Value, sources: &Sources) -> Dynamic { + let pattern = route["url"].as_str().unwrap_or_default(); + let app_dir = route["app"] + .as_str() + .filter(|app| !app.is_empty()) + .unwrap_or("."); + let cap = |mut urls: Vec| { + urls.sort(); + urls.dedup(); + urls.truncate(MAX_DYNAMIC_URLS); + urls + }; + if let Some(file) = route["file"].as_str() { + let found = static_params(Path::new(root), app_dir, file, pattern); + if !found.is_empty() { + return Dynamic { + urls: cap(found), + source: "generateStaticParams", + why: String::new(), + assumption: None, + }; + } + } + let linked = links + .as_object() + .into_iter() + .flatten() + .flat_map(|(_, hrefs)| hrefs.as_array().cloned().unwrap_or_default()) + .filter_map(|href| href.as_str().map(str::to_owned)) + .filter(|href| super::navigation::matches_route(pattern, href)) + .collect::>(); + if !linked.is_empty() { + return Dynamic { + urls: cap(linked), + source: "links on audited pages", + why: String::new(), + assumption: None, + }; + } + let frame = route["nodeIds"][0].as_str().unwrap_or_default(); + if let Some(text) = first_text(sources, frame) { + let segment = sample_segment(&text); + let values = params(pattern) + .into_iter() + .map(|(name, _)| (name, segment.clone())) + .collect::>(); + if let Some(url) = fill(pattern, &values) { + return Dynamic { + assumption: Some(format!( + "{pattern} audited at {url}: the sample comes from the detail frame {frame}'s title \"{text}\"; the app must serve it." + )), + urls: vec![url], + source: "Figma detail frame", + why: String::new(), + }; + } + } + Dynamic { + urls: Vec::new(), + source: "none", + why: "No generateStaticParams with literal or local-data params, no link to it on an audited page (run the audit once and auditPlan again), and no detail frame text to sample.".to_owned(), + assumption: None, + } +} + +const STRIP_PLUGIN: &[u8] = + include_bytes!("../../../../harness/runtime-audit/strip-figma-node-ids.mjs"); +const STRIP_FILE: &str = "vite-plugin-strip-figma-node-ids.mjs"; + +/// Puts the plugin that strips the audit attributes from production builds +/// next to each app's vite.config, once, and says whether the config uses +/// it yet. +fn production_strip( + routes: &[&Value], + root: &str, + policy: &OutputPolicy, +) -> Result, DevupError> { + let apps = routes + .iter() + .map(|route| { + route["app"] + .as_str() + .filter(|app| !app.is_empty()) + .unwrap_or(".") + .to_owned() + }) + .collect::>(); + let mut out = Vec::new(); + for app in apps { + let dir = Path::new(root).join(&app); + let file = dir.join(STRIP_FILE); + let written = if file.exists() { + false + } else { + let target = policy.resolve(&file.to_string_lossy())?; + let mut transaction = super::output::OutputTransaction::new(); + transaction.stage("stripPlugin", target, STRIP_PLUGIN)?; + transaction.commit()?; + true + }; + let config = [ + "vite.config.ts", + "vite.config.mts", + "vite.config.js", + "vite.config.mjs", + ] + .iter() + .map(|name| dir.join(name)) + .find(|path| path.is_file()); + let wired = config + .as_ref() + .and_then(|path| std::fs::read_to_string(path).ok()) + .is_some_and(|text| text.contains(STRIP_FILE)); + out.push(json!({ + "app": app, + "plugin": file.display().to_string(), + "written": written, + "viteConfig": config.map(|path| path.display().to_string()), + "wired": wired, + "add": format!("import stripFigmaNodeIds from './{STRIP_FILE}' // then plugins: [stripFigmaNodeIds(), vinext()]"), + "nextCli": "On the next CLI instead: compiler.reactRemoveProperties: process.env.DEVUP_AUDIT === '1' ? false : { properties: ['^data-figma-node-ids?$'] }", + "audit": "Audit against `bun run dev` or an audit build: DEVUP_AUDIT=1 bun run build.", + "verify": "After `bun run build`, `grep -rl data-figma-node-id dist` (or the app's build output) must find nothing.", + })); + } + Ok(out) +} + +/// One devup_visual_compare result for a comparison status returned. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct VisualResultInput { + /// The visualComparisons id. + pub id: String, + /// The comparison's verdict. + #[schemars(extend("enum" = ["pass", "fail", "inconclusive"]))] + pub verdict: String, + /// visual.changedRatio from the result. + #[serde(default)] + pub changed_ratio: Option, + /// The diffPath the result wrote. + #[serde(default)] + pub diff_path: Option, + /// Why it was inconclusive, when it was. + #[serde(default)] + pub reason: Option, +} + +/// The order a URL's failures are worked in: a page that does not render +/// first, then overflow, then layout, then pixels, then states. +fn class_rank(class: &str) -> usize { + [ + "runtime-error", + "overflow", + "layout-position", + "layout-size", + "asset-mismatch", + "section-visual", + "page-visual", + "interaction", + "view-transition", + ] + .iter() + .position(|candidate| *candidate == class) + .unwrap_or(9) +} + +/// Folds one audit run (and the visual results for it) into the plan's +/// finding records. A finding counts a failed run once per new evidence - +/// a new report for a check, a new screenshot for a comparison - so calling +/// status twice on one report does not burn an attempt. After +/// `max_fix_attempts` fix-and-rerun rounds that still fail it is +/// unresolved, with its evidence, and leaves the work queue. +pub(super) fn record_round( + plan: &mut Value, + audit: &Value, + report_sha: &str, + visual: &[VisualResultInput], + node_files: &BTreeMap, + max_fix_attempts: u32, +) -> Value { + let audited = audit["screenTable"] + .as_array() + .into_iter() + .flatten() + .filter_map(|row| { + Some(( + row["routePath"].as_str()?.to_owned(), + row["width"].as_u64()?, + )) + }) + .collect::>(); + let comparisons = audit["visualComparisons"] + .as_array() + .cloned() + .unwrap_or_default(); + let mut current = Vec::<(Value, String)>::new(); + for finding in audit["findings"].as_array().into_iter().flatten() { + current.push((finding.clone(), report_sha.to_owned())); + } + let mut compared = BTreeSet::new(); + let mut inconclusive = Vec::new(); + for result in visual { + let Some(comparison) = comparisons.iter().find(|c| c["id"] == result.id.as_str()) else { + inconclusive.push( + json!({"id": result.id, "why": "no comparison with this id in this audit run"}), + ); + continue; + }; + compared.insert(result.id.clone()); + match result.verdict.as_str() { + "fail" => { + let threshold = comparison["call"]["arguments"]["threshold"].as_f64(); + let reason = match (result.changed_ratio, threshold) { + (Some(ratio), Some(threshold)) => format!( + "{:.1}% of pixels differ from the Figma render (allowed {:.1}%)", + ratio * 100.0, + threshold * 100.0 + ), + _ => "differs from the Figma render".to_owned(), + }; + current.push(( + json!({ + "id": result.id, + "class": comparison["class"], + "screen": comparison["screen"], + "routePath": comparison["routePath"], + "width": comparison["viewport"]["width"], + "kind": "visual", + "label": comparison["label"], + "nodeId": comparison["nodeId"], + "codeFile": comparison["codeFile"], + "reason": reason, + "fixHint": comparison["ifItFails"]["fixHint"], + "evidence": {"screenshot": comparison["screenshot"]["path"], "diff": result.diff_path, + "reference": comparison["call"]["arguments"]["reference"]}, + }), + comparison["screenshot"]["sha256"] + .as_str() + .unwrap_or_default() + .to_owned(), + )); + } + "inconclusive" => inconclusive.push(json!({"id": result.id, "class": comparison["class"], + "why": result.reason.clone().unwrap_or_else(|| "the comparison was inconclusive".to_owned())})), + _ => {} + } + } + let pending = comparisons + .iter() + .filter(|comparison| { + !compared.contains(comparison["id"].as_str().unwrap_or_default()) + && comparison["blockers"].as_array().is_none_or(Vec::is_empty) + }) + .map(|comparison| comparison["id"].clone()) + .collect::>(); + // A converter bug is reported once and kept, whatever later runs show. + for (finding, _) in ¤t { + if finding["class"] == "converter-bug" + && let Some(node) = finding["nodeId"].as_str() + { + plan["audit"]["converterBugs"][node] = json!({ + "nodeId": node, "routePath": finding["routePath"], "width": finding["width"], + "reason": finding["reason"], "repro": finding["evidence"]["repro"], + "codeFile": finding["codeFile"], + }); + } + } + let records = &mut plan["audit"]["findings"]; + if !records.is_object() { + *records = json!({}); + } + let records = records.as_object_mut().expect("an object"); + let failing = current + .iter() + .filter_map(|(finding, _)| finding["id"].as_str().map(str::to_owned)) + .collect::>(); + for (mut finding, key) in current { + let id = finding["id"].as_str().unwrap_or_default().to_owned(); + if let Some((_, file)) = finding["nodeId"] + .as_str() + .and_then(|node| node_files.get(node)) + { + finding["codeFile"] = json!(file); + } + let record = records + .entry(id) + .or_insert_with(|| json!({"failedRuns": 0})); + if record["lastEvidence"] != key.as_str() { + record["failedRuns"] = json!(record["failedRuns"].as_u64().unwrap_or(0) + 1); + record["lastEvidence"] = json!(key); + } + let fix_attempts = record["failedRuns"].as_u64().unwrap_or(1).saturating_sub(1); + for field in [ + "class", + "screen", + "routePath", + "width", + "kind", + "label", + "nodeId", + "codeFile", + "reason", + "fixHint", + "evidence", + ] { + record[field] = finding[field].clone(); + } + record["fixAttempts"] = json!(fix_attempts); + // A deterministic result does not change by retrying: a converter + // bug leaves the fix loop at once and never spends attempts. + record["status"] = json!(if finding["class"] == "converter-bug" { + "converter-bug" + } else if fix_attempts >= u64::from(max_fix_attempts) { + "unresolved" + } else { + "open" + }); + } + // Anything this run measured again and did not see fail is fixed. A + // comparison is fixed only by a passing result for it. + let passed = visual + .iter() + .filter(|result| result.verdict == "pass") + .map(|result| result.id.as_str()) + .collect::>(); + for (id, record) in records.iter_mut() { + if failing.contains(id) || record["status"] == "resolved" { + continue; + } + let measured_again = if record["kind"] == "visual" { + passed.contains(id.as_str()) + } else { + record["routePath"] + .as_str() + .zip(record["width"].as_u64()) + .is_some_and(|(route, width)| audited.contains(&(route.to_owned(), width))) + }; + if measured_again { + record["status"] = json!("resolved"); + } + } + json!({"pendingComparisons": pending, "inconclusiveComparisons": inconclusive}) +} + +/// The open and unresolved findings in work order, most basic first. +pub(super) fn findings_by_status(plan: &Value, status: &str) -> Vec { + let mut list = plan["audit"]["findings"] + .as_object() + .into_iter() + .flatten() + .filter(|(_, record)| record["status"] == status) + .map(|(id, record)| { + let mut record = record.clone(); + record["id"] = json!(id); + record + }) + .collect::>(); + list.sort_by_key(|record| { + ( + record["routePath"].as_str().unwrap_or_default().to_owned(), + class_rank(record["class"].as_str().unwrap_or_default()), + std::cmp::Reverse(record["width"].as_u64().unwrap_or(0)), + ) + }); + list +} + +/// Per URL: what to do next, blocked items last. Nothing here waits on +/// the user; a blocked item waits on a pending decision taken at the end. +pub(super) fn work_queue( + plan: &Value, + rows: &[Value], + pending_comparisons: &[Value], +) -> Vec { + let open = findings_by_status(plan, "open"); + let decisions = plan["pendingDecisions"] + .as_array() + .cloned() + .unwrap_or_default(); + let audited_urls = plan["audit"]["plans"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| entry["url"].as_str()) + .collect::>(); + rows.iter() + .map(|row| { + let url = row["url"].as_str().unwrap_or_default(); + let screen = audit_screen(plan, url); + let mut items = Vec::new(); + let mut blocked = Vec::new(); + for decision in &decisions { + if decision["status"] == "open" + && decision["about"].as_str().is_some_and(|about| about.contains(url)) + { + blocked.push(json!({"do": "blocked", "on": decision["id"], + "why": decision["question"], "meanwhile": "work the rest; it is taken with assume at the end"})); + } + } + if row["implemented"] != "yes" { + items.push(json!({"do": "build", "file": row["file"]})); + } + if let Some(reason) = plan["audit"]["inconclusive"][url].as_str() { + items.push(json!({"do": "audit inconclusive", "why": reason, + "how": "install the runner when it can be; the rest of the work goes on"})); + } else if !audited_urls.contains(url) { + items.push(json!({"do": "audit", "how": "devup_screen_plan action auditPlan, run the plan, then status"})); + } else if row["widths"] + .as_array() + .into_iter() + .flatten() + .any(|width| width["result"] == "not-run") + { + items.push(json!({"do": "run the audit", "why": "some widths have no audit result yet"})); + } + for finding in open.iter().filter(|finding| finding["routePath"] == url) { + items.push(json!({"do": "fix", "finding": finding["id"], "class": finding["class"], + "width": finding["width"], "codeFile": finding["codeFile"], "fixHint": finding["fixHint"], + "attempt": finding["fixAttempts"].as_u64().unwrap_or(0) + 1})); + } + let comparisons = pending_comparisons + .iter() + .filter_map(Value::as_str) + .filter(|id| screen.as_deref().is_some_and(|screen| id.starts_with(&format!("{screen}|")))) + .count(); + if comparisons > 0 { + items.push(json!({"do": "compare", "count": comparisons, + "how": "run each visualComparisons call and pass the results back as visualResults"})); + } + let done = items.is_empty(); + items.extend(blocked); + let reported = plan["audit"]["converterBugs"] + .as_object() + .into_iter() + .flatten() + .filter(|(_, bug)| bug["routePath"] == url) + .map(|(_, bug)| json!({"converterBug": bug["nodeId"], "repro": bug["repro"], + "note": "reported, not retried; a local override needs a comment naming the repro"})) + .collect::>(); + json!({"url": url, "items": items, "reported": reported, "done": done}) + }) + .collect() +} + +fn audit_screen(plan: &Value, url: &str) -> Option { + plan["audit"]["plans"] + .as_array()? + .iter() + .find(|entry| entry["url"] == url)?["screen"] + .as_str() + .map(str::to_owned) +} + +/// The report the job ends with: every URL at every width by health, +/// geometry, the three visual levels and interaction, with screenshots and +/// diffs, then the unresolved findings with their evidence and the pending +/// decisions taken. The plan's own lists (assumptions, design fixes, +/// exclusions, defaults) and the dependency line follow. +pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> String { + let cell = |value: &Value| match value { + Value::Null => "-".to_owned(), + Value::String(text) => text.replace('|', "\\|"), + other => other.to_string(), + }; + let mut out = String::from( + "| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|\n", + ); + let records = plan["audit"]["findings"].as_object(); + for row in rows { + let url = row["url"].as_str().unwrap_or_default(); + for width in row["widths"].as_array().into_iter().flatten() { + let columns = &width["columns"]; + let diffs = records + .into_iter() + .flatten() + .filter(|(_, record)| { + record["routePath"] == url + && record["width"] == width["width"] + && record["status"] != "resolved" + }) + .filter_map(|(_, record)| record["evidence"]["diff"].as_str()) + .collect::>(); + let count = |class: &str| { + records + .into_iter() + .flatten() + .filter(|(_, record)| { + record["routePath"] == url + && record["width"] == width["width"] + && record["class"] == class + && record["status"] != "resolved" + }) + .count() + }; + out.push_str(&format!( + "| {url} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", + width["width"], + cell(&columns["health"]), + cell(&columns["geometry"]), + count("code-drift"), + count("converter-bug"), + cell(&columns["page"]), + cell(&columns["section"]), + cell(&columns["asset"]), + cell(&columns["interaction"]), + width["screenshots"] + .as_array() + .and_then(|shots| shots.first()) + .map_or("-".to_owned(), cell), + if diffs.is_empty() { + "-".to_owned() + } else { + diffs.join(", ") + }, + )); + } + } + let unresolved = findings_by_status(plan, "unresolved"); + out.push_str("\nUnresolved findings (after the fix-attempt budget):\n"); + if unresolved.is_empty() { + out.push_str("- none\n"); + } + for finding in &unresolved { + out.push_str(&format!( + "- {} at {}px, {} {}: {} (code: {}; screenshot: {}; diff: {})\n", + cell(&finding["routePath"]), + finding["width"], + cell(&finding["class"]), + cell(&finding["nodeId"]), + cell(&finding["reason"]), + cell(&finding["codeFile"]), + cell(&finding["evidence"]["screenshot"]), + cell(&finding["evidence"]["diff"]), + )); + } + out.push_str("\nConverter bugs (filed, not retried):\n"); + let bugs = plan["audit"]["converterBugs"] + .as_object() + .cloned() + .unwrap_or_default(); + if bugs.is_empty() { + out.push_str("- none\n"); + } + for bug in bugs.values() { + out.push_str(&format!( + "- {} at {}px, {}: {} (repro: {}; code: {})\n", + cell(&bug["routePath"]), + bug["width"], + cell(&bug["nodeId"]), + cell(&bug["reason"]), + cell(&bug["repro"]), + cell(&bug["codeFile"]), + )); + } + out.push_str("\nPending decisions:\n"); + let decisions = plan["pendingDecisions"] + .as_array() + .cloned() + .unwrap_or_default(); + if decisions.is_empty() { + out.push_str("- none\n"); + } + for decision in &decisions { + out.push_str(&format!( + "- {}: {} -> {} ({})\n", + cell(&decision["id"]), + cell(&decision["question"]), + cell(decision.get("taken").unwrap_or(&decision["recommended"])), + match decision["status"].as_str() { + Some("taken") => "taken at the end", + Some("left-unbuilt") => "destructive: left unbuilt", + _ => "still open", + } + )); + } + out.push_str(trailing); + let dependencies = &plan["dependencies"]; + out.push_str(&if dependencies.is_object() { + format!( + "\nDependencies: `{}` bumped {} packages; pins: {}.\n", + cell(&dependencies["command"]), + dependencies["bumped"], + dependencies["pins"] + .as_array() + .filter(|pins| !pins.is_empty()) + .map_or("none".to_owned(), |pins| pins + .iter() + .map(|pin| format!( + "{}@{} ({})", + cell(&pin["package"]), + cell(&pin["version"]), + cell(&pin["reason"]) + )) + .collect::>() + .join(", ")) + ) + } else { + "\nDependencies: the update step has not been recorded.\n".to_owned() + }); + out +} + +#[cfg(test)] +#[path = "audit_plan_tests.rs"] +mod tests; + +/// Geometry is exact, so a box that fails at a drawn width is classified by +/// cause: the code drifted from what the export wrote, or it did not and the +/// converter is wrong. A converter bug gets its repro written under +/// `.devup/converter-bugs/`. +pub(super) fn classify_geometry( + audit: &mut Value, + project_root: &str, + sources: &[String], + policy: &OutputPolicy, +) { + let Some(findings) = audit["findings"].as_array_mut() else { + return; + }; + for finding in findings { + if !matches!(finding["kind"].as_str(), Some("bounds" | "dimensions")) { + continue; + } + let Some(node) = finding["nodeId"].as_str().map(str::to_owned) else { + continue; + }; + match super::export_record::geometry_cause(Path::new(project_root), &node, sources) { + super::export_record::Cause::Drift { + file, + missing, + added, + } => { + finding["class"] = json!("code-drift"); + finding["codeFile"] = json!(file); + finding["evidence"]["drift"] = json!({"missing": missing, "added": added}); + finding["fixHint"] = json!(format!( + "{} Missing or changed: {}. Added layout props: {}.", + runtime_audit::fix_hint("code-drift", "bounds", &Value::Null), + if missing.is_empty() { + "none".to_owned() + } else { + missing.join(", ") + }, + if added.is_empty() { + "none".to_owned() + } else { + added.join(", ") + }, + )); + } + super::export_record::Cause::ConverterBug { + file, + record, + exported, + } => { + let width = finding["width"].clone(); + match super::export_record::write_repro( + policy, + project_root, + &node, + &width, + finding, + &file, + &record, + &exported, + ) { + Ok((path, _)) => { + finding["class"] = json!("converter-bug"); + finding["codeFile"] = json!(file); + finding["evidence"]["repro"] = json!(path); + finding["fixHint"] = json!(runtime_audit::fix_hint( + "converter-bug", + "bounds", + &Value::Null + )); + } + Err(error) => { + finding["evidence"]["reproError"] = json!(error.message); + } + } + } + super::export_record::Cause::Unknown(why) => { + finding["evidence"]["cause"] = json!(why); + } + } + } +} diff --git a/crates/devup-mcp/src/server/audit_plan_tests.rs b/crates/devup-mcp/src/server/audit_plan_tests.rs new file mode 100644 index 00000000..496fbc60 --- /dev/null +++ b/crates/devup-mcp/src/server/audit_plan_tests.rs @@ -0,0 +1,820 @@ +use super::*; + +fn payload(target: &str, nodes: Value, assets: Value, reference: bool) -> CollectedPayload { + let mut value = json!({ + "target": {"fileKey": "audit", "nodeId": target}, + "scope": "node", "metadata": {}, + "snapshot": {"fileKey": "audit", "version": "1", "roots": [target], "nodes": nodes, "diagnostics": []}, + "variables": null, "styles": null, "completeness": "resolved-values-only", "sourceVersion": "1", + "assets": assets, + }); + if reference { + value["referencePng"] = + json!({"mimeType": "image/png", "dataBase64": "", "byteLength": 0, "sha256": "0"}); + } + serde_json::from_value(value).unwrap() +} + +fn frame(id: &str, name: &str, at: [f64; 4], children: &[&str], extra: Value) -> (String, Value) { + let mut fields = json!({"name": name, "childrenIds": children, + "absoluteBoundingBox": {"x": at[0], "y": at[1], "width": at[2], "height": at[3]}}); + for (key, value) in extra.as_object().into_iter().flatten() { + fields[key] = value.clone(); + } + ( + id.to_owned(), + json!({"id": id, "type": "FRAME", "fields": fields}), + ) +} + +fn native(node: &str, asset: &str) -> Value { + json!({"assetId": asset, "nodeId": node, "field": "node", "sourceKind": "node", "status": "exported", + "format": "png", "scale": 1.0, "mimeType": "image/png", "renderMode": "node", + "renderInfo": {"representation": "native-node"}}) +} + +/// `/` drawn at 1440 (with its referencePng and the renders of the hero and +/// its icon) and at 390 (without a referencePng); `/about` at 1440. +fn sources() -> Sources { + let main = [ + frame( + "10:1", + "Main", + [0.0, 0.0, 1440.0, 1200.0], + &["10:11", "10:12", "10:13"], + json!({}), + ), + frame("10:11", "Header", [0.0, 0.0, 1440.0, 80.0], &[], json!({})), + frame( + "10:12", + "Hero", + [0.0, 80.0, 1440.0, 600.0], + &["10:121"], + json!({}), + ), + frame( + "10:121", + "Icon", + [100.0, 180.0, 24.0, 24.0], + &[], + json!({"isAsset": true}), + ), + frame( + "10:13", + "Cards", + [0.0, 680.0, 1440.0, 520.0], + &["10:131", "10:132"], + json!({}), + ), + frame( + "10:131", + "Card 1", + [120.0, 720.0, 400.0, 300.0], + &[], + json!({}), + ), + frame( + "10:132", + "Card 2", + [560.0, 720.0, 400.0, 300.0], + &[], + json!({}), + ), + ] + .into_iter() + .collect::>(); + let mobile = [ + frame( + "10:2", + "Main", + [0.0, 0.0, 390.0, 2400.0], + &["10:21"], + json!({}), + ), + frame("10:21", "Hero", [0.0, 0.0, 390.0, 800.0], &[], json!({})), + ] + .into_iter() + .collect::>(); + let about = [ + frame( + "10:3", + "About", + [0.0, 0.0, 1440.0, 900.0], + &["10:31"], + json!({}), + ), + frame("10:31", "Story", [0.0, 0.0, 1440.0, 900.0], &[], json!({})), + ] + .into_iter() + .collect::>(); + Sources::from_payloads(vec![ + ( + "art-main".into(), + payload( + "10:1", + Value::Object(main), + json!([ + native("10:12", "hero-render"), + native("10:121", "icon-render") + ]), + true, + ), + ), + ( + "art-mobile".into(), + payload("10:2", Value::Object(mobile), json!([]), false), + ), + ( + "art-about".into(), + payload("10:3", Value::Object(about), json!([]), true), + ), + ]) +} + +fn home() -> Target<'static> { + Target { + url: "/", + screen: "home".into(), + file: Some("src/app/page.tsx"), + frames: vec![ + (1440, "10:1".into(), Some(1200.0)), + (390, "10:2".into(), Some(2400.0)), + ], + next_url: Some("/about"), + hidden: Vec::new(), + } +} + +fn kinds(plan: &PlanInput) -> BTreeSet<&str> { + plan.screens[0] + .checks + .iter() + .map(|check| check.kind.as_str()) + .collect() +} + +#[test] +fn a_two_url_audit_plan_checks_the_page_its_sections_assets_geometry_overflow_and_transitions() { + let sources = sources(); + let thresholds = VisualThresholds::default(); + let home = url_plan(&home(), "http://localhost:3000", &sources, &thresholds).unwrap(); + let about = url_plan( + &Target { + url: "/about", + screen: "about".into(), + file: Some("src/app/about/page.tsx"), + frames: vec![(1440, "10:3".into(), Some(900.0))], + next_url: Some("/"), + hidden: Vec::new(), + }, + "http://localhost:3000", + &sources, + &thresholds, + ) + .unwrap(); + for plan in [&home.plan, &about.plan] { + for kind in [ + "page-health", + "no-horizontal-overflow", + "no-clipped-text", + "bounds", + "interaction", + "view-transition", + ] { + assert!(kinds(plan).contains(kind), "{kind} in {:?}", kinds(plan)); + } + } + let checks = &home.plan.screens[0].checks; + let captures = checks + .iter() + .filter_map(|check| { + check + .reference + .as_ref() + .map(|reference| (check.node_id.clone().unwrap(), reference)) + }) + .collect::>(); + assert_eq!(captures.len(), 2, "the hero and its icon have renders"); + assert_eq!(captures[0].0, "10:12"); + assert_eq!(captures[0].1.scope, "section"); + assert_eq!(captures[0].1.threshold, Some(SECTION_THRESHOLD)); + assert_eq!(captures[1].0, "10:121"); + assert_eq!(captures[1].1.scope, "asset"); + assert_eq!(captures[1].1.asset_id, "icon-render"); + + // Geometry: the second card sits 440px in and 40px down its section, at + // the drawn width only, so a card out of order fails. + let card = checks + .iter() + .find(|check| check.kind == "bounds" && check.node_id.as_deref() == Some("10:132")) + .unwrap(); + let expected = card.expected.as_ref().unwrap(); + assert_eq!( + (expected.x, expected.y, expected.width), + (560.0, 40.0, 400.0) + ); + assert_eq!(card.widths, [1440]); + assert!( + card.container + .as_ref() + .unwrap() + .css + .as_deref() + .unwrap() + .starts_with("[data-figma-node-id=\"10:13\"]") + ); + let icon = checks + .iter() + .find(|check| check.kind == "bounds" && check.node_id.as_deref() == Some("10:121")) + .unwrap(); + assert_eq!(icon.tolerance_px, None, "geometry is exact"); + assert!( + checks.iter().any(|check| check.kind == "contained" + && check.label.as_deref() == Some("navigation not cut off")), + "the Header section asks for the nav containment check" + ); + + // Every width, the drawn ones as tall as their frames. + let viewports = home + .plan + .viewports + .iter() + .map(|viewport| (viewport.width, viewport.height)) + .collect::>(); + assert_eq!( + viewports, + [(1440, 1200), (1100, 900), (858, 900), (390, 2400)] + ); + let figma = home.plan.screens[0].figma.as_ref().unwrap(); + assert_eq!(figma.drawn_widths, [1440, 390]); + assert_eq!(figma.frames[0].artifact_id.as_deref(), Some("art-main")); + assert_eq!(figma.frames[1].artifact_id, None); +} + +#[test] +fn the_view_transition_check_is_last_and_comes_back_to_its_screen() { + let plan = url_plan( + &home(), + "http://localhost:3000", + &sources(), + &VisualThresholds::default(), + ) + .unwrap() + .plan; + let checks = &plan.screens[0].checks; + let last = checks.last().unwrap(); + assert_eq!(last.kind, "view-transition"); + assert_eq!( + last.subject.as_ref().unwrap().css.as_deref(), + Some("a[href=\"/about\"] >> nth=0") + ); + assert_eq!( + checks + .iter() + .filter(|check| check.kind == "view-transition") + .count(), + 1 + ); + // Every screen starts from its own URL, so nothing after the transition + // depends on where it left the page. + assert!( + plan.screens + .iter() + .all(|screen| screen.route_path.is_some()) + ); + assert!(runtime_audit::validate_plan(&plan).is_ok()); +} + +#[test] +fn a_drawn_width_without_its_reference_png_is_reported() { + let built = url_plan( + &home(), + "http://localhost:3000", + &sources(), + &VisualThresholds::default(), + ) + .unwrap(); + let missing = built + .missing + .iter() + .filter(|item| item["kind"] == "referencePng") + .collect::>(); + assert_eq!(missing.len(), 1, "{:?}", built.missing); + assert_eq!(missing[0]["nodeId"], "10:2"); + assert_eq!(missing[0]["width"], 390); + assert_eq!( + missing[0]["call"]["arguments"]["outputs"], + json!(["referencePng"]) + ); + let renders = built + .missing + .iter() + .filter(|item| item["kind"] == "nativeRender") + .flat_map(|item| item["nodeIds"].as_array().unwrap().clone()) + .collect::>(); + assert_eq!(renders, [json!("10:11"), json!("10:13"), json!("10:21")]); + + let without_frame = url_plan( + &Target { + frames: vec![(1440, "99:1".into(), None)], + ..home() + }, + "http://localhost:3000", + &sources(), + &VisualThresholds::default(), + ) + .unwrap(); + assert_eq!(without_frame.missing[0]["kind"], "frame"); +} + +#[test] +fn per_node_thresholds_tune_one_asset() { + let thresholds = VisualThresholds { + asset: Some(0.08), + nodes: BTreeMap::from([("10:121".to_owned(), 0.12)]), + ..VisualThresholds::default() + }; + let plan = url_plan(&home(), "http://localhost:3000", &sources(), &thresholds) + .unwrap() + .plan; + let icon = plan.screens[0] + .checks + .iter() + .find(|check| check.kind == "capture" && check.node_id.as_deref() == Some("10:121")) + .unwrap(); + assert_eq!(icon.reference.as_ref().unwrap().threshold, Some(0.12)); +} + +fn audit_with(findings: Value, comparisons: Value) -> Value { + json!({ + "screenTable": [{"screen": "home", "routePath": "/", "width": 1440, "status": "fail", "reasons": []}], + "findings": findings, + "visualComparisons": comparisons, + }) +} + +fn position_finding() -> Value { + json!({"id": "home|bounds|10:132|1440", "class": "layout-position", "screen": "home", "routePath": "/", + "width": 1440, "kind": "bounds", "nodeId": "10:132", "codeFile": "src/app/page.tsx", + "reason": "position: x off by 440px", "fixHint": "order", "evidence": {}}) +} + +#[test] +fn a_finding_is_unresolved_after_the_fix_attempt_budget_with_its_evidence() { + let mut plan = json!({"status": "confirmed"}); + let audit = audit_with(json!([position_finding()]), json!([])); + let files = BTreeMap::from([( + "10:132".to_owned(), + ("/".to_owned(), "src/components/Cards.tsx".to_owned()), + )]); + record_round(&mut plan, &audit, "report-1", &[], &files, 3); + let record = &plan["audit"]["findings"]["home|bounds|10:132|1440"]; + assert_eq!(record["status"], "open"); + assert_eq!(record["fixAttempts"], 0); + assert_eq!( + record["codeFile"], "src/components/Cards.tsx", + "the node's own file" + ); + // The same report again is not another attempt. + record_round(&mut plan, &audit, "report-1", &[], &files, 3); + assert_eq!( + plan["audit"]["findings"]["home|bounds|10:132|1440"]["fixAttempts"], + 0 + ); + for (round, report) in ["report-2", "report-3"].iter().enumerate() { + record_round(&mut plan, &audit, report, &[], &files, 3); + let record = &plan["audit"]["findings"]["home|bounds|10:132|1440"]; + assert_eq!(record["fixAttempts"], round + 1); + assert_eq!(record["status"], "open"); + } + record_round(&mut plan, &audit, "report-4", &[], &files, 3); + let unresolved = findings_by_status(&plan, "unresolved"); + assert_eq!(unresolved.len(), 1); + assert_eq!(unresolved[0]["fixAttempts"], 3); + let rows = [ + json!({"url": "/", "screen": "home", "implemented": "yes", "widths": [{"width": 1440, "result": "fail"}]}), + ]; + let queue = work_queue(&plan, &rows, &[]); + assert!( + queue[0]["items"] + .as_array() + .unwrap() + .iter() + .all(|item| item["do"] != "fix"), + "an unresolved finding leaves the queue: {queue:?}" + ); + assert!( + final_report(&plan, &rows, "").contains("layout-position 10:132: position: x off by 440px") + ); + + // A later run that measures it and does not see it fail resolves it. + record_round( + &mut plan, + &audit_with(json!([]), json!([])), + "report-5", + &[], + &files, + 3, + ); + assert_eq!( + plan["audit"]["findings"]["home|bounds|10:132|1440"]["status"], + "resolved" + ); +} + +#[test] +fn a_failing_comparison_becomes_a_finding_and_a_passing_one_resolves_it() { + let mut plan = json!({"status": "confirmed"}); + let comparison = json!({"id": "home|asset|10:121|1440", "class": "asset-mismatch", "screen": "home", + "routePath": "/", "nodeId": "10:121", "codeFile": "src/app/page.tsx", "viewport": {"width": 1440}, + "screenshot": {"path": "report/c7.png", "sha256": "shot-1"}, + "call": {"arguments": {"threshold": 0.05, "reference": {"artifactId": "art-main", "assetId": "icon-render"}}}, + "ifItFails": {"fixHint": "mask on a multi-colour SVG"}, "blockers": []}); + let audit = audit_with(json!([]), json!([comparison])); + let pending = record_round(&mut plan, &audit, "r1", &[], &BTreeMap::new(), 3); + assert_eq!( + pending["pendingComparisons"], + json!(["home|asset|10:121|1440"]) + ); + let failed = VisualResultInput { + id: "home|asset|10:121|1440".into(), + verdict: "fail".into(), + changed_ratio: Some(0.4), + diff_path: Some("report/diff/icon.png".into()), + reason: None, + }; + record_round( + &mut plan, + &audit, + "r1", + std::slice::from_ref(&failed), + &BTreeMap::new(), + 3, + ); + let open = findings_by_status(&plan, "open"); + assert_eq!(open[0]["class"], "asset-mismatch"); + assert_eq!( + open[0]["reason"], + "40.0% of pixels differ from the Figma render (allowed 5.0%)" + ); + assert_eq!(open[0]["evidence"]["diff"], "report/diff/icon.png"); + let passed = VisualResultInput { + verdict: "pass".into(), + ..failed + }; + record_round(&mut plan, &audit, "r2", &[passed], &BTreeMap::new(), 3); + assert!(findings_by_status(&plan, "open").is_empty()); +} + +#[test] +fn blocked_work_goes_to_the_back_of_its_url() { + let plan = json!({ + "audit": {"plans": [{"url": "/", "screen": "home"}], "findings": { + "home|bounds|10:132|1440": {"status": "open", "class": "layout-position", "routePath": "/", "width": 1440, "fixAttempts": 0}}}, + "pendingDecisions": [{"id": "contact-target", "about": "/ contact form", "question": "Where does the form post?", "recommended": "mailto", "status": "open"}], + }); + let rows = [ + json!({"url": "/", "screen": "home", "implemented": "yes", "widths": [{"width": 1440, "result": "fail"}]}), + ]; + let queue = work_queue(&plan, &rows, &[json!("home|section|10:12|1440")]); + let items = queue[0]["items"].as_array().unwrap(); + let order = items + .iter() + .map(|item| item["do"].as_str().unwrap()) + .collect::>(); + assert_eq!(order, ["fix", "compare", "blocked"]); + assert_eq!(queue[0]["done"], false); +} + +#[test] +fn geometry_is_exact_and_merged_pages_are_found_per_width() { + let plan = url_plan( + &home(), + "http://localhost:3000", + &sources(), + &VisualThresholds::default(), + ) + .unwrap() + .plan; + let bounds = plan.screens[0] + .checks + .iter() + .filter(|check| check.kind == "bounds") + .collect::>(); + assert!( + bounds.iter().all(|check| check.tolerance_px.is_none()), + "no tolerance" + ); + let card = bounds + .iter() + .find(|check| check.node_id.as_deref() == Some("10:132")) + .unwrap(); + assert_eq!( + card.container.as_ref().unwrap().css.as_deref(), + Some("[data-figma-node-id=\"10:13\"], [data-figma-node-ids~=\"1440:10:13\"]") + ); + // The 390 frame's own sections are checked at 390, found through the + // merged page's per-width map. + let mobile = bounds + .iter() + .find(|check| check.node_id.as_deref() == Some("10:21")) + .unwrap(); + assert_eq!(mobile.widths, [390]); + assert!( + mobile + .subject + .as_ref() + .unwrap() + .css + .as_deref() + .unwrap() + .contains("390:10:21") + ); +} + +#[test] +fn a_merged_element_a_width_does_not_draw_is_checked_hidden_there() { + let records = vec![json!({"responsive": [ + {"ids": [{"width": 1440, "nodeId": "10:1"}, {"width": 390, "nodeId": "10:2"}], "hiddenAt": []}, + {"ids": [{"width": 1440, "nodeId": "10:13"}], "hiddenAt": [390]}, + ]})]; + let frames = home().frames; + let hidden = hidden_elements(&records, &frames); + assert_eq!(hidden, [(390, "10:13".to_owned())]); + let plan = url_plan( + &Target { hidden, ..home() }, + "http://localhost:3000", + &sources(), + &VisualThresholds::default(), + ) + .unwrap() + .plan; + let check = plan.screens[0] + .checks + .iter() + .find(|check| check.kind == "hidden") + .unwrap(); + assert_eq!(check.widths, [390]); + assert_eq!(check.node_id.as_deref(), Some("10:13")); +} + +struct Project(std::path::PathBuf); +impl Project { + fn new(name: &str) -> Self { + let root = std::env::temp_dir().join(format!("audit-plan-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + Self(root) + } + fn write(&self, path: &str, text: &str) { + let target = self.0.join(path); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(target, text).unwrap(); + } + fn root(&self) -> &str { + self.0.to_str().unwrap() + } +} +impl Drop for Project { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn blog_route() -> Value { + json!({"url": "/blog/[slug]", "file": "src/app/blog/[slug]/page.tsx", "app": ".", "nodeIds": ["20:1"], "drawnWidths": [1440]}) +} + +#[test] +fn a_dynamic_route_takes_its_literal_static_params() { + let project = Project::new("literal"); + project.write( + "src/app/blog/[slug]/page.tsx", + "export function generateStaticParams() {\n return [{ slug: 'hello' }, { slug: \"second-post\" }]\n}\nexport default function Page() { return null }\n", + ); + let found = dynamic_urls( + project.root(), + &blog_route(), + &Value::Null, + &Sources::from_payloads(Vec::new()), + ); + assert_eq!(found.source, "generateStaticParams"); + assert_eq!(found.urls, ["/blog/hello", "/blog/second-post"]); +} + +#[test] +fn a_dynamic_route_reads_the_local_data_its_static_params_map() { + let project = Project::new("data"); + project.write( + "src/app/blog/[slug]/page.tsx", + "import { posts } from '@/data/posts'\nexport async function generateStaticParams() {\n return posts.map((post) => ({ slug: post.slug }))\n}\nexport default function Page() { return null }\n", + ); + project.write( + "src/data/posts.ts", + "export const posts = [\n { slug: 'launch', title: 'Launch' },\n { slug: 'roadmap', title: 'Roadmap' },\n]\n", + ); + let found = dynamic_urls( + project.root(), + &blog_route(), + &Value::Null, + &Sources::from_payloads(Vec::new()), + ); + assert_eq!(found.urls, ["/blog/launch", "/blog/roadmap"]); +} + +#[test] +fn a_dynamic_route_without_params_takes_the_links_audited_pages_hold() { + let project = Project::new("links"); + let links = + json!({"/": ["/blog/from-home", "/about", "/blog/other"], "/about": ["/blog/from-home"]}); + let found = dynamic_urls( + project.root(), + &blog_route(), + &links, + &Sources::from_payloads(Vec::new()), + ); + assert_eq!(found.source, "links on audited pages"); + assert_eq!(found.urls, ["/blog/from-home", "/blog/other"]); + assert!(found.assumption.is_none()); +} + +#[test] +fn a_dynamic_route_falls_back_to_its_detail_frame_as_an_assumption() { + let project = Project::new("frame"); + let detail = [ + frame("20:1", "Blog detail", [0.0, 0.0, 1440.0, 900.0], &["20:2"], json!({})), + ("20:2".to_owned(), json!({"id": "20:2", "type": "TEXT", "fields": {"characters": "Our First Release!", "parentId": "20:1"}})), + ] + .into_iter() + .collect::>(); + let sources = Sources::from_payloads(vec![( + "art-blog".into(), + payload("20:1", Value::Object(detail), json!([]), true), + )]); + let found = dynamic_urls(project.root(), &blog_route(), &Value::Null, &sources); + assert_eq!(found.source, "Figma detail frame"); + assert_eq!(found.urls, ["/blog/our-first-release"]); + assert!(found.assumption.unwrap().contains("Our First Release!")); + + let nothing = dynamic_urls( + project.root(), + &blog_route(), + &Value::Null, + &Sources::from_payloads(Vec::new()), + ); + assert!(nothing.urls.is_empty()); + assert!(nothing.why.contains("generateStaticParams")); +} + +#[test] +fn a_catch_all_route_joins_its_segments() { + assert_eq!( + values_of("{ slug: ['docs', 'intro'] }", "slug"), + ["docs/intro"] + ); + assert_eq!(params("/docs/[...slug]"), [("slug".to_owned(), true)]); + assert_eq!( + fill( + "/docs/[...slug]", + &BTreeMap::from([("slug".to_owned(), "docs/intro".to_owned())]) + ) + .as_deref(), + Some("/docs/docs/intro") + ); +} + +const EXPORTED: &str = "export default function Page() {\n return (\n \n \n \n )\n}\n"; + +fn geometry_audit() -> Value { + json!({"findings": [{"id": "home|bounds|10:132|1440", "class": "layout-position", "kind": "bounds", + "nodeId": "10:132", "routePath": "/", "width": 1440, "reason": "position: x off by 1px", + "evidence": {"screenshot": "report/home.png", "measured": { + "expected": {"x": 560, "y": 40, "width": 400, "height": 300}, + "actual": {"x": 561, "y": 40, "width": 400, "height": 300}}}}]}) +} + +fn exported_project(name: &str) -> (Project, OutputPolicy) { + let project = Project::new(name); + let policy = OutputPolicy::from_roots(vec![project.0.clone()]).unwrap(); + let response = json!({"tsx": EXPORTED, "figmaNodeIds": {"root": "10:1"}, + "outputResults": {"tsx": {"_sourceMap": [ + {"nodeId": "10:132", "property": "width", "generatedProperty": "w=\"400px\"", "resolution": "exact"}]}}}); + let written = + super::super::export_record::write(&policy, project.root(), "FILEKEY", "10:1", &response) + .unwrap() + .unwrap(); + assert!(written.ends_with("10-1.json"), "{written}"); + (project, policy) +} + +#[test] +fn a_mismatch_with_the_code_still_equal_to_the_export_is_a_converter_bug_with_a_repro() { + let (project, policy) = exported_project("converter-bug"); + // The agent added behaviour, not layout: still the export's element. + project.write( + "src/app/page.tsx", + &EXPORTED.replace( + "", "w=\"401px\" ml=\"2px\" />"), + ); + let mut audit = geometry_audit(); + classify_geometry( + &mut audit, + project.root(), + &["src/app/page.tsx".to_owned()], + &policy, + ); + let finding = &audit["findings"][0]; + assert_eq!(finding["class"], "code-drift", "{finding}"); + assert_eq!( + finding["evidence"]["drift"]["missing"], + json!(["w=\"400px\""]) + ); + assert_eq!(finding["evidence"]["drift"]["added"], json!(["ml=\"2px\""])); + assert!( + finding["fixHint"] + .as_str() + .unwrap() + .contains("Restore the values the export wrote") + ); + assert!(!project.0.join(".devup/converter-bugs").exists()); +} + +#[test] +fn a_mismatch_without_an_export_record_keeps_its_class_and_says_why() { + let project = Project::new("no-record"); + let policy = OutputPolicy::from_roots(vec![project.0.clone()]).unwrap(); + let mut audit = geometry_audit(); + classify_geometry(&mut audit, project.root(), &[], &policy); + assert_eq!(audit["findings"][0]["class"], "layout-position"); + assert!( + audit["findings"][0]["evidence"]["cause"] + .as_str() + .unwrap() + .contains("No export record") + ); +} diff --git a/crates/devup-mcp/src/server/commands/opencode-devup.md b/crates/devup-mcp/src/server/commands/opencode-devup.md new file mode 100644 index 00000000..41247cf5 --- /dev/null +++ b/crates/devup-mcp/src/server/commands/opencode-devup.md @@ -0,0 +1,61 @@ +--- +description: Build pages or a site from Figma with devup-mcp (screen plan, questions up front, states, metadata, audit, final table) +--- + + +Run the devup workflow for: $ARGUMENTS + +The arguments are a Figma Section, frame or page link (optional while one +Devup Bridge plugin is attached) followed by notes for the plan. + +Follow the `devup` skill step by step - it is installed next to this command - +and the devup-implementation-plan skill for its rules: + +1. `devup_figma_auth` status. +2. `devup_skills` status, then install what is missing, and load the skills. +3. `devup_project_context` with scope theme and scope routes. +4. `devup_screen_plan` draft with projectRoot, the link, the notes and + extraQuestions for anything you would otherwise guess later. +5. Ask every question now with the `question` tool: the flat `questions` list + in one call. Never in prose, never during implementation. +6. `devup_screen_plan` confirm with every answer that applies. +7. Latest first, without asking: at the repository root run + `bunx @dependency-check-updates/cli -d -u --rm` - it updates every + manifest in the repository, every bun workspace and Cargo.toml included, + and --rm removes the lockfiles and installed directories so everything + resolves fresh - then `bun install`, build, lint and typecheck; fix code + to the new APIs, pin only when no fix exists, and record it with + `devup_screen_plan` assume (dependencyUpdate). Check current docs before + using any library API. +8. Implement URL by URL without asking anything: `devup_figma_export` with + projectRoot and frameIds, plus the plan's defaults - each element's role + recipe (hover, pressed, focus ring, current page, expanded, disabled, + loading; restrained), one shared transition, reduced motion, view + transitions by the plan's mechanism, metadata with openGraph, lang, alt + text, image sizes, a not-found page. Validate with `devup_ui_validate` + and projectRoot. Record anything new with `devup_screen_plan` assume. +9. `devup_screen_plan` auditPlan with baseUrl and every export artifactId: + one audit plan per URL - health and overflow at every width; page, + sections and assets against their Figma renders, and their size and + position exactly (0.5px rounding, no tolerance), at the drawn widths; + merged pages per width; dynamic routes at concrete URLs; hover, focus; a + view-transition check last. + Export what missingReferences names and run auditPlan again. +10. URL by URL: run the plan (a `devup_runtime_audit` plan, with the runner + auditPlan wrote), `devup_screen_plan` status with auditPlanPath + and auditReportPath, run every visualComparisons call and pass the + results as visualResults, fix each finding by its class and fix hint in + workQueue order, re-run. A geometry mismatch is code-drift (restore the + export) or converter-bug (repro filed, never retried). After + maxFixAttempts (default 3) a finding is unresolved with its evidence. No runner: toolingUnavailable, carry on. +11. Never stop to wait: a decision mid-run goes to `devup_screen_plan` defer + with the recommended option; take it with assume at the end (a + destructive one stays unbuilt). +12. Done = zero fails at the drawn widths and no health or overflow fail at + any width, or a filed converter bug, or listed as unresolved with + evidence. Also every URL implemented, no data-figma-node-id in the + production build (strip plugin in vite.config, grep the output), `bun run build`, `bun run lint`, `bunx oxlint + --deny-warnings`, `bun tsc --noEmit` in each changed app and `bun test` + pass (`bun run test` adds cargo tarpaulin, which does not work on + Windows), devup_ui_validate has no errors, site completeness filled. +13. Finish with the status finalReport and summary. diff --git a/crates/devup-mcp/src/server/compact.rs b/crates/devup-mcp/src/server/compact.rs index c69a45ad..f49070ac 100644 --- a/crates/devup-mcp/src/server/compact.rs +++ b/crates/devup-mcp/src/server/compact.rs @@ -192,6 +192,71 @@ pub fn outline(summary: &Value, options: &ResponseSelection) -> Value { "scanTruncated":summary["scanTruncated"],"missingCapturedNodes":summary["missingCapturedNodes"],"evidenceLimit":summary["evidenceLimit"]}) } +/// The values `path` reaches from `root`, each with the node or asset id it +/// belongs to when one is in reach. A field step over an array maps across +/// its items. +fn walk<'a>(root: &'a Value, path: &str) -> Result)>, DevupError> { + let mut values = vec![(root, None::)]; + for step in parse(path)? { + values = match step { + Step::Field(key) => values + .into_iter() + .flat_map(|(v, id)| { + if let Some(items) = v.as_array() { + items + .iter() + .filter_map(|v| { + v.get(&key).map(|field| { + ( + field, + v.get("nodeId") + .or_else(|| v.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| id.clone()), + ) + }) + }) + .collect::>() + } else { + v.get(&key).map(|field| (field, id)).into_iter().collect() + } + }) + .collect(), + Step::Index(index) => values + .into_iter() + .filter_map(|(v, id)| { + v.get(index).map(|item| { + ( + item, + item.get("nodeId") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .or(id), + ) + }) + }) + .collect(), + Step::Exact(key, value) => values + .into_iter() + .flat_map(|(v, _)| v.as_array().into_iter().flatten()) + .filter(|v| v.get(&key).and_then(Value::as_str) == Some(value.as_str())) + .map(|v| { + ( + v, + v.get("nodeId") + .or_else(|| v.get("id")) + .and_then(Value::as_str) + .map(str::to_owned), + ) + }) + .collect(), + }; + } + Ok(values) +} + pub fn select( result: &mut serde_json::Map, summary: &Value, @@ -205,64 +270,7 @@ pub fn select( root["nodes"] = summary["nodes"].clone(); let mut selections = Vec::new(); for path in options.response_fields.iter().chain(options.path.iter()) { - let mut values = vec![(&root, None::)]; - for step in parse(path)? { - values = match step { - Step::Field(key) => values - .into_iter() - .flat_map(|(v, id)| { - if let Some(items) = v.as_array() { - items - .iter() - .filter_map(|v| { - v.get(&key).map(|field| { - ( - field, - v.get("nodeId") - .or_else(|| v.get("id")) - .and_then(Value::as_str) - .map(str::to_owned) - .or_else(|| id.clone()), - ) - }) - }) - .collect::>() - } else { - v.get(&key).map(|field| (field, id)).into_iter().collect() - } - }) - .collect(), - Step::Index(index) => values - .into_iter() - .filter_map(|(v, id)| { - v.get(index).map(|item| { - ( - item, - item.get("nodeId") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .map(str::to_owned) - .or(id), - ) - }) - }) - .collect(), - Step::Exact(key, value) => values - .into_iter() - .flat_map(|(v, _)| v.as_array().into_iter().flatten()) - .filter(|v| v.get(&key).and_then(Value::as_str) == Some(value.as_str())) - .map(|v| { - ( - v, - v.get("nodeId") - .or_else(|| v.get("id")) - .and_then(Value::as_str) - .map(str::to_owned), - ) - }) - .collect(), - }; - } + let values = walk(&root, path)?; let count = values.len(); let end = count.min( options @@ -367,6 +375,46 @@ pub fn select( ))) } +/// A job poll's `responseFields`: the selected paths of the poll's own reply, +/// plus the job's progress, instead of the whole reply again. The same path +/// grammar and the same 64 KiB ceiling as an export's selection. +pub fn pick(reply: &Value, options: &ResponseSelection) -> Result { + options.validate()?; + let mut selections = Vec::new(); + for path in options.response_fields.iter().chain(options.path.iter()) { + let values = walk(reply, path)?; + let count = values.len(); + let end = count.min( + options + .response_offset + .saturating_add(options.response_limit), + ); + let values = values + .into_iter() + .skip(options.response_offset) + .take(options.response_limit) + .map(|(value, _)| value.clone()) + .collect::>(); + selections.push(json!({"path":path,"values":values,"matched":count, + "offset":options.response_offset,"nextOffset":(end < count).then_some(end), + "state":if count == 0 {"not-found"} else {"selected"},"truncated":end < count})); + } + let job = &reply["exportJob"]; + let picked = json!({"status":reply["status"],"selections":selections, + "exportJob":{"jobId":job["jobId"],"state":job["state"],"stage":job["stage"], + "assetProgress":job["assetProgress"],"nextAction":job["nextAction"], + "nextActionReason":job["nextActionReason"]}, + "selectionScope":"Selected from this poll's reply. Omit responseFields to read the whole reply."}); + if serde_json::to_vec(&picked).unwrap_or_default().len() > 65_536 { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "Selected data exceeds 65536 bytes. Select a narrower field or lower responseLimit; a scalar is never silently truncated.", + false, + )); + } + Ok(picked) +} + /// Standalone outlines keep the small data body inline and move unrelated /// full acquisition/projection evidence into the same bounded resource path. pub fn outline_response( diff --git a/crates/devup-mcp/src/server/export_record.rs b/crates/devup-mcp/src/server/export_record.rs new file mode 100644 index 00000000..9d0fae1e --- /dev/null +++ b/crates/devup-mcp/src/server/export_record.rs @@ -0,0 +1,406 @@ +//! What an export into a project wrote for each Figma node, kept so a +//! geometry mismatch can be told apart: code that no longer matches what was +//! exported (drift, fixed by restoring the export) from code that still +//! matches it and renders wrong anyway (a converter bug, reported with a +//! repro, never hand-tuned in silence). +//! +//! `.devup/exports/.json` holds, for every element the export +//! marked with `data-figma-node-id(s)`, its tag, its attributes as written +//! and the source map entries of its node. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use devup_mcp_devup_ui::provenance::attributes::generated_attributes; +use devup_mcp_figma::DevupError; +use serde_json::{Value, json}; + +use super::output::{OutputPolicy, OutputTransaction}; + +pub(super) const EXPORTS_DIR: &str = ".devup/exports"; +pub(super) const CONVERTER_BUGS_DIR: &str = ".devup/converter-bugs"; +const RECORD_KIND: &str = "devup-export-record"; +const MAX_RECORD_BYTES: u64 = 4 * 1024 * 1024; + +/// Props that decide an element's box. Adding one the export did not write +/// is hand-tuning layout, the drift the audit refuses. +const LAYOUT_PROPS: &[&str] = &[ + "w", + "h", + "width", + "height", + "minW", + "maxW", + "minH", + "maxH", + "minWidth", + "maxWidth", + "minHeight", + "maxHeight", + "boxSize", + "p", + "px", + "py", + "pt", + "pr", + "pb", + "pl", + "padding", + "paddingX", + "paddingY", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "m", + "mx", + "my", + "mt", + "mr", + "mb", + "ml", + "margin", + "marginTop", + "marginRight", + "marginBottom", + "marginLeft", + "gap", + "rowGap", + "columnGap", + "flex", + "flexDir", + "flexDirection", + "flexWrap", + "flexGrow", + "flexShrink", + "flexBasis", + "justifyContent", + "justifyItems", + "alignItems", + "alignSelf", + "alignContent", + "display", + "pos", + "position", + "top", + "right", + "bottom", + "left", + "inset", + "aspectRatio", + "gridTemplateColumns", + "gridTemplateRows", + "gridColumn", + "gridRow", + "order", + "fontSize", + "lineHeight", + "letterSpacing", + "fontWeight", + "fontFamily", + "typography", + "overflow", + "overflowX", + "overflowY", + "objectFit", + "transform", +]; + +fn file_name(node_id: &str) -> String { + node_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect() +} + +fn normalized(source: &str) -> String { + source.split_whitespace().collect::>().join(" ") +} + +fn is_identity(name: &str) -> bool { + name == "data-figma-node-id" || name == "data-figma-node-ids" +} + +/// Each element of `tsx` that names a Figma node: node id -> its tag and +/// attributes (without the identity attributes). An element of a merged +/// page is listed under the node of every width that draws it. +pub(super) fn elements(tsx: &str) -> BTreeMap { + let mut by_element = BTreeMap::)>::new(); + for attribute in generated_attributes(tsx) { + by_element + .entry(attribute.element) + .or_insert_with(|| (attribute.tag.clone(), Vec::new())) + .1 + .push((attribute.name, attribute.source)); + } + let value_of = |source: &str| { + source + .split_once('=') + .map(|(_, value)| value.trim_matches(|c| c == '"' || c == '\'').to_owned()) + }; + let mut out = BTreeMap::new(); + for (tag, attributes) in by_element.values() { + let mut ids = Vec::new(); + for (name, source) in attributes { + match name.as_str() { + "data-figma-node-id" => ids.extend(value_of(source)), + "data-figma-node-ids" => ids.extend(value_of(source).into_iter().flat_map(|map| { + map.split_whitespace() + .filter_map(|pair| pair.split_once(':').map(|(_, id)| id.to_owned())) + .collect::>() + })), + _ => {} + } + } + let written = attributes + .iter() + .filter(|(name, _)| !is_identity(name)) + .map(|(_, source)| normalized(source)) + .collect::>(); + for id in ids { + out.entry(id) + .or_insert_with(|| json!({"tag": tag, "attributes": written})); + } + } + out +} + +/// Writes the record of one export into a project. Returns where. +pub(super) fn write( + policy: &OutputPolicy, + project_root: &str, + file_key: &str, + root_node_id: &str, + response: &Value, +) -> Result, DevupError> { + let mut nodes = BTreeMap::new(); + let mut output = None; + for field in ["responsiveTsx", "tsx"] { + if let Some(tsx) = response[field].as_str() { + let found = elements(tsx); + if !found.is_empty() && output.is_none() { + output = Some(field); + } + for (id, element) in found { + nodes.entry(id).or_insert(element); + } + } + } + if nodes.is_empty() { + return Ok(None); + } + let mut source_map = BTreeMap::>::new(); + for entry in response["outputResults"]["tsx"]["_sourceMap"] + .as_array() + .into_iter() + .flatten() + { + if let Some(node) = entry["nodeId"].as_str() + && nodes.contains_key(node) + { + source_map + .entry(node.to_owned()) + .or_default() + .push(entry.clone()); + } + } + for (id, entries) in source_map { + if let Some(node) = nodes.get_mut(&id) { + node["sourceMap"] = json!(entries); + } + } + let record = json!({ + "kind": RECORD_KIND, + "schemaVersion": 1, + "fileKey": file_key, + "rootNodeId": root_node_id, + "output": output, + "nodes": nodes, + "responsive": response["figmaNodeIds"]["responsive"], + }); + let path = Path::new(project_root) + .join(EXPORTS_DIR) + .join(format!("{}.json", file_name(root_node_id))); + let target = policy.resolve(&path.to_string_lossy())?; + let display = target.display_path().display().to_string(); + let bytes = serde_json::to_vec_pretty(&record).map_err(|error| { + DevupError::new( + devup_mcp_figma::ErrorCode::DevupInvalidInput, + error.to_string(), + false, + ) + })?; + let mut transaction = OutputTransaction::new(); + transaction.stage("exportRecord", target, &bytes)?; + transaction.commit()?; + Ok(Some(display)) +} + +/// Every export record in the project. +pub(super) fn records(project_root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(project_root.join(EXPORTS_DIR)) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) + .filter(|entry| { + entry + .metadata() + .is_ok_and(|meta| meta.len() <= MAX_RECORD_BYTES) + }) + .filter_map(|entry| std::fs::read(entry.path()).ok()) + .filter_map(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|record| record["kind"] == RECORD_KIND) + .collect() +} + +/// Why an element's box differs from its Figma frame. +pub(super) enum Cause { + /// Its code no longer writes what the export wrote. + Drift { + file: String, + missing: Vec, + added: Vec, + }, + /// Its code is the export's, so the converter produced the wrong box. + ConverterBug { + file: String, + record: Value, + exported: Value, + }, + /// No export record or no code element to compare. + Unknown(String), +} + +/// Compares the element marked with `node_id` in the project's sources with +/// what the export wrote for it. An exported attribute missing or changed, +/// or a layout prop the export did not write, is drift; extra behaviour +/// (handlers, hover styles, links) is not. +pub(super) fn geometry_cause(project_root: &Path, node_id: &str, sources: &[String]) -> Cause { + let records = records(project_root); + let Some((record, exported)) = records.iter().find_map(|record| { + record["nodes"] + .get(node_id) + .map(|exported| (record.clone(), exported.clone())) + }) else { + return Cause::Unknown(format!( + "No export record names {node_id}; export the frame with projectRoot so a mismatch can be told apart as drift or a converter bug." + )); + }; + let mut code = None; + for source in sources { + let path: PathBuf = project_root.join(source); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if !text.contains(node_id) { + continue; + } + if let Some(element) = elements(&text).remove(node_id) { + code = Some((source.clone(), element)); + break; + } + } + let Some((file, element)) = code else { + return Cause::Unknown(format!( + "No source file marks an element with data-figma-node-id {node_id}; the exported element was removed or renamed." + )); + }; + let attributes = |value: &Value| { + value["attributes"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>() + }; + let wrote = attributes(&exported); + let has = attributes(&element); + let name = |source: &str| source.split('=').next().unwrap_or(source).trim().to_owned(); + let missing = wrote + .iter() + .filter(|attribute| !has.contains(attribute)) + .cloned() + .collect::>(); + let wrote_names = wrote + .iter() + .map(|attribute| name(attribute)) + .collect::>(); + let added = has + .iter() + .filter(|attribute| { + let name = name(attribute); + LAYOUT_PROPS.contains(&name.as_str()) && !wrote_names.contains(&name) + }) + .cloned() + .collect::>(); + if missing.is_empty() && added.is_empty() { + Cause::ConverterBug { + file, + record, + exported, + } + } else { + Cause::Drift { + file, + missing, + added, + } + } +} + +/// The repro a converter bug is filed with, written to +/// `.devup/converter-bugs/.json`. +#[allow(clippy::too_many_arguments)] +pub(super) fn write_repro( + policy: &OutputPolicy, + project_root: &str, + node_id: &str, + width: &Value, + finding: &Value, + file: &str, + record: &Value, + exported: &Value, +) -> Result<(String, Value), DevupError> { + let repro = json!({ + "kind": "devup-converter-bug", + "fileKey": record["fileKey"], + "frameNodeId": record["rootNodeId"], + "nodeId": node_id, + "viewportWidth": width, + "figmaBox": finding["evidence"]["measured"]["expected"], + "domBox": finding["evidence"]["measured"]["actual"], + "mismatch": finding["reason"], + "generatedTag": exported["tag"], + "generatedProps": exported["attributes"], + "sourceMap": exported["sourceMap"], + "codeFile": file, + "page": finding["routePath"], + "screenshot": finding["evidence"]["screenshot"], + "why": "The element's code still equals the export output, yet the browser box differs from the Figma box: the converter produced the wrong layout. Retrying does not change a deterministic result.", + "file": "Report it to devup-mcp with this file. A local override may finish the site, but it must carry a comment naming this file.", + }); + let path = Path::new(project_root) + .join(CONVERTER_BUGS_DIR) + .join(format!("{}.json", file_name(node_id))); + let target = policy.resolve(&path.to_string_lossy())?; + let display = target.display_path().display().to_string(); + let bytes = serde_json::to_vec_pretty(&repro).map_err(|error| { + DevupError::new( + devup_mcp_figma::ErrorCode::DevupInvalidInput, + error.to_string(), + false, + ) + })?; + let mut transaction = OutputTransaction::new(); + transaction.stage("converterBug", target, &bytes)?; + transaction.commit()?; + Ok((display, repro)) +} diff --git a/crates/devup-mcp/src/server/framework.rs b/crates/devup-mcp/src/server/framework.rs new file mode 100644 index 00000000..3bdac353 --- /dev/null +++ b/crates/devup-mcp/src/server/framework.rs @@ -0,0 +1,261 @@ +//! Which framework runs each app in a project: Next App Router on the `next` +//! CLI, or on vinext. +//! +//! vinext runs Next.js App Router on Vite, so an app built on it has a +//! `vite.config.ts`, no `next.config.*` and often no `next` dependency at all, +//! while its `layout.tsx` still imports `from 'next'`. Asking for a `next` +//! dependency missed every such app, which is DevFive's default +//! (`dev-five-git/service-template`). Each signal is reported as evidence, so +//! a caller can see why an app was or was not recognised. +//! +//! bun workspaces (`"workspaces": ["apps/*"]`) are followed, so a project +//! root may be the repository or one app inside it. + +use std::path::Path; + +use serde_json::{Value, json}; + +/// One app: where it is, what runs it, and why that was decided. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct App { + /// Relative to the project root; `.` for the root itself. + pub dir: String, + /// `vinext`, `next`, or `unknown` when only an `app/` directory says it + /// is an App Router app. + pub framework: &'static str, + pub evidence: Vec, + /// `src/app` or `app` under `dir`, when either exists. + pub app_router: Option, + /// The `name` in its package.json. + pub name: Option, +} + +impl App { + pub fn json(&self) -> Value { + json!({"dir": self.dir, "name": self.name, "framework": self.framework, + "evidence": self.evidence, "appRouter": self.app_router}) + } + + /// `dir` joined with a relative path, without a leading `./`. + pub fn path(&self, relative: &str) -> String { + if self.dir == "." { + relative.to_owned() + } else { + format!("{}/{relative}", self.dir) + } + } +} + +fn read_json(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +/// Workspace directories a `package.json` declares: `["apps/*"]`, or +/// `{"packages": [...]}`. A trailing `/*` lists that directory; anything else +/// is taken as one path. +fn workspace_dirs(root: &Path, package: &Value) -> Vec { + let patterns = package["workspaces"] + .as_array() + .or_else(|| package["workspaces"]["packages"].as_array()) + .cloned() + .unwrap_or_default(); + let mut dirs = Vec::new(); + for pattern in patterns.iter().filter_map(Value::as_str) { + let pattern = pattern.trim_start_matches("./").trim_end_matches('/'); + if let Some(parent) = pattern.strip_suffix("/*") { + let Ok(entries) = std::fs::read_dir(root.join(parent)) else { + continue; + }; + let mut found = entries + .flatten() + .filter(|entry| entry.path().join("package.json").is_file()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .map(|name| format!("{parent}/{name}")) + .collect::>(); + found.sort(); + dirs.extend(found); + } else if !pattern.contains('*') && root.join(pattern).join("package.json").is_file() { + dirs.push(pattern.to_owned()); + } + } + dirs +} + +/// What runs the app in `dir`, from its package.json and vite config. +fn classify(root: &Path, dir: &str) -> App { + let base = if dir == "." { + root.to_path_buf() + } else { + root.join(dir) + }; + let package = read_json(&base.join("package.json")).unwrap_or(Value::Null); + let mut vinext = Vec::new(); + let mut next = Vec::new(); + for section in ["dependencies", "devDependencies"] { + if let Some(version) = package[section]["vinext"].as_str() { + vinext.push(format!("vinext {version} in {dir}/package.json {section}")); + } + if let Some(version) = package[section]["next"].as_str() { + next.push(format!("next {version} in {dir}/package.json {section}")); + } + } + if let Some(scripts) = package["scripts"].as_object() { + let names = scripts + .iter() + .filter(|(_, command)| { + command + .as_str() + .is_some_and(|command| command.split_whitespace().any(|word| word == "vinext")) + }) + .map(|(name, _)| name.as_str()) + .collect::>(); + if !names.is_empty() { + vinext.push(format!( + "vinext in {dir}/package.json scripts ({})", + names.join(", ") + )); + } + } + for file in [ + "vite.config.ts", + "vite.config.mts", + "vite.config.js", + "vite.config.mjs", + ] { + if let Ok(source) = std::fs::read_to_string(base.join(file)) + && (source.contains("from 'vinext'") || source.contains("from \"vinext\"")) + { + vinext.push(format!("{dir}/{file} imports vinext")); + } + } + let app_router = ["src/app", "app"] + .into_iter() + .find(|candidate| base.join(candidate).is_dir()) + .map(str::to_owned); + let (framework, evidence) = if !vinext.is_empty() { + ("vinext", [vinext, next].concat()) + } else if !next.is_empty() { + ("next", next) + } else { + ( + "unknown", + app_router + .iter() + .map(|found| format!("{dir}/{found} exists, but nothing names a framework")) + .collect(), + ) + }; + App { + dir: dir.to_owned(), + framework, + evidence, + app_router, + name: package["name"].as_str().map(str::to_owned), + } +} + +/// Every app in the project that a framework or an App Router directory +/// marks: the root itself and each bun workspace. +pub(super) fn detect(root: &Path) -> Vec { + let package = read_json(&root.join("package.json")).unwrap_or(Value::Null); + std::iter::once(".".to_owned()) + .chain(workspace_dirs(root, &package)) + .map(|dir| classify(root, &dir)) + .filter(|app| app.framework != "unknown" || app.app_router.is_some()) + .collect() +} + +/// The `frameworks` block `devup_project_context` adds to a ui or routes +/// inventory. +pub(super) fn report(root: &Path) -> Value { + let apps = detect(root); + let kinds = apps + .iter() + .map(|app| app.framework) + .collect::>(); + json!({ + "framework": if kinds.len() == 1 { json!(kinds.iter().next()) } else { Value::Null }, + "apps": apps.iter().map(App::json).collect::>(), + "note": "vinext runs Next.js App Router on Vite: an app with a vinext dependency, vinext scripts or a vite.config importing vinext is an App Router app even with no next dependency and no next.config.", + }) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + + pub(crate) fn service_template(root: &Path) { + let write = |path: &str, text: &str| { + let target = root.join(path); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(target, text).unwrap(); + }; + write( + "package.json", + r#"{"workspaces": ["apps/*"], "scripts": {"build": "bun run -F ./apps/front build"}}"#, + ); + write( + "apps/front/package.json", + r#"{"name": "front", "scripts": {"dev": "vinext dev --port 3000", "build": "vinext build"}, + "dependencies": {"react": "^19", "react-server-dom-webpack": "19.3.0", "vinext": "1.0.0"}}"#, + ); + write( + "apps/front/vite.config.ts", + "import vinext from 'vinext'\nexport default { plugins: [vinext({ react: { compiler: true } })] }\n", + ); + write( + "apps/front/src/app/layout.tsx", + "import type { Metadata } from 'next'\nexport const metadata: Metadata = { title: 'Devfive' }\nexport default function RootLayout({ children }) { return {children} }\n", + ); + write( + "apps/admin/package.json", + r#"{"name": "admin", "dependencies": {"next": "16.1.0"}}"#, + ); + write( + "apps/admin/src/app/page.tsx", + "export default function AdminPage() { return null }\n", + ); + } + + #[test] + fn vinext_is_an_app_router_app_without_a_next_dependency() { + let root = std::env::temp_dir().join(format!("devup-framework-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + service_template(&root); + let apps = detect(&root); + let summary = apps + .iter() + .map(|app| (app.dir.as_str(), app.framework, app.app_router.as_deref())) + .collect::>(); + assert_eq!( + summary, + [ + ("apps/admin", "next", Some("src/app")), + ("apps/front", "vinext", Some("src/app")) + ] + ); + let front = &apps[1]; + assert!( + front + .evidence + .iter() + .any(|why| why.contains("vinext 1.0.0")) + ); + assert!( + front + .evidence + .iter() + .any(|why| why.contains("scripts (dev, build)")) + ); + assert!( + front + .evidence + .iter() + .any(|why| why.contains("vite.config.ts imports vinext")) + ); + assert_eq!(report(&root)["framework"], Value::Null); + assert_eq!(front.path("src/app"), "apps/front/src/app"); + std::fs::remove_dir_all(root).ok(); + } +} diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index e181a6d1..c841cf57 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -27,8 +27,8 @@ pub const GUIDE_MIME_TYPE: &str = "text/markdown"; /// The original rule numbering is preserved in [`GUIDE`] - it skipped 9 - so a /// reader comparing the two can see nothing was dropped in the move. pub const INSTRUCTIONS: &str = concat!( - "Build identity: identify deployments by server.commit/buildId, not version alone; ", - "server.displayVersion combines version and buildId. Reconnect the MCP server if the expected build differs. ", + "Build identity: identify deployments by server.commit/buildId, not version alone. ", + "Reconnect the MCP server if the expected build differs. ", "server.updateAvailable reports whether a newer release exists; state \"unknown\" means the check is disabled, ", "not yet run, or could not reach the network, and it never blocks a call.\n", "1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n", @@ -39,6 +39,8 @@ pub const INSTRUCTIONS: &str = concat!( "run devup_ui_validate and report wired/tested counts before functional completion.\n", "2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ", "workspace lacks and installs the ones it carries.\n", + "2c. Building pages or a site from Figma (a Section, several frames): follow skill ", + "devup-implementation-plan even without /devup - devup_screen_plan first, all questions up front, then never wait; audit every width (rule 16).\n", "3. Request only outputs you will read. Before your second call, resources/read \"devup://guide/usage\" ", "for output sizing, verification boundaries, SECTION batching, assets and delivery." ); @@ -153,6 +155,12 @@ A `maskImage` or `Image` src is 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. +`maskImage` + `bg` is emitted only for an icon painted in one flat colour; a +multi-colour icon (gradient, several colours, an image) stays ``. +Never turn an `` icon into a mask or recolour it by hand: a mask keeps +only the shape and fills it with one colour. `devup_ui_validate` with +`projectRoot` warns `multicolor-mask` when one does. + ## 11. A referenced asset must actually exist A fixed asset such as an icon must be exported, never referenced by a path that @@ -190,7 +198,9 @@ assetManifest/imageAudit and `asset-css-map.json`, schedules sorted batches of a most six and retains hidden, missing-source, failed and limit dispositions. Use assetPublicRoot for written `.webp` files and mapped code, or resources for reusable bytes. Custom assetRequests remain separate. Poll exportJob; resume -paused work or cancel with jobAction. Accepted results/checkpoints live in this +paused work or cancel with jobAction. Polls return compact asset rows; add +`responseFields` (e.g. `exportJob.assets`) to a poll for full rows. `assetJob` +only references `exportJob`. Accepted results/checkpoints live in this server process for the reported retention period. Scheduling is bounded to4096 sources and128 MiB retained asset bytes; remaining IDs carry explicit failures and can be retried in smaller scopes. Read completeness and failures before use. @@ -212,6 +222,51 @@ widths is checked in the consumer project: `devup_runtime_audit` writes the plan and verifies the report, and never runs the browser itself. Its ingest returns a ready `devup_visual_compare` call for each screenshot taken at a drawn width. + +## 14. Interactions by role, restrained + +Hover plus a transition is not a finished interaction. Each interactive +element gets its role's recipe - button, link, card, nav item, icon button, +input, disclosure - with pressed, focus, current-page, expanded, disabled and +loading states where they apply, and view transitions between pages - always +on, and proved by the runtime audit's view-transition check. Restraint is a rule, not a taste: at +most two properties per state, 150-250ms, existing tokens only, scale 0.97 to +1.05, nothing infinite, everything off under `_motionReduce`. The table is in +the devup-implementation-plan skill; `devup_screen_plan` records it in +`defaults` and `devup_ui_validate` reports both too little and too much. + +## 15. The latest stack, and vinext + +DevFive builds on Next App Router run by vinext on Vite, in bun workspaces, +on the latest versions. An app with a `vinext` dependency, `vinext` scripts or +a `vite.config` importing vinext is an App Router app with no `next` +dependency; `devup_project_context` reports `frameworks` per app. Before +implementing, the workflow runs `bunx @dependency-check-updates/cli -d -u --rm` +at the repository root, then `bun install`, build, lint and typecheck, and +checks current docs before using a library API - API knowledge from memory is +assumed stale. A new project starts from `dev-five-git/service-template`; one +on the `next` CLI is asked once whether to migrate. + +## 16. Visual verification of everything, full autonomy + +Visually verify every screen, section and asset at every drawn width. +`devup_screen_plan` `auditPlan` writes one runtime-audit plan per URL: page +health, overflow and clipped text at every width; the page, each section and +each asset against its Figma render, and the size and position of each +section, card and asset, at the drawn widths; hover, focus and a view +transition, ordered by code. Layout is exact; a mismatch is drift or a +converter bug: geometry allows only half a pixel of rounding, and a +mismatch is `code-drift` when the code no longer writes what the export +recorded in `.devup/exports/`, or `converter-bug` when it does - reported +with a repro in `.devup/converter-bugs/`, never retried or tuned silently. +Merged pages are found per width through `data-figma-node-ids`, dynamic +routes at concrete URLs, and production builds strip the ids unless +`DEVUP_AUDIT=1`. `status` classifies each failure (runtime-error, +overflow, layout-position, layout-size, asset-mismatch, section-visual, +page-visual, interaction, view-transition) with its node, file and fix hint. +Never stop to wait; reorder the work: a decision after confirm goes to +`defer` and is taken with `assume` at the end. Done = zero fails, or listed as +unresolved with evidence after `maxFixAttempts`. "#; #[cfg(test)] @@ -245,10 +300,13 @@ mod tests { /// The ceiling moved once, for rule 1a: the connection order has to be /// read before the first Figma call, because an agent that met a plugin /// attached and a `disconnected` direct path asked its user to log in. + /// It moved again for rule 2c: the screen plan has to come before the + /// first page is written, and an agent that has not read the guide yet + /// is exactly the one that writes pages straight away. #[test] fn instructions_are_far_smaller_than_the_guidance_they_point_at() { assert!( - INSTRUCTIONS.len() < 1_400, + INSTRUCTIONS.len() < 1_500, "instructions grew back to {} bytes; the per-session cost is the thing being fixed", INSTRUCTIONS.len() ); diff --git a/crates/devup-mcp/src/server/mask_guard.rs b/crates/devup-mcp/src/server/mask_guard.rs new file mode 100644 index 00000000..c15e6790 --- /dev/null +++ b/crates/devup-mcp/src/server/mask_guard.rs @@ -0,0 +1,578 @@ +//! `devup_ui_validate`'s `multicolor-mask` rule: a `maskImage` that points at +//! an SVG painted in more than one colour. +//! +//! A CSS mask keeps only the shape of its image and fills it with the one +//! `bg` colour, so a gradient face and a solid outline become one solid blob +//! and whatever the icon drew inside them disappears. The generator no +//! longer emits such a mask, but hand-written code (or a hand "fix" of an +//! `` icon) can, and nothing in the TSX alone shows it: the colours +//! are in the file. So when `projectRoot` is given, each `maskImage` URL is +//! looked up under the project's public directories and the SVG's own paints +//! are read. +//! +//! Like the rest of the ground-truth tools this reads the files on every +//! call and never caches them. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use devup_mcp_devup_ui::ui_validate::{Severity, Violation}; +use quick_xml::{Reader, events::BytesStart, events::Event}; +use serde_json::{Map, json}; + +use super::project_root::find_dirs_named; + +pub(super) const RULE: &str = "multicolor-mask"; + +/// SVG files larger than this are not read; no icon is anywhere near it. +const MAX_SVG_BYTES: u64 = 2 * 1024 * 1024; + +/// How deep to look for `public` directories, enough for `apps/web/public`. +const PUBLIC_DIR_DEPTH: usize = 4; + +/// One `multicolor-mask` warning per `maskImage` URL in `sources` whose SVG, +/// found under a public directory of `project_root`, paints more than one +/// colour. URLs that do not resolve to a file are left alone: the check +/// cannot speak about a file it cannot read. +pub(super) fn violations(sources: &[(&str, &str)], project_root: Option<&str>) -> Vec { + let Some(root) = project_root.map(Path::new).filter(|root| root.is_dir()) else { + return Vec::new(); + }; + let mut public_dirs: Option> = None; + let mut found = Vec::new(); + for (name, source) in sources { + for (url, byte_range) in mask_urls(source) { + let public_dirs = public_dirs.get_or_insert_with(|| public_dirs_of(root)); + let Some(file) = resolve(public_dirs, &url) else { + continue; + }; + let Some(svg) = read_svg(&file) else { + continue; + }; + let paints = svg_paints(&svg); + if !paints.is_multicolor() { + continue; + } + let shown = file + .strip_prefix(root) + .unwrap_or(&file) + .to_string_lossy() + .replace('\\', "/"); + let mut context = Map::new(); + context.insert("maskUrl".into(), json!(url)); + context.insert("file".into(), json!(shown)); + context.insert("paints".into(), json!(paints.describe())); + if !name.is_empty() { + context.insert("sourceName".into(), json!(name)); + } + found.push(Violation { + rule: RULE, + severity: Severity::Warning, + byte_range, + context, + message: format!( + "maskImage points at {shown}, which paints {}. A mask keeps only the shape and fills it with one bg colour, so this icon flattens to a single-colour blob.", + paints.summary() + ), + suggestion: Some(format!( + "Render it as with the same size and drop bg/maskImage/maskSize/maskPos/maskRepeat. Keep maskImage only for single-colour icons." + )), + }); + } + } + found +} + +/// Every `url(...)` inside a `maskImage` value, as JSX (`maskImage="..."`, +/// `maskImage={...}`) or object (`maskImage: "..."`), with its byte range. +/// Only root-relative `.svg` URLs are kept, decoded and without query or +/// fragment. +fn mask_urls(source: &str) -> Vec<(String, [usize; 2])> { + let bytes = source.as_bytes(); + let mut urls = Vec::new(); + let mut from = 0; + while let Some(offset) = source[from..].find("maskImage") { + let mut index = from + offset + "maskImage".len(); + from = index; + index = skip_space(bytes, index); + if !matches!(bytes.get(index), Some(b'=' | b':')) { + continue; + } + index = skip_space(bytes, index + 1); + let Some(end) = value_end(bytes, index) else { + continue; + }; + let value = &source[index..end]; + let mut cursor = 0; + while let Some(start) = value[cursor..].find("url(") { + let open = cursor + start + "url(".len(); + let Some(close) = value[open..].find(')') else { + break; + }; + let raw = value[open..open + close] + .trim() + .trim_matches(|c| matches!(c, '\'' | '"')); + cursor = open + close + 1; + let path = raw.split(['?', '#']).next().unwrap_or(raw); + if path.starts_with('/') + && !path.starts_with("//") + && path.to_ascii_lowercase().ends_with(".svg") + { + urls.push(( + percent_decode(path), + [index + open - "url(".len(), index + cursor], + )); + } + } + from = end; + } + urls +} + +fn skip_space(bytes: &[u8], mut index: usize) -> usize { + while bytes.get(index).is_some_and(u8::is_ascii_whitespace) { + index += 1; + } + index +} + +/// Where a prop or property value that starts at `start` ends: the closing +/// quote of a string, or the brace that balances an expression container. +fn value_end(bytes: &[u8], start: usize) -> Option { + match *bytes.get(start)? { + quote @ (b'"' | b'\'' | b'`') => bytes[start + 1..] + .iter() + .position(|byte| *byte == quote) + .map(|position| start + 1 + position + 1), + b'{' => { + let mut depth = 0usize; + let mut quote = None; + for (position, byte) in bytes.iter().enumerate().skip(start) { + match (quote, *byte) { + (Some(open), byte) if byte == open => quote = None, + (Some(_), _) => {} + (None, b'"' | b'\'' | b'`') => quote = Some(*byte), + (None, b'{') => depth += 1, + (None, b'}') => { + depth -= 1; + if depth == 0 { + return Some(position + 1); + } + } + _ => {} + } + } + None + } + _ => None, + } +} + +fn percent_decode(path: &str) -> String { + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && let Some(byte) = path + .get(index + 1..index + 3) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()) + { + decoded.push(byte); + index += 3; + continue; + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8(decoded).unwrap_or_else(|_| path.to_owned()) +} + +/// `/public` first, then any other `public` directory the project's +/// packages have, as the authoritative scan finds them. +fn public_dirs_of(root: &Path) -> Vec { + let mut dirs = Vec::new(); + let top = root.join("public"); + if top.is_dir() { + dirs.push(top); + } + for dir in find_dirs_named(root, "public", PUBLIC_DIR_DEPTH).0 { + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + dirs +} + +/// The file a root-relative URL is served from, refusing any segment that +/// could leave the public directory. +fn resolve(public_dirs: &[PathBuf], url: &str) -> Option { + let segments = url.trim_start_matches('/').split('/').collect::>(); + if segments.iter().any(|segment| { + segment.is_empty() || matches!(*segment, "." | "..") || segment.contains(['\\', ':']) + }) { + return None; + } + public_dirs.iter().find_map(|dir| { + let file = segments + .iter() + .fold(dir.clone(), |path, segment| path.join(segment)); + file.is_file().then_some(file) + }) +} + +fn read_svg(file: &Path) -> Option { + let length = std::fs::metadata(file).ok()?.len(); + if length > MAX_SVG_BYTES { + return None; + } + std::fs::read_to_string(file).ok() +} + +/// The paints an SVG draws with, outside ``, `` and other +/// definitions that are never painted themselves. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct SvgPaints { + /// Flat colours, normalised so `#FFF` and `white` are one. + colors: BTreeSet, + /// `url(#...)` paints: gradients or patterns, more than one colour each. + references: BTreeSet, + /// `` elements: pixels a mask would throw away. + images: usize, +} + +impl SvgPaints { + fn is_multicolor(&self) -> bool { + self.colors.len() > 1 || !self.references.is_empty() || self.images > 0 + } + + fn describe(&self) -> Vec { + let mut paints = self.colors.iter().cloned().collect::>(); + paints.extend(self.references.iter().cloned()); + if self.images > 0 { + paints.push(format!("{} ", self.images)); + } + paints + } + + fn summary(&self) -> String { + let mut parts = Vec::new(); + if !self.colors.is_empty() { + parts.push(format!( + "{} colour(s) ({})", + self.colors.len(), + self.colors.iter().cloned().collect::>().join(", ") + )); + } + if !self.references.is_empty() { + parts.push(format!( + "{} gradient/pattern fill(s)", + self.references.len() + )); + } + if self.images > 0 { + parts.push(format!("{} embedded image(s)", self.images)); + } + parts.join(" and ") + } +} + +/// Elements whose subtree defines something rather than painting it. +const UNPAINTED: &[&str] = &[ + "clippath", + "mask", + "defs", + "lineargradient", + "radialgradient", + "pattern", + "symbol", + "marker", + "filter", + "style", + "title", + "desc", + "metadata", +]; + +/// Elements that paint their fill and stroke. +const SHAPES: &[&str] = &[ + "path", "rect", "circle", "ellipse", "polygon", "polyline", "line", "text", "use", +]; + +/// Inherited state for the element being read. +#[derive(Clone, Default)] +struct Inherited { + fill: Option, + stroke: Option, + unpainted: bool, +} + +pub(super) fn svg_paints(svg: &str) -> SvgPaints { + let mut paints = SvgPaints::default(); + let mut reader = Reader::from_str(svg); + reader.config_mut().check_end_names = false; + let mut stack = vec![Inherited::default()]; + loop { + let (element, opens) = match reader.read_event() { + Ok(Event::Start(element)) => (element, true), + Ok(Event::Empty(element)) => (element, false), + Ok(Event::End(_)) => { + if stack.len() > 1 { + stack.pop(); + } + continue; + } + Ok(Event::Eof) | Err(_) => break, + Ok(_) => continue, + }; + let parent = stack.last().cloned().unwrap_or_default(); + let current = paint_element(&element, &parent, &mut paints); + if opens { + stack.push(current); + } + } + paints +} + +/// Records what `element` paints and returns what its children inherit. +fn paint_element( + element: &BytesStart<'_>, + parent: &Inherited, + paints: &mut SvgPaints, +) -> Inherited { + let name = element.local_name().as_ref().to_ascii_lowercase(); + let mut fill = None; + let mut stroke = None; + let mut hidden = false; + let mut fill_hidden = false; + let mut stroke_hidden = false; + let mut apply = |key: &str, value: &str| match key { + "fill" => fill = Some(value.trim().to_owned()), + "stroke" => stroke = Some(value.trim().to_owned()), + "opacity" => hidden |= is_zero(value), + "fill-opacity" => fill_hidden |= is_zero(value), + "stroke-opacity" => stroke_hidden |= is_zero(value), + "display" => hidden |= value.trim() == "none", + "visibility" => hidden |= value.trim() == "hidden", + _ => {} + }; + for attribute in element.attributes().with_checks(false).flatten() { + let key = attribute.key.local_name().as_ref().to_ascii_lowercase(); + let value = quick_xml::escape::unescape(&attribute.value) + .map_or_else(|_| attribute.value.to_string(), |value| value.into_owned()); + if key == "style" { + for declaration in value.split(';') { + if let Some((key, value)) = declaration.split_once(':') { + apply(&key.trim().to_ascii_lowercase(), value); + } + } + } else { + apply(&key, &value); + } + } + let inherit = |own: Option, inherited: &Option| match own { + Some(value) if value != "inherit" => Some(value), + _ => inherited.clone(), + }; + let current = Inherited { + fill: inherit(fill, &parent.fill), + stroke: inherit(stroke, &parent.stroke), + unpainted: parent.unpainted || hidden || UNPAINTED.contains(&name.as_str()), + }; + if current.unpainted { + return current; + } + if name == "image" { + paints.images += 1; + } else if SHAPES.contains(&name.as_str()) { + // An unset fill is black; a line has no area to fill. + if name != "line" && !fill_hidden { + add_paint(paints, current.fill.as_deref().unwrap_or("black")); + } + if let Some(stroke) = current.stroke.as_deref().filter(|_| !stroke_hidden) { + add_paint(paints, stroke); + } + } + current +} + +fn is_zero(value: &str) -> bool { + value + .trim() + .trim_end_matches('%') + .parse::() + .is_ok_and(|number| number == 0.0) +} + +fn add_paint(paints: &mut SvgPaints, value: &str) { + let value = value.trim().to_ascii_lowercase(); + match value.as_str() { + "" | "none" | "transparent" => {} + reference if reference.starts_with("url(") => { + paints.references.insert(reference.to_owned()); + } + color => { + paints.colors.insert(normalize_color(color)); + } + } +} + +fn normalize_color(color: &str) -> String { + match color { + "black" => return "#000000".to_owned(), + "white" => return "#ffffff".to_owned(), + _ => {} + } + if let Some(hex) = color.strip_prefix('#') + && matches!(hex.len(), 3 | 4) + && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + let mut long = String::from("#"); + for digit in hex.chars() { + long.push(digit); + long.push(digit); + } + return long; + } + color.split_whitespace().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The prime "find" icon as Figma exports it: a gradient face under a + /// solid outline, clipped by a white rect. + const FIND: &str = r##" + + + + + + + + + + + + + + +"##; + + #[test] + fn a_gradient_with_a_solid_outline_is_multicolor() { + let paints = svg_paints(FIND); + assert!(paints.is_multicolor(), "{paints:?}"); + assert_eq!(paints.colors, BTreeSet::from(["#552aff".to_owned()])); + assert_eq!(paints.references.len(), 1); + } + + #[test] + fn a_clip_path_rect_does_not_count_as_a_paint() { + let svg = r##" + + + +"##; + let paints = svg_paints(svg); + assert!(!paints.is_multicolor(), "{paints:?}"); + assert_eq!(paints.colors, BTreeSet::from(["#552aff".to_owned()])); + } + + #[test] + fn two_colours_and_inherited_or_default_fills_are_counted() { + let two = + r##""##; + assert!(svg_paints(two).is_multicolor()); + + // `` reaches its paths, and an unset fill is black. + let inherited = r##""##; + assert_eq!(svg_paints(inherited).colors.len(), 2); + + let style = r##""##; + assert!(!svg_paints(style).is_multicolor()); + } + + #[test] + fn hidden_paints_and_embedded_images() { + let hidden = r##""##; + assert!(!svg_paints(hidden).is_multicolor()); + + let image = r##""##; + assert!(svg_paints(image).is_multicolor()); + } + + #[test] + fn mask_urls_are_read_from_jsx_and_object_values() { + let source = r#" + +const s = css({ maskImage: 'url("/icons/c.svg")' }) +"#; + let urls = mask_urls(source) + .into_iter() + .map(|(url, range)| { + assert!(source[range[0]..range[1]].starts_with("url(")); + url + }) + .collect::>(); + assert_eq!( + urls, + [ + "/icons/find.svg", + "/icons/속성 1=a.svg", + "/icons/b.svg", + "/icons/c.svg" + ] + ); + } + + #[test] + fn urls_cannot_leave_the_public_directory() { + let dirs = vec![PathBuf::from("public")]; + assert_eq!(resolve(&dirs, "/../secret.svg"), None); + assert_eq!(resolve(&dirs, "/icons//x.svg"), None); + assert_eq!(resolve(&dirs, "/C:/x.svg"), None); + } + + #[test] + fn a_project_file_is_read_and_named() -> std::io::Result<()> { + let root = std::env::temp_dir().join(format!( + "mask-guard-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(root.join("public/icons"))?; + std::fs::write(root.join("public/icons/find.svg"), FIND)?; + std::fs::write( + root.join("public/icons/arrow.svg"), + r##""##, + )?; + let tsx = r##"<>"##; + let root_text = root.to_string_lossy().into_owned(); + + let found = violations(&[("Page.tsx", tsx)], Some(&root_text)); + assert_eq!(found.len(), 1, "{found:?}"); + let violation = &found[0]; + assert_eq!(violation.rule, RULE); + assert_eq!(violation.severity, Severity::Warning); + assert_eq!(violation.context["maskUrl"], "/icons/find.svg"); + assert_eq!(violation.context["sourceName"], "Page.tsx"); + assert!( + violation.message.contains("find.svg"), + "{}", + violation.message + ); + assert!( + violation + .suggestion + .as_deref() + .is_some_and(|suggestion| suggestion.contains(" Result { + if let Some(hints) = &input.screenshot_observations { + hints.validate()?; + } + let target = self + .bind_to_bridge( + parse_link(input.url.as_deref())?, + Unnamed::Selection, + &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit,"screenshotObservations":input.screenshot_observations } }), + ) + .await + ?; + target.node_id.as_ref().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Exploring neighboring Figma screens requires a node-id.", + false, + ) + })?; + if !(1..=MATCH_CEILING).contains(&input.limit) { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "The explore limit must be between 1 and 100 inclusive.", + false, + )); + } + let requested_target = target.clone(); + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.resource_scope = ResourceScope::None; + request.explore = Some(ExploreReadOptions { + // Fixed, not derived from `limit`. Derived, it made `limit` two + // things at once - how many screens come back, and how much of the + // page the collection script is allowed to walk - and the second + // one is why limit 33 could answer with fewer screens than limit + // 30. Asking for the script's own ceiling every time also means + // one anchor's projection can be reused for a neighbour's, since + // every explore of a file now reads the same amount. + projection_limit: EXPLORE_PROJECTION_LIMIT, + text_preview_limit: if input.include_text_preview + || input.screenshot_observations.is_some() + { + 160 + } else { + 0 + }, + }); + let result = self + .start_operation( + PendingOperation::Explore { + // The projection ranks up to the ceiling and `limit` cuts + // the ranked list afterwards, in `apply_explore_limit`, so + // that what was found and what was returned are both known + // and can be reported apart. + limit: MATCH_CEILING, + target: requested_target, + }, + request, + input.refresh, + ) + .await?; + let mut result = result; + screenshot_observations::rank(&mut result, input.screenshot_observations.as_ref()); + Ok(apply_explore_limit(result, input.limit)) + } + + /// The screens `devup_screen_plan draft` plans: the caller's list from an + /// earlier explore, or one explore of the linked Section or selection. + async fn plan_screens( + &self, + input: &screen_plan::ScreenPlanInput, + ) -> Result<(Vec, Value), DevupError> { + if !input.screens.is_empty() { + return Ok(( + input.screens.clone(), + json!({"from": "caller", "url": input.url, + "nodeId": input.url.as_deref().and_then(|url| FigmaTarget::parse(url).ok()).and_then(|target| target.node_id)}), + )); + } + let explored = self + .explore(FigmaExploreInput { + screenshot_observations: None, + url: input.url.clone(), + limit: MATCH_CEILING, + include_text_preview: false, + refresh: input.refresh, + }) + .await?; + let candidates = explored["candidates"] + .as_array() + .cloned() + .unwrap_or_default(); + let screens_only = candidates + .iter() + .any(|candidate| candidate["node"]["kind"] == "screen"); + let screens = candidates + .iter() + .map(|candidate| &candidate["node"]) + .filter(|node| !screens_only || node["kind"] == "screen") + .filter_map(screen_plan::screen_from_explore) + .collect(); + // Nodes beside the screens - a logo, a frame named og - are the + // favicon and share image candidates the site questions offer. + let assets = candidates + .iter() + .map(|candidate| &candidate["node"]) + .filter(|node| screens_only && node["kind"] != "screen") + .filter_map(|node| { + Some(json!({"nodeId": node["nodeId"].as_str()?, "name": node["name"].as_str()?})) + }) + .collect::>(); + Ok(( + screens, + json!({ + "assetCandidates": assets, + "from": "devup_figma_explore", + "url": input.url, + "nodeId": explored["anchor"]["nodeId"], + "anchorName": explored["anchor"]["name"], + "anchorBreadcrumb": explored["anchor"]["breadcrumb"], + "targetKind": explored["targetKind"], + "fileKey": explored["source"]["fileKey"], + "truncation": explored["truncation"], + }), + )) + } + async fn connection_report(&self) -> Result { let status = self.services.auth.status().await?; let direct = self.services.auth.direct_path_snapshot().await?; @@ -1555,73 +1688,14 @@ impl DevupServer { &self, Parameters(input): Parameters, ) -> Result { - if let Some(hints) = &input.screenshot_observations { - hints.validate().map_err(to_mcp_error)?; - } - let target = self - .bind_to_bridge( - parse_link(input.url.as_deref()).map_err(to_mcp_error)?, - Unnamed::Selection, - &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit,"screenshotObservations":input.screenshot_observations } }), - ) - .await - .map_err(to_mcp_error)?; - target.node_id.as_ref().ok_or_else(|| { - to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Exploring neighboring Figma screens requires a node-id.", - false, - )) - })?; - if !(1..=MATCH_CEILING).contains(&input.limit) { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "The explore limit must be between 1 and 100 inclusive.", - false, - ))); - } - let requested_target = target.clone(); - let mut request = CollectionRequest::new(target, CollectionScope::Node); - request.resource_scope = ResourceScope::None; - request.explore = Some(ExploreReadOptions { - // Fixed, not derived from `limit`. Derived, it made `limit` two - // things at once - how many screens come back, and how much of the - // page the collection script is allowed to walk - and the second - // one is why limit 33 could answer with fewer screens than limit - // 30. Asking for the script's own ceiling every time also means - // one anchor's projection can be reused for a neighbour's, since - // every explore of a file now reads the same amount. - projection_limit: EXPLORE_PROJECTION_LIMIT, - text_preview_limit: if input.include_text_preview - || input.screenshot_observations.is_some() - { - 160 - } else { - 0 - }, - }); - let result = self - .start_operation( - PendingOperation::Explore { - // The projection ranks up to the ceiling and `limit` cuts - // the ranked list afterwards, in `apply_explore_limit`, so - // that what was found and what was returned are both known - // and can be reported apart. - limit: MATCH_CEILING, - target: requested_target, - }, - request, - input.refresh, - ) + self.explore(input) .await - .map_err(to_mcp_error)?; - let mut result = result; - screenshot_observations::rank(&mut result, input.screenshot_observations.as_ref()); - Ok(tool_result(apply_explore_limit(result, input.limit))) + .map(tool_result) + .map_err(to_mcp_error) } #[tool( - description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. \ + description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). \ The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. \ This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. \ Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. \ @@ -1647,6 +1721,49 @@ impl DevupServer { } } } + // Before anything is validated or collected: a page write without a + // confirmed screen plan is answered with the plan to make, and a + // pageScaffold route comes from the plan when one covers the frame. + if workflow.job_id.is_none() + && let Some(project_root) = input.project_root.clone() + { + let mut frames = input.frame_ids.clone(); + if frames.is_empty() { + let node = match (&input.url, &input.artifact_id) { + (Some(url), _) => FigmaTarget::parse(url).ok().and_then(|t| t.node_id), + (None, Some(id)) => self + .artifacts + .get(id) + .await + .and_then(|artifact| artifact.payload.target.node_id.clone()), + _ => None, + }; + frames.extend(node); + } + let request = screen_plan::ExportRequest { + project_root: &project_root, + url: input.url.as_deref(), + frames, + all_screens: input.all_screens, + scaffold_write: input.page_scaffold.as_ref().is_some_and(|s| s.write), + scaffold_route: input.page_scaffold.as_ref().map(|s| s.route.as_str()), + output_paths: input + .output_paths + .iter() + .filter(|(key, _)| !key.ends_with("pageScaffold")) + .map(|(_, path)| path.as_str()) + .collect(), + }; + match screen_plan::export_gate(&request).map_err(to_mcp_error)? { + screen_plan::Gate::Open => {} + screen_plan::Gate::Blocked(response) => return Ok(tool_result(response)), + screen_plan::Gate::Route(route) => { + if let Some(scaffold) = input.page_scaffold.as_mut() { + scaffold.route = route; + } + } + } + } projection::page_scaffold::validate(input.page_scaffold.as_ref(), &input.outputs) .map_err(to_mcp_error)?; validation::validate_asset_budget(&input.asset_requests).map_err(to_mcp_error)?; @@ -1696,21 +1813,29 @@ impl DevupServer { // their TSX. Checking it only on the synchronous returns left // every real export unchecked. let project_root = input.project_root.clone(); - return job - .wait_briefly() + // responseFields on a poll selects from this reply; without them + // the reply carries compact per-asset rows. + let selection = &input.response_selection; + selection.validate().map_err(to_mcp_error)?; + let result = job + .wait_briefly(selection.active()) .await - .map(|result| { - tool_result(with_project_checks( - result, - project_root.as_deref(), - &skills::Lookup::new( - project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ), - )) - }) - .map_err(to_mcp_error); + .map_err(to_mcp_error)?; + let result = with_project_checks( + result, + project_root.as_deref(), + &skills::Lookup::new( + project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ), + ); + if selection.active() { + return compact::pick(&result, selection) + .map(tool_result) + .map_err(to_mcp_error); + } + return Ok(tool_result(result)); } if workflow.job_action.is_some() { return Err(to_mcp_error(DevupError::new( @@ -2187,7 +2312,7 @@ impl DevupServer { } #[tool( - description = "Read a project's real devup.json theme tokens, openapi.json endpoints/schemas, Vespertide models/*.json tables/columns, or UI component/props/import/route reuse evidence (scope: theme | api | db | ui | routes | all) — read-only, no session cache, never guesses. UI/routes are opt-in and excluded from all. Routes inventories Next App Router pages with package authority and scan limits. Identify deployments by server.commit/buildId, not version alone; server.displayVersion is a readable version+buildId.", + description = "Read a project's real devup.json theme tokens, openapi.json endpoints/schemas, Vespertide models/*.json tables/columns, or UI component/props/import/route reuse evidence (scope: theme | api | db | ui | routes | all) — read-only, no session cache, never guesses. UI/routes are opt-in and excluded from all. Routes inventories Next App Router pages with package authority and scan limits; ui and routes add `frameworks`, which app runs on vinext (a vinext dependency or scripts, or a vite.config importing vinext - no next dependency needed) or next, across bun workspaces. Identify deployments by server.commit/buildId, not version alone; server.displayVersion is a readable version+buildId.", output_schema = permissive_object_output_schema() )] async fn devup_project_context( @@ -2214,8 +2339,8 @@ impl DevupServer { #[tool( description = "Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. \ - `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. `themeNotes` summarizes each encountered empty token category once. \ - Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio.", + `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. With projectRoot, a maskImage whose public SVG paints more than one colour warns `multicolor-mask` (use ). `themeNotes` summarizes each encountered empty token category once. \ + Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), one with no focus style is a warning (focus-style), too much motion - over 400ms, scale above 1.05, infinite - is a warning (too-much-motion), an icon-only button without aria-label is a warning (icon-button-label); a button with no _active, a _hover with no transition, motion with no _motionReduce and a nav link with no aria-current are info (button-active, hover-transition, motion-reduce, nav-current); a page or layout with no metadata export is a warning (page-metadata, files mode), and with projectRoot an app with no icon or favicon is a warning (app-icon). Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio.", output_schema = permissive_object_output_schema() )] async fn devup_ui_validate( @@ -2258,6 +2383,29 @@ impl DevupServer { input.strict, ) }; + // Site completeness, for code checked against a project: feedback on + // every interactive element (bundle mode checks it per file), and an + // icon for the app. + if let Some(root) = input.project_root.as_deref() { + if input.files.is_none() { + report.violations.extend( + devup_mcp_devup_ui::ui_validate::site::interaction_findings( + input.source_name.as_deref().unwrap_or("input.tsx"), + &input.tsx, + ), + ); + } + report + .violations + .extend(screen_plan::missing_app_icon(root)); + if input.strict + && report.violations.iter().any(|finding| { + finding.severity == devup_mcp_devup_ui::ui_validate::Severity::Warning + }) + { + report.ok = false; + } + } if input.asset_policies.len() > 4096 { return Err(invalid("assetPolicies exceeds 4096 entries.".into())); } @@ -2271,7 +2419,7 @@ impl DevupServer { .collect::>() }) .unwrap_or_else(|| vec![("", input.tsx.as_str())]); - for (name, tsx) in sources { + for &(name, tsx) in &sources { for diagnostic in devup_mcp_devup_ui::codegen::validate_asset_effects(tsx, &input.asset_policies) { @@ -2288,6 +2436,12 @@ impl DevupServer { } } } + // A mask flattens its SVG to one colour, which only the file shows. + let masks = mask_guard::violations(&sources, input.project_root.as_deref()); + if input.strict && !masks.is_empty() { + report.ok = false; + } + report.violations.extend(masks); // These keys are assembled here rather than serialized from // `UiValidation`, so anything the struct's own documentation // explains reaches nobody unless it is answered here too. Three @@ -2431,7 +2585,7 @@ impl DevupServer { #[tool( description = "Plan, then verify, a browser run of generated screens at several viewport widths that the consumer project executes (action: plan | ingest). This server never launches a browser or runs a command. \ - Optional templatePath transactionally writes the fixed Playwright runner and package files alongside outputPath. Dimension checks bind explicit Figma artifactId/nodeId references to CSS logical DOM sizes at viewports (default 1440/1100/858/390); width tables disclose missing references and report tolerance exceedances only, never multiplying zoom or DPR. plan validates a declarative plan - baseUrl and allowedOrigins, viewports, screens by routePath or by steps that find elements by role and accessible name, testId or text, and geometry checks (no-horizontal-overflow, contained, aligned, spans, visible) - gives it a content id, and writes it under an allowed root with outputPath. Unknown fields, shell strings, other origins and URLs carrying credentials are refused. \ + Optional templatePath transactionally writes the fixed Playwright runner and package files alongside outputPath. Dimension checks bind explicit Figma artifactId/nodeId references to CSS logical DOM sizes at viewports (default 1440/1100/858/390); width tables disclose missing references and report tolerance exceedances only, never multiplying zoom or DPR. plan validates a declarative plan - baseUrl and allowedOrigins, viewports, screens by routePath or by steps that find elements by role and accessible name, testId or text, and geometry checks (no-horizontal-overflow, contained, aligned, spans, visible, hidden, and bounds - size and position against the Figma frame, exact up to 0.5px of rounding) plus page-health, no-clipped-text, interaction, capture and view-transition (follow a link and confirm a view transition ran) - gives it a content id, and writes it under an allowed root with outputPath. Unknown fields, shell strings, other origins and URLs carrying credentials are refused. \ The consumer runs harness/runtime-audit against it in fresh isolated browser contexts. ingest reads the plan and the report.json it wrote, rejects a report not bound to that plan by id and file hash, recomputes every count from the items, verifies each screenshot's SHA-256, length and decoded width, and separates what it verified from what the executor only declared. A missing item is not-run, never a pass; a viewport with an environment error makes the verdict inconclusive. A visual original exists only at the widths a screen's Figma frame was drawn at.", output_schema = permissive_object_output_schema() )] @@ -2478,6 +2632,46 @@ impl DevupServer { .map_err(to_mcp_error)?; Ok(tool_result(result)) } + #[tool( + description = "Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a \"the Figma structure is wrong\" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `questionRounds`: questions already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed), at most maxQuestionsPerRound (default 4) per round. Ask everything now: a question tool with no cap (`question` in OpenCode) takes the flat `questions` list in one call; one with a cap (`AskUserQuestion` in Claude Code) takes the rounds in consecutive calls with no other work in between, skipping a question whose `when` was not met; never ask in prose and never implement before confirm. confirm takes every applicable answer keyed by question id and refuses the call, naming the missing ids. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it.", + output_schema = permissive_object_output_schema() + )] + async fn devup_screen_plan( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = match input.action.as_str() { + "draft" => { + let (screens, source) = self.plan_screens(&input).await.map_err(to_mcp_error)?; + let mut assets = input.assets.clone(); + assets.extend( + source["assetCandidates"] + .as_array() + .into_iter() + .flatten() + .filter_map(|asset| serde_json::from_value(asset.clone()).ok()), + ); + screen_plan::draft_with(&input, &screens, &assets, source, &self.output_policy) + .await + } + "confirm" => screen_plan::confirm(&input, &self.output_policy), + "assume" => screen_plan::assume(&input, &self.output_policy), + "status" => screen_plan::status(&input, &self.output_policy, &self.artifacts).await, + "auditPlan" => { + screen_plan::audit_plan(&input, &self.output_policy, &self.artifacts).await + } + "defer" => screen_plan::defer(&input, &self.output_policy), + other => Err(DevupError::new( + ErrorCode::DevupInvalidInput, + format!( + "action must be draft, confirm, status, assume, auditPlan or defer, not {other}." + ), + false, + )), + } + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } } /// Adds what `devup_ui_validate` checked beyond its rules, kept apart from @@ -2515,6 +2709,9 @@ fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { let mut rules = devup_mcp_devup_ui::ui_validate::rule_names() .into_iter() .collect::>(); + if input.project_root.is_some() { + rules.push(mask_guard::RULE); + } if input.files.is_some() { rules.extend([ "file-placement", @@ -2672,9 +2869,17 @@ fn skill_gap<'a>( if missing.is_empty() && outdated.is_empty() { return None; } + // The same install writes /devup, the one word that starts the whole + // workflow; say so where the agent is already being told to install. + let command = skills::command_state(lookup); Some(json!({ "missing": missing, "outdated": outdated, + "command": if command["installed"] == true { Value::Null } else { json!({ + "invocation": command["invocation"], + "supported": command["supported"], + "how": command["how"], + }) }, "why": why, "how": "Call devup_skills with action \"install\" and this same projectRoot, then load \ the skills the way your runtime loads a project skill. Before writing the code, \ diff --git a/crates/devup-mcp/src/server/page_scaffold.rs b/crates/devup-mcp/src/server/page_scaffold.rs index 3a7f6b5b..41e9570b 100644 --- a/crates/devup-mcp/src/server/page_scaffold.rs +++ b/crates/devup-mcp/src/server/page_scaffold.rs @@ -13,7 +13,10 @@ use std::{ // Object-form false schema keeps strict option parsing compatible with MCP clients. #[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] pub struct PageScaffoldOptions { - /// Caller-supplied App Router route, e.g. /settings/profile or / for the root page. + /// App Router route, e.g. /settings/profile or / for the root page. Omit + /// it when a confirmed devup_screen_plan covers the frame: the plan's + /// route is used, and a different one is refused as a mismatch. + #[serde(default)] pub route: String, /// Preview by default, including assets and other outputPaths in this call. #[serde(default)] diff --git a/crates/devup-mcp/src/server/project_context.rs b/crates/devup-mcp/src/server/project_context.rs index da46c6ec..24ec7d0e 100644 --- a/crates/devup-mcp/src/server/project_context.rs +++ b/crates/devup-mcp/src/server/project_context.rs @@ -255,9 +255,14 @@ pub async fn run( "theme" => Ok(theme_scope(&root, filter)), "api" => Ok(api_scope(&root, filter)), "db" => Ok(db_scope(&root, filter)), - "ui" => Ok(ui::run(&root, filter)), + "ui" => { + let mut inventory = ui::run(&root, filter); + inventory["frameworks"] = super::framework::report(&root); + Ok(inventory) + } "routes" => { let mut inventory = ui::run(&root, filter); + inventory["frameworks"] = super::framework::report(&root); inventory.as_object_mut().unwrap().remove("components"); inventory["scope"] = json!("routes"); Ok(inventory) diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 3335dbbb..ef3bbd07 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -1462,6 +1462,61 @@ fn projection_failure( "retryable":error.retryable,"details":details}) } +/// Marks the page root, its top-level children (the sections), their +/// children and every asset with `data-figma-node-id`, so the runtime audit captures a section +/// or an asset by the node it came from. Only for exports into a project +/// (`projectRoot`); `.devup/screen-plan.config.json` `figmaNodeIds: false` +/// turns it off. +fn annotate_node_ids( + output: &mut devup_mcp_devup_ui::codegen::CodegenOutput, + snapshot: &devup_mcp_figma::Snapshot, + root_id: &str, +) -> Value { + let sections = snapshot + .nodes + .get(root_id) + .map(|node| { + node.typed_view() + .child_ids() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + // A section's own children (the cards of a card row) too, so their + // order and position are checked against the frame. + let parts = sections + .iter() + .filter_map(|id| snapshot.nodes.get(id)) + .flat_map(|node| { + node.typed_view() + .child_ids() + .map(str::to_owned) + .collect::>() + }) + .collect::>(); + let assets = output + .source_map + .entries + .iter() + .filter(|entry| entry.asset_id.is_some()) + .filter_map(|entry| entry.node_id.clone()) + .collect::>(); + let wanted = std::iter::once(root_id.to_owned()) + .chain(sections.iter().cloned()) + .chain(parts.iter().cloned()) + .chain(assets.iter().cloned()) + .collect(); + let written = devup_mcp_devup_ui::codegen::node_ids::annotate_node_ids(output, &wanted); + json!({ + "attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE, + "root": written.iter().any(|id| id == root_id).then_some(root_id), + "sections": sections.iter().filter(|id| written.contains(id)).collect::>(), + "parts": parts.iter().filter(|id| written.contains(id)).collect::>(), + "assets": assets.iter().filter(|id| written.contains(id)).collect::>(), + "note": "Inert data attributes the runtime audit locates sections and assets by. Keep them; to drop them from a production build use Next's compiler.reactRemoveProperties {properties: [\"^data-figma-node-id$\"]}, or set figmaNodeIds false in .devup/screen-plan.config.json and re-export.", + }) +} + fn generate_collected_component( payload: &CollectedPayload, node_id: &str, @@ -1516,6 +1571,10 @@ pub(super) async fn complete_operation( output_policy: &OutputPolicy, artifact_store: &ArtifactStore, ) -> Result { + let project_root = match &operation { + PendingOperation::Export { theme_options, .. } => theme_options.project_root.clone(), + _ => None, + }; let mut response = project_operation( operation, payload, @@ -1531,6 +1590,23 @@ pub(super) async fn complete_operation( { attach_bridge_source(source, payload); } + // What this export wrote for each marked node, so the audit can tell + // code drift from a converter bug later. + if let Some(root) = project_root.as_deref() + && response.get("figmaNodeIds").is_some() + { + let root_node = payload.target.node_id.clone().unwrap_or_default(); + match super::export_record::write( + output_policy, + root, + &payload.target.file_key, + &root_node, + &response, + ) { + Ok(path) => response["exportRecord"] = json!(path), + Err(error) => response["exportRecordError"] = json!(error.message), + } + } Ok(response) } @@ -2527,7 +2603,7 @@ async fn project_operation( if outputs .iter() .any(|output| output == "tsx" || output == "responsiveTsx") - && let Some(merged) = merge_breakpoints( + && let Some(mut merged) = merge_breakpoints( &responsive_snapshot, &CodegenOptions { asset_policies: asset_policies.clone(), @@ -2577,6 +2653,21 @@ async fn project_operation( "Two frames occupy the same breakpoint slot. Request tsx separately, or select one frame per breakpoint slot.", )); } + if let Some(root) = theme_options.project_root.as_deref() + && super::screen_plan::node_ids_enabled(root) + { + let written = + devup_mcp_devup_ui::codegen::node_ids::annotate_merged(&mut merged); + let ids = result.entry("figmaNodeIds").or_insert_with(|| { + json!({"attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE}) + }); + ids["perWidthAttribute"] = + json!(devup_mcp_devup_ui::codegen::node_ids::NODE_IDS_ATTRIBUTE); + ids["responsive"] = json!(written); + ids["responsiveFormat"] = json!( + "data-figma-node-id is the node at the widest width the element is shown at; data-figma-node-ids lists \":\" for every drawn width it is shown at, widest first, space-separated, so [data-figma-node-ids~=\"390:56:78\"] selects it at one width. hiddenAt lists the drawn widths it is hidden at." + ); + } let module = merged.module(&name); // The responsive tree builder does not run the ordinary // finalizer. Preserve each breakpoint's source limitations. @@ -2744,6 +2835,12 @@ async fn project_operation( projection_diagnostics.extend(output.diagnostics.iter().cloned()); fidelity_reports.push(output.fidelity_report.clone()); generated_output_target = "tsx"; + if let Some(root) = theme_options.project_root.as_deref() + && super::screen_plan::node_ids_enabled(root) + { + let annotated = annotate_node_ids(&mut output, &payload.snapshot, node_id); + result.insert("figmaNodeIds".to_owned(), annotated); + } tsx_source_map = Some(output.source_map.clone()); if keep_tsx && output_paths.contains_key("tsx") { pending_text_outputs.insert("tsx".to_owned(), output.tsx.clone()); diff --git a/crates/devup-mcp/src/server/runtime_audit.rs b/crates/devup-mcp/src/server/runtime_audit.rs index 73f2507f..88a90539 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -29,7 +29,9 @@ const SCHEMA_VERSION: u64 = 1; const MAX_VIEWPORTS: usize = 8; const MAX_SCREENS: usize = 60; const MAX_STEPS: usize = 20; -const MAX_CHECKS: usize = 40; +// A full audit names a bounds, capture and health check per section and +// asset, so a screen carries far more than a hand-written plan would. +const MAX_CHECKS: usize = 200; const MAX_TIMEOUT_MS: u64 = 120_000; const MAX_VIEWPORT_SIDE: u32 = 4096; const MAX_TEXT: usize = 300; @@ -209,7 +211,29 @@ pub struct CheckInput { /// visible: subject renders with a non-empty box. /// dimensions: compare the explicitly mapped element box in CSS pixels /// with acquired Figma logical dimensions at each named viewport width. - #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions"]))] + /// view-transition: follow the subject link to another route and confirm + /// a view transition ran (document.startViewTransition, or a + /// cross-document pagereveal transition). It navigates away, so the + /// executor runs it after the screen's other checks and screenshot; the + /// next screen should start from its own routePath. The plan writer + /// moves it after the screen's other checks and the executor returns to + /// the screen's URL after it, so order in the plan cannot break a run. + /// page-health: no error page text ("This page couldn't load", + /// "Application error", ...), page error, console error or failed + /// navigation while the screen was open. + /// bounds: subject's size and position relative to container's top-left, + /// in CSS px, against `expected` (the Figma geometry). Exact: layout is + /// converted statically, so only half a pixel of rounding is allowed and + /// no tolerance can be set. + /// hidden: subject is absent or has no visible box - an element a width's + /// frame does not draw. + /// no-clipped-text: no text inside subject (or the page) is cut off by an + /// overflow-hidden box; ellipsis and line clamp are intended and pass. + /// interaction: hovering or keyboard-focusing subject (`state`) changes + /// something visible; without a subject, every visible link and button. + /// capture: screenshot subject's element box for devup_visual_compare + /// against `reference`; ingest turns it into a ready call. + #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden"]))] pub kind: String, #[serde(default)] pub subject: Option, @@ -236,8 +260,60 @@ pub struct CheckInput { /// intermediate widths are never interpolated or inferred from screenshots. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub figma_dimensions: Vec, + /// bounds: the expected box relative to container, in CSS px. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected: Option, + /// interaction: hover or focus (keyboard, so :focus-visible applies). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(extend("enum" = ["hover", "focus"]))] + pub state: Option, + /// Only at these viewport widths; empty means every width. A bounds or + /// capture check holds Figma geometry, which exists only where the frame + /// was drawn. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub widths: Vec, + /// The Figma node this check is about, so a failure names it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + /// capture: what the element screenshot is compared with. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct BoundsInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// A native-node PNG of the same Figma node, captured by devup_figma_export +/// (assetCaptures, renderMode node, format png, scale 1). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct CaptureReferenceInput { + /// section: a top-level child of the frame. asset: an image or icon. + #[schemars(extend("enum" = ["section", "asset"]))] + pub scope: String, + pub artifact_id: String, + pub asset_id: String, + /// Changed-pixel ratio the comparison allows; default 0.03 for a + /// section and 0.05 for an asset, which absorbs anti-aliased edges but + /// not a filled shape where an outline was drawn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub threshold: Option, } +/// Changed-pixel ratios that absorb anti-aliasing between Figma's renderer +/// and Chromium. A small icon has proportionally more edge, so it gets more. +pub(crate) const PAGE_THRESHOLD: f64 = 0.03; +pub(crate) const SECTION_THRESHOLD: f64 = 0.03; +pub(crate) const ASSET_THRESHOLD: f64 = 0.05; + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(extend("additionalProperties" = json!({"not": {}})))] @@ -264,6 +340,24 @@ pub struct ReadinessInput { pub struct FigmaAnchorInput { pub node_id: String, pub drawn_widths: Vec, + /// The frame drawn at each width when a page has one per width, with + /// the devup_figma_export artifact holding its referencePng. Ingest puts + /// that artifactId in the page comparison it returns. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub frames: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct FrameReferenceInput { + pub width: u32, + pub node_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_id: Option, + /// Changed-pixel ratio the page comparison allows; default 0.03. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub threshold: Option, } fn invalid(message: impl Into) -> DevupError { @@ -522,8 +616,64 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { Ok(()) }; match check.kind.as_str() { - "no-horizontal-overflow" => needs(false, false)?, - "visible" => needs(true, false)?, + "no-horizontal-overflow" | "page-health" => needs(false, false)?, + "visible" | "view-transition" | "hidden" => needs(true, false)?, + "no-clipped-text" => { + if check.container.is_some() { + return Err(invalid("A no-clipped-text check takes no container.")); + } + } + "bounds" => { + needs(true, true)?; + if check.tolerance_px.is_some() { + return Err(invalid( + "A bounds check is exact (half a pixel of rounding); it takes no tolerancePx. A mismatch is code drift or a converter bug, never noise.", + )); + } + let Some(expected) = &check.expected else { + return Err(invalid( + "A bounds check needs expected {x, y, width, height}.", + )); + }; + if [expected.x, expected.y, expected.width, expected.height] + .iter() + .any(|value| !value.is_finite() || value.abs() > 100_000.0) + || expected.width < 0.0 + || expected.height < 0.0 + { + return Err(invalid( + "bounds expected values must be finite px, width and height nonnegative.", + )); + } + } + "interaction" => { + // Without a subject it sweeps every visible link and button. + if check.container.is_some() { + return Err(invalid("An interaction check takes no container.")); + } + if !matches!(check.state.as_deref(), Some("hover" | "focus")) { + return Err(invalid("An interaction check needs state hover or focus.")); + } + } + "capture" => { + needs(true, false)?; + let Some(reference) = &check.reference else { + return Err(invalid( + "A capture check needs reference {scope, artifactId, assetId}.", + )); + }; + if !matches!(reference.scope.as_str(), "section" | "asset") { + return Err(invalid("capture reference.scope must be section or asset.")); + } + bounded_text("reference.artifactId", &reference.artifact_id)?; + bounded_text("reference.assetId", &reference.asset_id)?; + if reference + .threshold + .is_some_and(|value| !(0.0..=1.0).contains(&value)) + { + return Err(invalid("reference.threshold must be between 0 and 1.")); + } + } "dimensions" => { needs(true, false)?; if check.tolerance_px.is_none() @@ -546,6 +696,18 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { } other => return Err(invalid(format!("Unknown check kind {other}."))), } + for (field, present, kind) in [ + ("expected", check.expected.is_some(), "bounds"), + ("state", check.state.is_some(), "interaction"), + ("reference", check.reference.is_some(), "capture"), + ] { + if present && check.kind != kind { + return Err(invalid(format!("{field} is only valid for {kind} checks."))); + } + } + if let Some(node_id) = &check.node_id { + bounded_text("checks[].nodeId", node_id)?; + } if check.kind != "dimensions" && !check.figma_dimensions.is_empty() { return Err(invalid( "figmaDimensions is only valid for dimensions checks.", @@ -670,17 +832,129 @@ async fn dimension_references(input: &PlanInput, artifacts: &ArtifactStore) -> V } pub(crate) async fn create_plan( - input: PlanInput, + mut input: PlanInput, policy: &OutputPolicy, output_path: Option<&str>, template_path: Option<&str>, artifacts: &ArtifactStore, ) -> Result { + order_checks(&mut input); validate_plan(&input)?; let references = dimension_references(&input, artifacts).await; write_plan(input, policy, output_path, template_path, references) } +/// One screen at one width by what was checked: health (renders, no +/// overflow, no clipped text), geometry (size and position), interaction +/// (states and view transitions), and the three visual levels, which stay +/// "to compare" until devup_visual_compare results come back through +/// devup_screen_plan status. +fn columns( + screen: &ScreenInput, + viewport: &ViewportInput, + judged: &[(&str, &CheckInput)], + row: &Value, +) -> Value { + let fold = |kinds: &[&str]| { + let statuses = judged + .iter() + .filter(|(_, check)| kinds.contains(&check.kind.as_str())) + .map(|(status, _)| *status) + .collect::>(); + if statuses.is_empty() { + "-" + } else if statuses.contains(&"fail") { + "fail" + } else if statuses.iter().all(|status| *status == "pass") { + "pass" + } else if statuses.contains(&"inconclusive") { + "inconclusive" + } else { + "not-run" + } + }; + let unreached = row["reasons"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .any(|reason| reason.starts_with("page error") || reason.starts_with("screen not reached")); + let health = if unreached { + "fail" + } else { + fold(&["page-health", "no-horizontal-overflow", "no-clipped-text"]) + }; + let captures = |scope: &str| { + let count = judged + .iter() + .filter(|(status, check)| { + *status == "pass" + && check + .reference + .as_ref() + .is_some_and(|reference| reference.scope == scope) + }) + .count(); + let missing = judged + .iter() + .filter(|(status, check)| { + *status != "pass" + && check + .reference + .as_ref() + .is_some_and(|reference| reference.scope == scope) + }) + .count(); + match (count, missing) { + (0, 0) => json!("-"), + (_, 0) => json!(format!("{count} to compare")), + _ => json!(format!("{count} to compare, {missing} not captured")), + } + }; + let drawn = screen + .figma + .as_ref() + .is_some_and(|figma| figma.drawn_widths.contains(&viewport.width)); + json!({ + "health": health, + "geometry": fold(&["bounds", "dimensions", "contained", "aligned", "spans", "visible"]), + "interaction": fold(&["interaction", "view-transition"]), + "page": if drawn { json!(if row["screenshot"].is_string() { "1 to compare" } else { "not captured" }) } else { json!("not drawn") }, + "section": captures("section"), + "asset": captures("asset"), + }) +} + +/// A check limited to some widths does not exist at the others. +fn applies(check: &CheckInput, width: u32) -> bool { + check.widths.is_empty() || check.widths.contains(&width) +} + +fn planned_items(plan: &PlanInput) -> usize { + plan.viewports + .iter() + .map(|viewport| { + plan.screens + .iter() + .flat_map(|screen| &screen.checks) + .filter(|check| applies(check, viewport.width)) + .count() + }) + .sum() +} + +/// A view-transition check clicks away from its screen, so every one moves +/// behind the screen's other checks, keeping their order otherwise. The +/// executor also returns to the screen's URL after it; together a plan +/// cannot measure the wrong page whatever order it was written in. +pub(crate) fn order_checks(input: &mut PlanInput) { + for screen in &mut input.screens { + screen + .checks + .sort_by_key(|check| check.kind == "view-transition"); + } +} + fn write_plan( input: PlanInput, policy: &OutputPolicy, @@ -695,7 +969,6 @@ fn write_plan( )); } let id = plan_id(&input)?; - let checks = input.screens.iter().map(|s| s.checks.len()).sum::(); let references = input .screens .iter() @@ -720,7 +993,7 @@ fn write_plan( "expected": { "runs": input.viewports.len(), "screensPerRun": input.screens.len(), - "checkItems": checks * input.viewports.len(), + "checkItems": planned_items(&input), "dimensionRows": input.viewports.len() * input.screens.iter().map(|s| s.checks.iter().filter(|c| c.kind == "dimensions").count()).sum::(), }, "limits": {"dimensionRows": MAX_DIMENSION_ROWS, "dimensionRowsOmitted": 0}, @@ -747,6 +1020,11 @@ fn write_plan( "checks.mjs", include_bytes!("../../../../harness/runtime-audit/checks.mjs").as_slice(), ), + ( + "strip-figma-node-ids.mjs", + include_bytes!("../../../../harness/runtime-audit/strip-figma-node-ids.mjs") + .as_slice(), + ), ( "package.json", include_bytes!("../../../../harness/runtime-audit/package.json").as_slice(), @@ -905,6 +1183,10 @@ fn ingest_references( let mut environment_invalid = false; let mut visual_comparisons = Vec::new(); let mut dimension_rows = Vec::new(); + let mut screen_rows = Vec::new(); + let mut findings = Vec::new(); + let mut missing_nodes = Vec::new(); + let mut links = BTreeMap::>::new(); for viewport in &plan.viewports { let matching_runs = report["runs"] .as_array() @@ -944,6 +1226,7 @@ fn ingest_references( let mut screenshots_verified = 0; let mut screenshot_issues = Vec::new(); let mut drawn_width_shots = Vec::new(); + let mut captures = Vec::new(); for screen in &plan.screens { if run.is_some_and(|run| { run["screens"] @@ -965,7 +1248,25 @@ fn ingest_references( .iter() .find(|candidate| candidate["id"] == screen.id.as_str()) }); + if let (Some(route), Some(found)) = ( + screen.route_path.as_ref(), + reported.and_then(|s| s["links"].as_array()), + ) { + links.entry(route.clone()).or_default().extend( + found + .iter() + .filter_map(Value::as_str) + .filter(|href| href.starts_with('/') && href.len() <= MAX_TEXT) + .take(200) + .map(str::to_owned), + ); + } + let mut outcomes = Vec::new(); + let mut judged = Vec::new(); for (index, check) in screen.checks.iter().enumerate() { + if !applies(check, viewport.width) { + continue; + } if reported.is_some_and(|s| { s["checks"] .as_array() @@ -1027,10 +1328,92 @@ fn ingest_references( "measured": dimension.as_ref().map(|row| &row["domCssPx"]).or_else(|| item.map(|item| &item["measured"])), })); } + outcomes.push(( + status, + check.kind.as_str(), + dimension + .as_ref() + .map(|row| &row["reason"]) + .or_else(|| item.map(|item| &item["reason"])) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + )); + let reason = dimension + .as_ref() + .map(|row| &row["reason"]) + .or_else(|| item.map(|item| &item["reason"])) + .and_then(Value::as_str) + .unwrap_or_default(); + judged.push((status, check)); + if status == "fail" { + findings.push(finding( + screen, + viewport, + Some((index, check)), + failure_class(&check.kind, reason), + reason, + dimension + .as_ref() + .map(|row| &row["domCssPx"]) + .or_else(|| item.map(|item| &item["measured"])) + .unwrap_or(&Value::Null), + reported.and_then(|s| s["screenshot"]["path"].as_str()), + )); + } + if status == "inconclusive" + && reason.contains("matched no element") + && let Some(node_id) = &check.node_id + { + missing_nodes.push(json!({"screen": screen.id, "width": viewport.width, + "nodeId": node_id, "check": index, "kind": check.kind})); + } + if check.kind == "capture" + && status == "pass" + && let (Some(reference), Some(shot)) = ( + &check.reference, + item.map(|item| &item["screenshot"]) + .filter(|shot| shot.is_object()), + ) + { + match verify_capture(policy, &report_dir, shot, None) { + Ok(verified) => captures.push((screen, index, check, reference, verified)), + Err(Integrity::Tampered(issue)) => { + integrity_problems.push(json!({"screen": screen.id, "issue": issue})); + } + Err(Integrity::Unverified(issue)) => { + screenshot_issues.push(json!({"screen": screen.id, "issue": issue})); + } + } + } if let Some(row) = dimension { dimension_rows.push(row); } } + let mut row = screen_row( + screen, + viewport, + run.is_some(), + reported, + &environment_errors, + &outcomes, + ); + row["columns"] = columns(screen, viewport, &judged, &row); + // A screen that never rendered fails as a runtime error, not as + // the checks that could not measure it. + if row["status"] == "fail" && !outcomes.iter().any(|(status, _, _)| *status == "fail") { + let reason = array_text(&row["reasons"]); + findings.push(finding( + screen, + viewport, + None, + "runtime-error", + &reason, + &Value::Null, + None, + )); + } + screen_rows.push(row); let Some(shot) = reported .map(|screen| &screen["screenshot"]) .filter(|shot| shot.is_object()) @@ -1076,6 +1459,11 @@ fn ingest_references( screen, node_id, viewport, shot, &report, run_valid, )); } + for (screen, index, check, reference, shot) in &captures { + visual_comparisons.push(capture_comparison( + screen, *index, check, reference, viewport, shot, + )); + } runs.push(json!({ "viewport": viewport, "reported": run.is_some(), @@ -1090,7 +1478,7 @@ fn ingest_references( json!({"artifacts": integrity_problems}), )); } - let planned = plan.viewports.len() * plan.screens.iter().map(|s| s.checks.len()).sum::(); + let planned = planned_items(&plan); let count = |status: &str| counts.get(status).copied().unwrap_or(0); let verdict = if count("fail") > 0 && !environment_invalid { "fail" @@ -1134,6 +1522,10 @@ fn ingest_references( "runs": runs, "figmaReferences": figma, "visualComparisons": visual_comparisons, + "findings": findings, + "missingNodes": missing_nodes, + "links": links, + "screenTable": screen_rows, "widthTable": plan.viewports.iter().map(|viewport| json!({ "width": viewport.width, "height": viewport.height, "deviceScaleFactor": viewport.device_scale_factor, @@ -1154,6 +1546,7 @@ fn ingest_references( ], "executorDeclared": [ "element geometry, locator counts, visibility and non-dimension check outcomes", + "the same-origin links found on each page", "page, console and network errors", "timing, browser and environment versions", ], @@ -1162,6 +1555,96 @@ fn ingest_references( })) } +/// One screen at one width, folded from its checks, its own record and the +/// environment errors the run could pin on it. This is the row a person reads +/// to learn whether `/about` works at 390; `failures` and `runs` say the same +/// things but spread across three lists. +/// +/// A page error is attributed only when its message names this screen's route +/// (`navigation-failed` does). An uncaught script error carries no route, so +/// it leaves every screen at that width inconclusive rather than failing the +/// one that may not have thrown it. +fn screen_row( + screen: &ScreenInput, + viewport: &ViewportInput, + run_reported: bool, + reported: Option<&Value>, + environment_errors: &[Value], + outcomes: &[(&str, &str, String)], +) -> Value { + let message = |error: &Value| error["message"].as_str().unwrap_or_default().to_owned(); + let own_navigation = environment_errors + .iter() + .filter(|error| { + error["kind"] == "navigation-failed" + && screen.route_path.as_deref().is_some_and(|route| { + error["message"] + .as_str() + .is_some_and(|text| text.starts_with(&format!("{route}:"))) + }) + }) + .map(|error| format!("page error: {}", message(error))) + .collect::>(); + let unattributed = environment_errors + .iter() + .filter(|error| error["kind"] == "page-error") + .map(|error| format!("page error during this width's run: {}", message(error))) + .collect::>(); + let failed_checks = outcomes + .iter() + .filter(|(status, _, _)| *status == "fail") + .map(|(_, kind, reason)| match *kind { + "no-horizontal-overflow" => format!("horizontal overflow: {reason}"), + "view-transition" => format!("view transition: {reason}"), + kind => format!("{kind}: {reason}"), + }) + .collect::>(); + let record_status = reported.and_then(|screen| screen["status"].as_str()); + let record_reason = reported + .and_then(|screen| screen["reason"].as_str()) + .unwrap_or_default(); + let (status, reasons) = if !run_reported || reported.is_none() { + ( + "not-run", + vec!["no report item for this screen at this width".to_owned()], + ) + } else if !own_navigation.is_empty() { + ("fail", own_navigation) + } else if record_status == Some("failed") { + ("fail", vec![format!("screen not reached: {record_reason}")]) + } else if !failed_checks.is_empty() { + ("fail", failed_checks) + } else if record_status == Some("skipped") { + ("not-run", vec![format!("skipped: {record_reason}")]) + } else if !unattributed.is_empty() { + ("inconclusive", unattributed) + } else if outcomes.is_empty() { + ( + "inconclusive", + vec!["reached, but the plan names no checks for this screen".to_owned()], + ) + } else if outcomes.iter().all(|(status, _, _)| *status == "pass") { + ("pass", Vec::new()) + } else { + ( + "inconclusive", + outcomes + .iter() + .filter(|(status, _, _)| *status != "pass") + .map(|(status, kind, reason)| format!("{kind} {status}: {reason}")) + .collect(), + ) + }; + json!({ + "screen": screen.id, + "routePath": screen.route_path, + "width": viewport.width, + "status": status, + "reasons": reasons, + "screenshot": reported.and_then(|screen| screen["screenshot"]["path"].as_str()), + }) +} + #[allow(clippy::too_many_arguments)] fn dimension_row( screen: &ScreenInput, @@ -1285,6 +1768,17 @@ fn verify_screenshot( report_dir: &Path, shot: &Value, viewport: &ViewportInput, +) -> Result { + verify_capture(policy, report_dir, shot, Some(viewport)) +} + +/// An element capture has the element's size, not the viewport's; only its +/// bytes and PNG decoding are checked. +fn verify_capture( + policy: &OutputPolicy, + report_dir: &Path, + shot: &Value, + viewport: Option<&ViewportInput>, ) -> Result { let path = shot["path"] .as_str() @@ -1319,12 +1813,14 @@ fn verify_screenshot( "{path}: not a decodable PNG within 8192px per side" )) })?; - let expected = (f64::from(viewport.width) * viewport.device_scale_factor).round() as u32; - let minimum = (f64::from(viewport.height) * viewport.device_scale_factor).round() as u32; - if width != expected || height < minimum { - return Err(Integrity::Unverified(format!( - "{path}: {width}x{height} is not {expected} wide and at least {minimum} tall; the capture does not show this viewport" - ))); + if let Some(viewport) = viewport { + let expected = (f64::from(viewport.width) * viewport.device_scale_factor).round() as u32; + let minimum = (f64::from(viewport.height) * viewport.device_scale_factor).round() as u32; + if width != expected || height < minimum { + return Err(Integrity::Unverified(format!( + "{path}: {width}x{height} is not {expected} wide and at least {minimum} tall; the capture does not show this viewport" + ))); + } } Ok(VerifiedShot { path: target.display_path().display().to_string(), @@ -1334,6 +1830,174 @@ fn verify_screenshot( }) } +/// The one place a finding's identity is made, so a later run of the same +/// check at the same width finds its earlier attempts. +fn comparison_id(screen: &ScreenInput, kind: &str, subject: &str, width: u32) -> String { + format!("{}|{kind}|{subject}|{width}", screen.id) +} + +fn diff_path(actual: &str, id: &str) -> String { + let directory = Path::new(actual).parent().unwrap_or(Path::new("")); + let name = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '_' + } + }) + .collect::(); + directory + .join("diff") + .join(format!("{name}.png")) + .display() + .to_string() +} + +fn array_text(value: &Value) -> String { + value + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>() + .join("; ") +} + +/// What kind of problem a failed check is, so a fix starts in the right +/// place: a page that does not render, content wider than its box, a box in +/// the wrong place or of the wrong size, a state or a transition missing. +pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { + match kind { + "page-health" => "runtime-error", + "no-horizontal-overflow" | "no-clipped-text" => "overflow", + "bounds" if reason.starts_with("position") => "layout-position", + "bounds" | "dimensions" | "visible" => "layout-size", + "contained" | "aligned" | "spans" => "layout-position", + "interaction" => "interaction", + "view-transition" => "view-transition", + _ => "layout-position", + } +} + +/// A concrete first step for each class. The measured evidence names the +/// element where the executor reported one. +pub(crate) fn fix_hint(class: &str, kind: &str, measured: &Value) -> String { + match class { + "runtime-error" => "Open the URL at this width and read the first page or console error: an error-boundary text (\"This page couldn't load\", \"Application error\") means a component threw while rendering or a data fetch failed. Fix the throwing component or give the data a loading and error state, then re-run.".to_owned(), + "overflow" if kind == "no-clipped-text" => "Text is cut off by a box with overflow hidden: let the box grow (drop the fixed height or width the Figma frame had), let the text wrap, or, when truncation is designed, use textOverflow ellipsis or lineClamp.".to_owned(), + "overflow" => format!( + "Content is wider than the viewport{}: give the element maxW=\"100%\", let its row wrap (flexWrap=\"wrap\") or shrink (minW={{0}}), and turn a fixed Figma width into a responsive value at this width.", + measured["widest"].as_str().map(|widest| format!(" (widest: {widest})")).unwrap_or_default() + ), + "layout-position" | "layout-size" if kind == "bounds" => "Layout is exact at a drawn width: a box off by more than half a pixel is code drift (the code no longer writes what the export wrote) or a converter bug (it does, and renders wrong). devup_screen_plan status tells which; never hand-tune the value.".to_owned(), + "layout-position" => "The element sits in the wrong place relative to its parent: compare the parent's gap, padding and alignment (justifyContent, alignItems) with the Figma frame, check the order of its children and any absolute offsets.".to_owned(), + "layout-size" => "The element has the wrong size: compare its width and height props with the Figma sizing (fixed, fill or hug), make fill stretch at widths that were not drawn, and check an image's aspect ratio and min/max sizes.".to_owned(), + "code-drift" => "Restore the values the export wrote for this element (the finding lists what is missing and what was added); layout is exact, so do not tune it by hand - re-export the node if the design changed.".to_owned(), + "converter-bug" => "The code still equals the export and the browser box differs from Figma: a devup-mcp converter bug. File the repro; a local override may finish the site only with a comment naming the repro file. Retrying does not help.".to_owned(), + "asset-mismatch" => "The rendered asset differs from the Figma node's render. Likely causes, in order: a CSS mask on a multi-colour SVG paints one solid shape (render it with instead; masks are for single-colour icons only); the wrong file at the src path; a crop or objectFit (cover vs contain) or a missing viewBox. Open the diff PNG to see which.".to_owned(), + "section-visual" => "The section differs from its Figma render: open the diff PNG, find the highlighted area and fix the elements there; the usual causes are font size, weight or line height, a missing background or border, and spacing.".to_owned(), + "page-visual" => "The page differs from the frame: fix the section and asset comparisons first, they localise the problem; a difference only the page shows is usually the page background, its padding or the spacing between sections.".to_owned(), + "interaction" => "A link or button shows no hover or focus change: give it the interaction recipe for its role from the plan defaults (theme tokens only, at most two properties, the shared 150-250ms transition) and a visible _focusVisible outline.".to_owned(), + "view-transition" => "No view transition ran on this navigation: wrap the routed content in React's (or add @view-transition { navigation: auto } for a cross-document transition) and navigate with the framework's Link so it is a client navigation.".to_owned(), + _ => "Re-run the check and read its reason.".to_owned(), + } +} + +fn finding( + screen: &ScreenInput, + viewport: &ViewportInput, + check: Option<(usize, &CheckInput)>, + class: &str, + reason: &str, + measured: &Value, + screenshot: Option<&str>, +) -> Value { + let kind = check.map_or("screen", |(_, check)| check.kind.as_str()); + let subject = check + .and_then(|(index, check)| { + check + .node_id + .clone() + .or_else(|| check.label.clone()) + .or_else(|| Some(format!("#{index}"))) + }) + .unwrap_or_else(|| "page".to_owned()); + json!({ + "id": comparison_id(screen, kind, &subject, viewport.width), + "class": class, + "screen": screen.id, + "routePath": screen.route_path, + "width": viewport.width, + "check": check.map(|(index, _)| index), + "kind": kind, + "label": check.and_then(|(_, check)| check.label.clone()), + "nodeId": check.and_then(|(_, check)| check.node_id.clone()), + "codeFile": screen.component_path, + "reason": reason, + "fixHint": fix_hint(class, kind, measured), + "evidence": {"screenshot": screenshot, "measured": measured}, + }) +} + +/// The `devup_visual_compare` call for one element capture against the +/// Figma node's own native render. +fn capture_comparison( + screen: &ScreenInput, + index: usize, + check: &CheckInput, + reference: &CaptureReferenceInput, + viewport: &ViewportInput, + shot: &VerifiedShot, +) -> Value { + let class = if reference.scope == "asset" { + "asset-mismatch" + } else { + "section-visual" + }; + let subject = check.node_id.clone().unwrap_or_else(|| format!("#{index}")); + let id = comparison_id(screen, &reference.scope, &subject, viewport.width); + let threshold = reference + .threshold + .unwrap_or(if reference.scope == "asset" { + ASSET_THRESHOLD + } else { + SECTION_THRESHOLD + }); + let mut blockers = Vec::new(); + if (viewport.device_scale_factor - 1.0).abs() > f64::EPSILON { + blockers.push(format!( + "Captured at deviceScaleFactor {}; the native render is at scale 1.", + viewport.device_scale_factor + )); + } + json!({ + "id": id, + "class": class, + "screen": screen.id, + "routePath": screen.route_path, + "nodeId": check.node_id, + "label": check.label, + "check": index, + "codeFile": screen.component_path, + "viewport": viewport, + "screenshot": {"path": shot.path, "sha256": shot.sha256, "width": shot.width, "height": shot.height}, + "call": { + "tool": "devup_visual_compare", + "arguments": { + "actual": shot.path, + "reference": {"artifactId": reference.artifact_id, "assetId": reference.asset_id}, + "threshold": threshold, + "diffPath": diff_path(&shot.path, &id), + }, + }, + "ifItFails": {"class": class, "fixHint": fix_hint(class, "capture", &Value::Null)}, + "blockers": blockers, + "note": "A size mismatch with the native render is reported as dimensions, not pixels: fix the element's size (its bounds check says by how much) and compare again.", + }) +} + /// The `devup_visual_compare` call for one screenshot taken at a width its /// Figma frame was drawn at, with every field the audit itself knows filled /// in, the ones only the consumer can state named, and what would still make @@ -1402,19 +2066,40 @@ fn visual_comparison( .to_owned(), ); } + let frame = screen + .figma + .iter() + .flat_map(|figma| &figma.frames) + .find(|frame| frame.width == viewport.width); + let node_id = frame.map_or(node_id, |frame| frame.node_id.as_str()); + let artifact = frame.and_then(|frame| frame.artifact_id.clone()); + if artifact.is_some() { + must_add.retain(|field| *field != "reference.artifactId"); + } + let id = comparison_id(screen, "page", node_id, viewport.width); + let threshold = frame + .and_then(|frame| frame.threshold) + .unwrap_or(PAGE_THRESHOLD); json!({ + "id": id, + "class": "page-visual", "screen": screen.id, + "routePath": screen.route_path, "nodeId": node_id, + "codeFile": screen.component_path, "viewport": viewport, "screenshot": {"path": shot.path, "sha256": shot.sha256, "width": shot.width, "height": shot.height}, "call": { "tool": "devup_visual_compare", "arguments": { "actual": shot.path, - "reference": {"artifactId": format!("")}, + "reference": {"artifactId": artifact.unwrap_or_else(|| format!(""))}, + "threshold": threshold, + "diffPath": diff_path(&shot.path, &id), "environment": environment, }, }, + "ifItFails": {"class": "page-visual", "fixHint": fix_hint("page-visual", "", &Value::Null)}, "consumerMustAdd": must_add, "blockers": blockers, "note": "Filled from the plan, the verified screenshot and the executor's report. Export the frame with outputs [\"referencePng\"] first and put its artifactId in reference; devup_visual_compare pins that reference itself. The fields in consumerMustAdd are the consumer's to state.", @@ -1484,7 +2169,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 4); + assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 5); assert_eq!(planned["template"]["executed"], false); assert_eq!( std::fs::read(dir.join("runner/run.mjs")).unwrap(), @@ -1753,6 +2438,102 @@ mod tests { (document["planId"].as_str().unwrap().to_owned(), bytes) } + #[test] + fn a_view_transition_check_needs_the_link_to_follow() { + let mut plan = plan_input(); + plan.screens[0].checks.push( + serde_json::from_value(json!({"kind": "view-transition", "subject": {"testId": "go"}})) + .unwrap(), + ); + assert!(validate_plan(&plan).is_ok()); + plan.screens[0] + .checks + .push(serde_json::from_value(json!({"kind": "view-transition"})).unwrap()); + let error = validate_plan(&plan).unwrap_err(); + assert!( + error + .message + .contains("view-transition check takes a subject"), + "{}", + error.message + ); + } + + #[test] + fn the_new_check_kinds_take_what_they_measure() { + let ok = |check: Value| { + let mut plan = plan_input(); + plan.screens[0] + .checks + .push(serde_json::from_value(check).unwrap()); + validate_plan(&plan).map_err(|error| error.message) + }; + let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); + assert!(ok(json!({"kind": "page-health"})).is_ok()); + assert!(ok(json!({"kind": "no-clipped-text"})).is_ok()); + assert!(ok(json!({"kind": "interaction", "state": "hover"})).is_ok()); + assert!( + ok( + json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1"), + "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "widths": [1440]}) + ) + .is_ok() + ); + assert!( + ok(json!({"kind": "capture", "subject": node("1:2"), + "reference": {"scope": "asset", "artifactId": "a", "assetId": "b"}})) + .is_ok() + ); + assert!( + ok(json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1")})) + .unwrap_err() + .contains("expected") + ); + assert!( + ok(json!({"kind": "interaction"})) + .unwrap_err() + .contains("hover or focus") + ); + assert!(ok(json!({"kind": "hidden", "subject": node("1:9")})).is_ok()); + assert!( + ok( + json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1"), + "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "tolerancePx": 4}) + ) + .unwrap_err() + .contains("exact"), + "geometry takes no tolerance" + ); + assert!( + ok(json!({"kind": "capture", "subject": node("1:2")})) + .unwrap_err() + .contains("reference") + ); + assert!( + ok(json!({"kind": "visible", "subject": node("1:2"), "state": "hover"})) + .unwrap_err() + .contains("only valid for interaction") + ); + } + + #[test] + fn view_transition_checks_move_behind_the_rest_of_their_screen() { + let mut plan = plan_input(); + plan.screens[0].checks = serde_json::from_value(json!([ + {"kind": "view-transition", "subject": {"testId": "go"}, "label": "vt"}, + {"kind": "page-health", "label": "health"}, + {"kind": "no-horizontal-overflow", "label": "overflow"}, + ])) + .unwrap(); + order_checks(&mut plan); + let labels = plan.screens[0] + .checks + .iter() + .map(|check| check.label.clone().unwrap()) + .collect::>(); + assert_eq!(labels, ["health", "overflow", "vt"]); + } + #[test] fn a_plan_is_identified_by_its_content_and_refuses_what_is_not_declarative() { let first = plan_id(&plan_input()).unwrap(); @@ -1837,6 +2618,75 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// The screen table is what `devup_screen_plan status` reads: one row per + /// screen and width, a failed overflow check named as overflow, a failed + /// navigation pinned on its own route, and a width with no run not-run. + #[test] + fn the_screen_table_folds_checks_and_page_errors_per_width() { + let (dir, policy) = root(); + let (id, plan_file) = write_plan(&dir, &policy); + let mut report = report_for(&dir, &plan_file, &id, "pass", 800); + report["runs"][0]["screens"][0]["checks"][0]["status"] = json!("fail"); + report["runs"][0]["screens"][0]["checks"][0]["reason"] = json!("content reaches 412px"); + report["runs"][1]["environmentErrors"] = + json!([{"kind": "navigation-failed", "message": "/today: HTTP 500"}]); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + let row = |screen: &str, width: u32| { + result["screenTable"] + .as_array() + .unwrap() + .iter() + .find(|row| row["screen"] == screen && row["width"] == width) + .cloned() + .unwrap() + }; + assert_eq!(row("today", 360)["status"], "fail"); + assert_eq!( + row("today", 360)["reasons"][0], + "horizontal overflow: content reaches 412px" + ); + assert_eq!(row("today", 1200)["status"], "fail"); + assert_eq!( + row("today", 1200)["reasons"][0], + "page error: /today: HTTP 500" + ); + assert_eq!(row("next", 360)["status"], "pass"); + assert_eq!(row("next", 360)["screenshot"], "360-next.png"); + + report["runs"].as_array_mut().unwrap().truncate(1); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + let missing = result["screenTable"] + .as_array() + .unwrap() + .iter() + .filter(|row| row["width"] == 1200) + .collect::>(); + assert_eq!(missing.len(), 2); + assert!(missing.iter().all(|row| row["status"] == "not-run")); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + /// Only the screen's drawn width has a visual original, and its verified /// screenshot comes back as a ready comparison: what the audit knows is /// filled in, what only the consumer knows is named. @@ -1891,6 +2741,154 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// Every failed check comes back as a finding of one class with the + /// Figma node, the code file and a fix hint; a capture comes back as a + /// ready asset comparison against the node's own render, and checks + /// limited to a width are neither planned nor not-run elsewhere. + #[test] + fn failures_are_classified_and_captures_become_asset_comparisons() { + let (dir, policy) = root(); + let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); + let input: PlanInput = serde_json::from_value(json!({ + "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], + "viewports": [{"width": 1440, "height": 900}, {"width": 390, "height": 900}], + "screens": [{"id": "home", "routePath": "/", "componentPath": "src/app/page.tsx", + "figma": {"nodeId": "1:1", "drawnWidths": [1440], "frames": [{"width": 1440, "nodeId": "1:1", "artifactId": "art-1"}]}, + "checks": [ + {"kind": "page-health"}, + {"kind": "bounds", "subject": node("1:3"), "container": node("1:2"), "nodeId": "1:3", + "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "widths": [1440]}, + {"kind": "interaction", "state": "hover", "widths": [1440]}, + {"kind": "capture", "subject": node("1:4"), "nodeId": "1:4", "widths": [1440], + "reference": {"scope": "asset", "artifactId": "art-1", "assetId": "icon"}}, + ]}], + })) + .unwrap(); + let written = plan( + input, + &policy, + Some(&dir.join("plan.json").to_string_lossy()), + ) + .unwrap(); + assert_eq!( + written["expected"]["checkItems"], 5, + "four at 1440, health alone at 390" + ); + let plan_bytes = std::fs::read(dir.join("plan.json")).unwrap(); + let page = png(1440, 900); + let icon = png(24, 24); + std::fs::write(dir.join("page.png"), &page).unwrap(); + std::fs::write(dir.join("icon.png"), &icon).unwrap(); + let shot = |bytes: &[u8], path: &str| json!({"path": path, "sha256": sha256(bytes), "bytes": bytes.len()}); + let report = json!({"schemaVersion": 1, "kind": REPORT_KIND, "planId": written["planId"], + "planSha256": sha256(&plan_bytes), "status": "completed", "runs": [ + {"viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1}, "environmentErrors": [], + "screens": [{"id": "home", "status": "completed", "screenshot": shot(&page, "page.png"), "checks": [ + {"index": 0, "status": "pass", "reason": "ok", "measured": {}}, + {"index": 1, "status": "fail", "reason": "position: y off by 60px", "measured": {}}, + {"index": 2, "status": "fail", "reason": "hover changes nothing on 1 of 2", "measured": {}}, + {"index": 3, "status": "pass", "reason": "captured", "measured": {}, "screenshot": shot(&icon, "icon.png")}, + ]}]}, + {"viewport": {"width": 390, "height": 900, "deviceScaleFactor": 1}, "environmentErrors": [], + "screens": [{"id": "home", "status": "completed", "checks": [ + {"index": 0, "status": "fail", "reason": "the page shows \"Application error\"", "measured": {}}, + ]}]}, + ]}); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + assert_eq!(result["summary"]["notRun"], 0, "{result}"); + let findings = result["findings"].as_array().unwrap(); + let classes = findings + .iter() + .map(|finding| { + ( + finding["class"].as_str().unwrap(), + finding["width"].as_u64().unwrap(), + ) + }) + .collect::>(); + assert_eq!( + classes, + [ + ("layout-position", 1440), + ("interaction", 1440), + ("runtime-error", 390) + ] + ); + let position = &findings[0]; + assert_eq!(position["nodeId"], "1:3"); + assert_eq!(position["codeFile"], "src/app/page.tsx"); + assert_eq!(position["id"], "home|bounds|1:3|1440"); + assert!( + position["fixHint"] + .as_str() + .unwrap() + .contains("Layout is exact") + ); + assert!( + findings[2]["fixHint"] + .as_str() + .unwrap() + .contains("error-boundary") + ); + + let comparisons = result["visualComparisons"].as_array().unwrap(); + let page_call = comparisons + .iter() + .find(|c| c["class"] == "page-visual") + .unwrap(); + assert_eq!( + page_call["call"]["arguments"]["reference"], + json!({"artifactId": "art-1"}) + ); + assert_eq!(page_call["call"]["arguments"]["threshold"], PAGE_THRESHOLD); + let asset_call = comparisons + .iter() + .find(|c| c["class"] == "asset-mismatch") + .unwrap(); + assert_eq!(asset_call["id"], "home|asset|1:4|1440"); + assert_eq!( + asset_call["call"]["arguments"]["reference"], + json!({"artifactId": "art-1", "assetId": "icon"}) + ); + assert_eq!( + asset_call["call"]["arguments"]["threshold"], + ASSET_THRESHOLD + ); + assert!( + asset_call["call"]["arguments"]["diffPath"] + .as_str() + .unwrap() + .ends_with("home_asset_1_4_1440.png") + ); + assert!( + asset_call["ifItFails"]["fixHint"] + .as_str() + .unwrap() + .contains("multi-colour SVG") + ); + let wide = result["screenTable"] + .as_array() + .unwrap() + .iter() + .find(|row| row["width"] == 1440) + .unwrap(); + assert_eq!(wide["columns"]["geometry"], "fail"); + assert_eq!(wide["columns"]["asset"], "1 to compare"); + assert_eq!(wide["columns"]["page"], "1 to compare"); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_failure_fails_missing_items_are_inconclusive_and_edits_are_rejected() { let (dir, policy) = root(); diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs new file mode 100644 index 00000000..bd230fd4 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -0,0 +1,5730 @@ +//! `devup_screen_plan`: which Figma screen becomes which URL, decided and +//! confirmed before any page is written. +//! +//! An agent handed "implement this design" used to export and write pages +//! straight away. Afterwards nobody could say which Figma screen had become +//! which URL, so nobody could say what had been built or whether it worked: +//! on one site the pages rendered "This page couldn't load" at some widths and +//! were cut off on the right at others, and there was no URL-by-width table on +//! which anyone could have noticed. +//! +//! So the order is fixed. `draft` maps every screen to a route from evidence +//! only - an existing page built from the frame, a nav label already bound to +//! an href, an explicit `navigationTargets` entry or the project's config - +//! and turns everything it cannot decide into questions shaped for the +//! runtime's question tool, split into rounds that are all asked up front, back +//! to back. A guess from a layer name is never silent: it is marked +//! `name-match` and put to the user in the same rounds. `confirm` takes every +//! answer that applies at once and refuses to proceed with any missing. After +//! it nothing is asked: implementation records what it decided alone with +//! `assume`. `status` reads the confirmed plan against the routes on disk and a +//! runtime audit report, one row per URL and width, where a width nobody ran is +//! `not-run` and never `pass`. +//! +//! Nothing here reaches Figma or runs a browser. The screen list comes from +//! `devup_figma_explore` (or the caller, from an earlier explore), the routes +//! from `devup_project_context`, the links from the navigation parser and the +//! widths from `devup_runtime_audit`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use devup_mcp_figma::{DevupError, ErrorCode, FigmaTarget}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use super::artifacts::ArtifactStore; +use super::output::{OutputPolicy, OutputTransaction}; + +pub(super) const PLAN_KIND: &str = "devup-screen-plan"; +const SCHEMA_VERSION: u64 = 1; +/// Where the plan lives, relative to the project root. The export gate reads +/// it from the same place, so the two cannot disagree about which plan holds. +pub(super) const PLAN_FILE: &str = ".devup/screen-plan.json"; +const PLAN_MARKDOWN: &str = ".devup/screen-plan.md"; +pub(super) const ASK_INSTRUCTION: &str = "Ask every question now, before implementing, with your question tool and never in prose. If the tool has no per-call cap (OpenCode `question`), ask the flat `questions` list in one call; each conditional question says when it applies. If it has one (Claude Code AskUserQuestion: 4), ask the questionRounds back to back in consecutive calls with no other work in between, skipping a question whose `when` was not met. Then confirm. After confirm never ask the user anything: take the recommended or most conservative option and record it with action assume."; +/// What implementation does instead of asking. Someone may have walked away +/// from the keyboard three hours ago; a question then stops all the work. +pub(super) const IMPLEMENT_INSTRUCTION: &str = "Implement URL by URL without asking the user anything. devup_figma_export with projectRoot takes each page's route from this plan. Anything new: take the recommended or most conservative option, record it with devup_screen_plan action assume, and keep going. Then run devup_runtime_audit at every width and devup_screen_plan action status."; +/// The optional project file whose entries count as evidence. +pub(super) const CONFIG_FILE: &str = ".devup/screen-plan.config.json"; +/// The question tools this is shaped for cut headers short. OpenCode's has no +/// hard limit; 30 characters stays readable in all of them. +pub(super) const HEADER_LIMIT: usize = 30; +/// Claude Code's AskUserQuestion takes two to four options and adds its own +/// "Other" for a custom answer. Four is the most every runtime accepts. +const MAX_OPTIONS: usize = 4; +/// AskUserQuestion also takes at most four questions per call. +pub(super) const MAX_QUESTIONS_PER_ROUND: usize = 4; +const RECOMMENDED: &str = " (Recommended)"; +const MAX_SCREENS: usize = 100; +const MAX_PLAN_BYTES: usize = 2 * 1024 * 1024; +/// The widths `devup_runtime_audit` plans by default. Without a report they +/// are what `status` lists as not-run, because the failures this tool exists +/// to surface were at widths nobody drew. +const DEFAULT_WIDTHS: [u32; 4] = [1440, 1100, 858, 390]; +const MAX_SCANNED_SOURCES: usize = 400; +const MAX_SOURCE_BYTES: u64 = 512 * 1024; +const SKIP: &str = "Skip this screen"; +const NOT_A_PAGE: &str = "Not a page (part of another screen)"; + +const MODAL_WORDS: &[&str] = &[ + "modal", + "popup", + "pop-up", + "pop up", + "dialog", + "overlay", + "bottom sheet", + "bottomsheet", + "drawer", + "toast", + "팝업", + "모달", + "레이어", +]; +const DEVICE_WORDS: &[&str] = &[ + "pc", "mobile", "mo", "mob", "m", "tablet", "tab", "desktop", "web", "dt", +]; +/// Device words that name a whole section rather than a frame suffix. +const SECTION_DEVICE_WORDS: &[&str] = &["phone", "모바일", "태블릿", "데스크톱", "데스크탑", "웹"]; +const ROOT_WORDS: &[&str] = &["home", "main", "index", "landing", "메인", "홈"]; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ScreenPlanInput { + /// draft: map screens to routes and return every open question. + /// confirm: apply all answers at once. status: URL x width report, the + /// audit findings, the work queue and the final report. assume: after + /// confirm, record a decision implementation made without asking, or + /// take a pending decision. auditPlan: write the runtime-audit plan for + /// every confirmed URL. defer: record a decision the work needs as + /// pending, with its recommended option, and keep working. + #[schemars(extend("enum" = ["draft", "confirm", "status", "assume", "auditPlan", "defer"]))] + pub action: String, + /// The Next App Router project. The plan is written to + /// /.devup/screen-plan.json and .md, under an allowed root. + pub project_root: Option, + /// draft: Figma Section or frame link. Omitted while one Devup Bridge + /// plugin is attached, the Figma selection is the anchor. + #[serde(default)] + pub url: Option, + /// draft: screens already listed by devup_figma_explore or search. When + /// given, Figma is not read again. + #[serde(default)] + pub screens: Vec, + /// draft: explicit Figma node id -> route bindings. The only way to name a + /// route that no page or nav link already shows. + #[serde(default)] + pub navigation_targets: BTreeMap, + /// draft: free text recorded in the plan, never interpreted. + #[serde(default)] + pub notes: Option, + /// confirm/status: the planId draft returned. + #[serde(default)] + pub plan_id: Option, + /// confirm: every question id -> the chosen option label (or labels when + /// multiple), or custom text. + #[serde(default)] + pub answers: BTreeMap, + /// status: the runtime audit plan JSON, together with auditReportPath. + #[serde(default)] + pub audit_plan_path: Option, + /// status: the report.json the runtime audit executor wrote. + #[serde(default)] + pub audit_report_path: Option, + /// draft: re-read Figma instead of reusing a cached exploration. + #[serde(default)] + pub refresh: bool, + /// assume: what was decided during implementation without asking. + #[serde(default)] + pub assumptions: Vec, + /// assume: the outcome of the dependency step. Each pin is also recorded + /// as an assumption. + #[serde(default)] + pub dependency_update: Option, + /// draft: how many questions one question-tool call takes; default 4 + /// (Claude Code AskUserQuestion). A tool with no cap asks the flat + /// `questions` list in one call instead. + #[serde(default)] + #[schemars(range(min = 1, max = 50))] + pub max_questions_per_round: Option, + /// draft: Figma nodes that may be the favicon or share image (a logo, a + /// frame named og or share), beside the screens. + #[serde(default)] + pub assets: Vec, + /// draft: the agent's own questions - anything it would otherwise guess + /// during the job (form target, contact details, external links, legal + /// pages, data source, language) - asked in the same up-front rounds. + #[serde(default)] + pub extra_questions: Vec, + /// auditPlan: where the app is served; default http://localhost:3000. + #[serde(default)] + pub base_url: Option, + /// auditPlan: app name -> base URL, when the plan spans several apps. + #[serde(default)] + pub base_urls: BTreeMap, + /// auditPlan: every devup_figma_export artifactId of this plan's frames + /// (the TSX export, the referencePng export, the native renders). They + /// are searched for each frame's geometry and references. + #[serde(default)] + pub artifacts: Vec, + /// auditPlan: only these URLs; default every confirmed URL. + #[serde(default)] + pub urls: Vec, + /// status: the devup_visual_compare result for each visualComparisons + /// call the last status returned. + #[serde(default)] + pub visual_results: Vec, + /// status, with auditPlanPath and no report: the runner could not be + /// installed or started, and why. That URL's audit is inconclusive; the + /// work goes on. + #[serde(default)] + pub tooling_unavailable: Option, + /// defer: the decision the work needs. + #[serde(default)] + pub decision: Option, +} + +/// A decision the work hit after confirm. It never stops the work: it is +/// recorded with the option the agent recommends, the rest goes on, and it +/// is taken with assume at the end - except a destructive one, which is +/// left unbuilt. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PendingDecisionInput { + /// A short id, e.g. contact-form-target. + pub id: String, + /// What it is about: a URL, a frame id, a file. + pub about: String, + pub question: String, + #[serde(default)] + pub options: Vec, + /// The option to take when the work is otherwise done. + pub recommended: String, + /// It deletes or overwrites something, or reaches outside the project. + #[serde(default)] + pub destructive: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ExtraQuestionInput { + /// Becomes `extra:`. + pub id: String, + /// 30 characters or fewer. + pub header: String, + pub question: String, + /// Two to four, the recommended one first. + pub options: Vec, + #[serde(default)] + pub multiple: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ExtraOptionInput { + pub label: String, + pub description: String, +} + +/// The agent's own questions in the plan's shape, or why one cannot be asked +/// as given. The recommended option is the first, as everywhere else. +fn extra_questions(extra: &[ExtraQuestionInput]) -> Result, DevupError> { + let mut seen = BTreeSet::new(); + extra + .iter() + .map(|asked| { + let id = format!("extra:{}", asked.id.trim().trim_start_matches("extra:")); + if id == "extra:" || !seen.insert(id.clone()) { + return Err(invalid(format!( + "extraQuestions ids must be non-empty and unique; {:?} is not.", + asked.id + ))); + } + if asked.header.trim().is_empty() || asked.header.chars().count() > HEADER_LIMIT { + return Err(invalid(format!( + "{id}: header must be 1 to {HEADER_LIMIT} characters." + ))); + } + if !(2..=MAX_OPTIONS).contains(&asked.options.len()) { + return Err(invalid(format!("{id}: give 2 to {MAX_OPTIONS} options."))); + } + let choices = asked + .options + .iter() + .map(|option| { + let label = strip_recommended(&option.label).to_owned(); + choice( + label.clone(), + option.description.clone(), + json!({"extra": label}), + ) + }) + .collect::>(); + let mut built = question( + id, + asked.header.clone(), + asked.question.clone(), + choices, + "extra", + ); + built["multiple"] = json!(asked.multiple); + Ok(built) + }) + .collect() +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct AssumptionInput { + /// What it is about, e.g. a frame id or a URL. + pub about: String, + /// The option taken: the plan's recommended one or the most conservative. + pub decision: String, + #[serde(default)] + pub reason: Option, +} + +/// The outcome of the dependency step, recorded once it ran. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct DependencyUpdateInput { + /// How many packages the update bumped. + pub bumped: usize, + /// Packages held back because no code fix exists, with the reason. + #[serde(default)] + pub pins: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PinInput { + pub package: String, + pub version: String, + pub reason: String, +} + +/// `.devup/screen-plan.config.json`: how this project's designers name +/// things. Each entry present counts as evidence and replaces the question +/// it would otherwise take. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct PlanConfig { + /// Replaces the built-in modal words (modal, popup, dialog, ...). + #[serde(default)] + pub modal_keywords: Option>, + /// Layer name -> route. + #[serde(default)] + pub route_aliases: BTreeMap, + /// responsive | separate | ask (the default). Applies to several widths + /// in one section; frames in different sections are always asked. + #[serde(default)] + pub width_grouping: Option, + /// Replaces the built-in words that mark pages, sections and frames as + /// not for implementation (trash, archive, draft, copy, ...). + #[serde(default)] + pub exclude_keywords: Option>, + /// Names containing one of these are never excluded by keyword. + #[serde(default)] + pub include_keywords: Option>, + /// Pre-answers for the site questions: the favicon and share image (a + /// path, or figma:), the site name, its production URL, and + /// motion beyond hover (none | reveal). + #[serde(default)] + pub favicon: Option, + #[serde(default)] + pub og_image: Option, + #[serde(default)] + pub site_name: Option, + #[serde(default)] + pub site_url: Option, + #[serde(default)] + pub motion: Option, + /// false for a project that forbids the dependency update step. + #[serde(default)] + pub update_dependencies: Option, + /// vinext | next: pre-answers the migrate-to-vinext question. + #[serde(default)] + pub framework: Option, + /// false opts the project out of view transitions. + #[serde(default)] + pub view_transitions: Option, + /// false stops exports into this project from writing + /// data-figma-node-id on the page, section and asset elements. + #[serde(default)] + pub figma_node_ids: Option, + /// How many fix-and-rerun rounds one audit finding gets before it is + /// recorded as unresolved with its evidence; default 3. + #[serde(default)] + pub max_fix_attempts: Option, + /// Changed-pixel ratios for the visual comparisons: page, section, + /// asset, and nodes keyed by Figma node id for one asset that needs its + /// own. + #[serde(default)] + pub visual_thresholds: Option, +} + +/// Whether exports into this project mark their elements with +/// data-figma-node-id: on unless the project config says false. An +/// unreadable config leaves it on; draft reports the config error. +pub(super) fn node_ids_enabled(project_root: &str) -> bool { + !matches!( + read_config(project_root), + Ok(Some(PlanConfig { + figma_node_ids: Some(false), + .. + })) + ) +} + +fn read_config(project_root: &str) -> Result, DevupError> { + let path = Path::new(project_root).join(CONFIG_FILE); + let Ok(bytes) = std::fs::read(&path) else { + return Ok(None); + }; + let mut config: PlanConfig = serde_json::from_slice(&bytes).map_err(|error| { + invalid(format!( + "{} is not a valid screen plan config: {error}", + path.display() + )) + })?; + if let Some(mode) = config.width_grouping.as_deref() + && !matches!(mode, "responsive" | "separate" | "ask") + { + return Err(invalid(format!( + "{}: widthGrouping must be responsive, separate or ask, not {mode:?}.", + path.display() + ))); + } + if let Some(framework) = config.framework.as_deref() + && !matches!(framework, "vinext" | "next") + { + return Err(invalid(format!( + "{}: framework must be vinext or next, not {framework:?}.", + path.display() + ))); + } + for (alias, route) in &mut config.route_aliases { + *route = normalize_route(route) + .filter(|route| valid_route(route)) + .ok_or_else(|| { + invalid(format!( + "{}: routeAliases[{alias:?}] = {route:?} is not an app route.", + path.display() + )) + })?; + } + Ok(Some(config)) +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum AnswerInput { + One(String), + Many(Vec), +} + +impl AnswerInput { + fn values(&self) -> Vec<&str> { + match self { + Self::One(value) => vec![value.as_str()], + Self::Many(values) => values.iter().map(String::as_str).collect(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanScreenInput { + pub node_id: String, + pub name: String, + pub width: f64, + #[serde(default)] + pub height: Option, + /// The Figma Section (or other parent) the frame sits in, by id or name. + /// devup_figma_explore fills it from the frame's parent. + #[serde(default)] + pub section: Option, + /// The section's layer name, when `section` is an id. + #[serde(default)] + pub section_name: Option, + /// Layer names from the page down to this frame, as devup_figma_explore + /// reports them. Used to keep the plan inside the linked node and out of + /// trash, archive and draft areas. + #[serde(default)] + pub breadcrumb: Vec, + /// Ancestor node ids, nearest first, when known. + #[serde(default)] + pub ancestors: Vec, + /// false when the frame is hidden in Figma. + #[serde(default)] + pub visible: Option, + #[serde(default)] + pub opacity: Option, + /// Shared part names the caller saw in this screen, e.g. Header, Footer. + #[serde(default)] + pub parts: Vec, + /// Links the caller saw in this screen, e.g. prototype destinations. + #[serde(default)] + pub links: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanLinkInput { + pub label: String, + #[serde(default)] + pub destination_node_id: Option, + #[serde(default)] + pub url: Option, +} + +fn invalid(message: impl Into) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} + +fn text(value: &Value) -> &str { + value.as_str().unwrap_or_default() +} + +fn array(value: &Value) -> &[Value] { + value.as_array().map(Vec::as_slice).unwrap_or_default() +} + +fn sha256(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn now() -> String { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default(); + super::format_epoch_rfc3339(seconds) +} + +/// What the project already says about its routes, gathered once per call. +#[derive(Debug, Clone, Default)] +pub(super) struct ProjectEvidence { + /// (route pattern, page file) from the App Router inventory. + pub routes: Vec<(String, String)>, + /// (link label, href, file:line) for every static link in scanned sources. + pub nav_links: Vec<(String, String, String)>, + /// Components the app's layouts import, so every page already shares them. + pub layout_parts: Vec, + /// `src/app` or `app`, whichever the existing pages use. + pub app_dir: String, + pub inventory_note: Option, + /// Figma node id -> (route, file) for every `data-figma-node-id` already + /// in one route's sources: that page was built from that frame. + pub figma_nodes: BTreeMap, + pub config: PlanConfig, + /// The config file, when one was read. + pub config_file: Option, + /// The project root the relative paths here are under. + pub root: PathBuf, + /// Route pages and the components they render, relative to `root`. + pub sources: Vec, + pub icon_files: Vec, + pub og_files: Vec, + /// (value, where it was found). + pub site_name: Option<(String, String)>, + pub site_url: Option<(String, String)>, + pub package_name: Option, + pub analytics: Vec, + pub not_found: Option, + pub lang: Option<(String, String)>, + /// (version as declared or resolved, where it was read). + pub next_version: Option<(String, String)>, + pub react_version: Option<(String, String)>, + /// Page-transition and animation libraries the project depends on. + pub motion_libraries: Vec, + pub vinext_version: Option<(String, String)>, + /// react-dom and react-server-dom-webpack, which a vinext app pins to + /// the React it runs while `react` itself may say only `^19`. + pub react_peers: Vec<(String, String)>, + /// Every app the project holds, and what runs each. + pub apps: Vec, + /// The app this plan builds, relative to the root (`.` for the root). + /// `None` with several apps: which one is asked. + pub target_app: Option, +} + +impl ProjectEvidence { + /// The target app's directory, or `.`. + fn app_root(&self) -> &str { + self.target_app.as_ref().map_or(".", |app| app.dir.as_str()) + } + + /// The target app's framework, or `unknown`. + fn framework(&self) -> &'static str { + self.target_app + .as_ref() + .map_or("unknown", |app| app.framework) + } +} + +/// Picks the app a plan builds and points `app_dir` at its App Router. +fn choose_app(root: &Path, evidence: &mut ProjectEvidence) { + evidence.apps = super::framework::detect(root); + evidence.target_app = match evidence.apps.as_slice() { + [only] => Some(only.clone()), + apps => apps.iter().find(|app| app.dir == ".").cloned(), + }; + let first = evidence + .target_app + .clone() + .or_else(|| evidence.apps.first().cloned()); + if let Some(app) = first { + evidence.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); + } +} + +/// The `data-figma-node-id="..."` values in one source file. +fn figma_node_ids(source: &str) -> Vec { + let mut ids = Vec::new(); + let mut rest = source; + while let Some(start) = rest.find("data-figma-node-id=") { + rest = &rest[start + "data-figma-node-id=".len()..]; + let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { + continue; + }; + rest = &rest[1..]; + if let Some(end) = rest.find(quote) { + ids.push(rest[..end].to_owned()); + rest = &rest[end..]; + } + } + ids +} + +/// Reads routes, layout components and static links through the same +/// inventory `devup_project_context scope:"ui"` returns, then the navigation +/// parser `devup_ui_validate` uses on each scanned file. +pub(super) async fn project_evidence(project_root: &str) -> Result { + let inventory = super::project_context::run("ui", Some(project_root), None).await?; + let config = read_config(project_root)?; + let mut evidence = ProjectEvidence { + app_dir: "src/app".into(), + config_file: config.is_some().then(|| CONFIG_FILE.to_owned()), + config: config.unwrap_or_default(), + ..ProjectEvidence::default() + }; + evidence.root = PathBuf::from(project_root); + if inventory["found"] != true { + choose_app(&PathBuf::from(project_root), &mut evidence); + read_site_facts(&PathBuf::from(project_root), &mut evidence); + evidence.inventory_note = Some( + "No App Router source was found; every route is unverified until pages exist or navigationTargets name them." + .into(), + ); + return Ok(evidence); + } + if inventory["truncation"]["truncated"] == true || inventory["unparsedFiles"] != 0 { + evidence.inventory_note = Some( + "The route inventory is truncated or has unparsed files; a route missing here may still exist.".into(), + ); + } + let root = PathBuf::from(text(&inventory["projectRoot"])); + evidence.root = root.clone(); + let mut sources = BTreeSet::new(); + let mut owner = BTreeMap::>::new(); + for route in array(&inventory["routes"]) { + let path = text(&route["route"]).to_owned(); + evidence + .routes + .push((path.clone(), text(&route["page"]).to_owned())); + sources.insert(text(&route["page"]).to_owned()); + owner + .entry(text(&route["page"]).to_owned()) + .or_default() + .insert(path.clone()); + for component in array(&route["components"]) { + sources.insert(text(component).to_owned()); + owner + .entry(text(component).to_owned()) + .or_default() + .insert(path.clone()); + } + } + if evidence + .routes + .iter() + .any(|(_, page)| page.starts_with("app/")) + && !evidence + .routes + .iter() + .any(|(_, page)| page.starts_with("src/app/")) + { + evidence.app_dir = "app".into(); + } + choose_app(&root, &mut evidence); + for component in array(&inventory["components"]) { + sources.insert(text(&component["path"]).to_owned()); + let in_layout = array(&component["importedBy"]).iter().any(|site| { + let path = text(&site["path"]); + path.ends_with("layout.tsx") || path.ends_with("layout.jsx") + }); + if in_layout { + for name in array(&component["exports"]) { + let name = text(name); + if !name.is_empty() + && name != "default" + && !evidence.layout_parts.iter().any(|part| part == name) + { + evidence.layout_parts.push(name.to_owned()); + } + } + } + } + for relative in sources + .iter() + .filter(|path| !path.is_empty()) + .take(MAX_SCANNED_SOURCES) + { + let path = root.join(relative); + if std::fs::metadata(&path).map_or(true, |meta| meta.len() > MAX_SOURCE_BYTES) { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + // A frame id in exactly one route's files says that route was built + // from it; in several, it is a shared part and says nothing. + if let Some(routes) = owner.get(relative).filter(|routes| routes.len() == 1) { + for id in figma_node_ids(&source) { + evidence.figma_nodes.entry(id).or_insert_with(|| { + ( + routes.iter().next().cloned().unwrap_or_default(), + relative.clone(), + ) + }); + } + } + for element in array(&super::navigation::source_evidence(&source)["elements"]) { + if element["isLink"] == true + && let Some(href) = element["href"].as_str() + && href.starts_with('/') + && !text(&element["name"]).is_empty() + { + evidence.nav_links.push(( + text(&element["name"]).to_owned(), + href.to_owned(), + format!("{relative}:{}", element["line"]), + )); + } + } + } + evidence.sources = sources + .iter() + .filter(|path| !path.is_empty()) + .take(MAX_SCANNED_SOURCES) + .cloned() + .collect(); + read_site_facts(&root, &mut evidence); + Ok(evidence) +} + +/// Lowercased words of a layer name with widths and device words removed, so +/// `About - 1440`, `[MO] About_390` and `about` are one screen. +fn base_words(name: &str) -> Vec { + let lowered = name.to_lowercase(); + let words = lowered + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_owned) + .collect::>(); + let kept = words + .iter() + .filter(|word| { + let digits = word.trim_end_matches("px"); + let width = !digits.is_empty() + && digits.chars().all(|c| c.is_ascii_digit()) + && digits + .parse::() + .is_ok_and(|n| (240..=4000).contains(&n)); + !width && !DEVICE_WORDS.contains(&word.as_str()) + }) + .cloned() + .collect::>(); + if kept.is_empty() { words } else { kept } +} + +/// The URL slug a name would have, from its ASCII words only. Empty for a +/// name with none, which is why a slug is offered as an option and never +/// proposed on its own. +fn slug(words: &[String]) -> String { + words + .iter() + .filter(|word| word.chars().all(|c| c.is_ascii_alphanumeric())) + .cloned() + .collect::>() + .join("-") +} + +fn compact(value: &str) -> String { + value + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + +/// The same rule `pageScaffold.route` is held to, so a confirmed route is one +/// the export will accept. +pub(super) fn valid_route(route: &str) -> bool { + let trimmed = route.trim_matches('/'); + route.starts_with('/') + && !route.starts_with("//") + && !route.ends_with("//") + && trimmed.split('/').all(|segment| { + (trimmed.is_empty() || !segment.is_empty()) + && segment != "." + && segment != ".." + && segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.[]()@".contains(c)) + }) +} + +fn normalize_route(href: &str) -> Option { + let path = href.split(['?', '#']).next().unwrap_or_default().trim(); + if !path.starts_with('/') || path.starts_with("//") { + return None; + } + let trimmed = path.trim_end_matches('/'); + Some(if trimmed.is_empty() { + "/".to_owned() + } else { + trimmed.to_owned() + }) +} + +fn truncate(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + return value.to_owned(); + } + let mut cut = value.chars().take(limit - 1).collect::(); + cut.push('…'); + cut +} + +fn header(prefix: &str, name: &str) -> String { + truncate(&format!("{prefix}: {name}"), HEADER_LIMIT) +} + +/// One option as the question tool shows it, and what choosing it does. +struct Choice { + label: String, + description: String, + action: Value, +} + +fn choice(label: impl Into, description: impl Into, action: Value) -> Choice { + Choice { + label: label.into(), + description: description.into(), + action, + } +} + +/// A question in the shape every runtime's question tool takes: the +/// recommended option first with " (Recommended)" on its label, two to four +/// options, and custom answers allowed. `resolve` stays in the plan file and is +/// stripped from what the caller is asked. +fn question(id: String, header: String, prompt: String, choices: Vec, kind: &str) -> Value { + let mut seen = BTreeSet::new(); + let choices = choices + .into_iter() + .filter(|choice| seen.insert(choice.label.clone())) + .take(MAX_OPTIONS) + .collect::>(); + let options = choices + .iter() + .enumerate() + .map(|(index, choice)| { + json!({ + "label": if index == 0 { format!("{}{RECOMMENDED}", choice.label) } else { choice.label.clone() }, + "description": choice.description, + }) + }) + .collect::>(); + let resolve = choices + .iter() + .map(|choice| json!({"label": choice.label, "action": choice.action})) + .collect::>(); + json!({ + "id": id, + "header": header, + "question": prompt, + "options": options, + "multiple": false, + "custom": true, + "kind": kind, + "resolve": resolve, + }) +} + +/// The questions as they go to the question tool: no resolution table. +fn ask_shape(questions: &[Value]) -> Vec { + ask_shape_with(questions, questions) +} + +/// As [`ask_shape`], with `all` the whole plan's questions so a conditional +/// question can say in its own text when it applies - the flat list may be +/// asked in one call, where the answer it depends on is not yet known. +fn ask_shape_with(all: &[Value], questions: &[Value]) -> Vec { + questions + .iter() + .map(|question| { + let mut shaped = question.clone(); + let conditions = array(&question["when"]) + .iter() + .map(|condition| { + let parent = all + .iter() + .find(|other| other["id"] == condition["question"]) + .map_or_else( + || text(&condition["question"]).to_owned(), + |other| text(&other["header"]).to_owned(), + ); + format!("\"{parent}\" is \"{}\"", text(&condition["answer"])) + }) + .collect::>(); + if !conditions.is_empty() { + shaped["question"] = json!(format!( + "{} (Only if {}.)", + text(&question["question"]), + conditions.join(" and ") + )); + } + if let Some(object) = shaped.as_object_mut() { + object.remove("resolve"); + object.remove("kind"); + object.remove("fix"); + if let Some(conditions) = object.get_mut("when").and_then(Value::as_array_mut) { + for condition in conditions { + if let Some(condition) = condition.as_object_mut() { + condition.remove("action"); + } + } + } + } + shaped + }) + .collect() +} + +#[derive(Debug, Clone)] +struct Frame { + node_id: String, + name: String, + width: f64, + height: Option, + section: Option, + section_name: Option, +} + +impl Frame { + fn json(&self) -> Value { + json!({"nodeId": self.node_id, "name": self.name, "width": self.width, "height": self.height, + "section": self.section, "sectionName": self.section_name}) + } +} + +/// How the frames of one name are turned into pages. +#[derive(Debug, Clone, PartialEq)] +enum Grouping { + /// Decided: one page, from a single frame, project config, or evidence. + Fixed(&'static str), + /// Several widths in one section: asked, one responsive page recommended. + Ask { + recommend_single: bool, + reason: String, + }, + /// The same screen name in different sections. A designer usually does + /// that for a reason, so this is never resolved here: the question names + /// the sections and frames and offers to have the Figma fixed instead. + Sections { + all_device: bool, + sections: Vec, + why: String, + }, +} + +#[derive(Debug, Clone)] +struct DraftScreen { + frames: Vec, + words: Vec, + parts: Vec, + links: Vec, + grouping: Grouping, + /// Conditions every question about this screen carries: a structure + /// question about it must have been answered with "keep" for any of them + /// to apply. + extra_when: Vec, + /// The structure questions this screen is part of, so confirm can tell + /// whether the user asked for a Figma fix instead. + structure: Vec, +} + +impl DraftScreen { + fn id(&self) -> &str { + &self.frames[0].node_id + } + fn name(&self) -> &str { + &self.frames[0].name + } + fn widths(&self) -> Vec { + widths_of(&self.frames) + } + /// The question that decides how this screen's frames become pages. + fn grouping_question(&self) -> Option { + match self.grouping { + Grouping::Fixed(_) => None, + Grouping::Ask { .. } => Some(format!("group:{}", self.id())), + Grouping::Sections { .. } => Some(format!("sections:{}", self.id())), + } + } +} + +fn widths_of(frames: &[Frame]) -> Vec { + let mut widths = frames + .iter() + .map(|frame| frame.width.round().max(0.0) as u32) + .collect::>(); + widths.sort_unstable_by(|a, b| b.cmp(a)); + widths.dedup(); + widths +} + +fn width_list(widths: &[u32]) -> String { + widths + .iter() + .map(u32::to_string) + .collect::>() + .join("/") +} + +/// Whether a section name says only which device it is for: `PC`, +/// `Mobile`, `Tablet`, `모바일`. +fn device_name(name: &str) -> bool { + let lowered = name.to_lowercase(); + let words = lowered + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect::>(); + !words.is_empty() + && words.iter().all(|word| { + DEVICE_WORDS.contains(word) + || SECTION_DEVICE_WORDS.contains(word) + || word.chars().all(|c| c.is_ascii_digit()) + }) +} + +fn section_label(frame: &Frame) -> String { + frame + .section_name + .clone() + .or_else(|| frame.section.clone()) + .unwrap_or_else(|| "(no section)".to_owned()) +} + +fn describe_frames(frames: &[Frame]) -> String { + frames + .iter() + .map(|frame| { + format!( + "\"{}\" ({}, {}px, section {})", + frame.name, + frame.node_id, + frame.width.round(), + section_label(frame) + ) + }) + .collect::>() + .join("; ") +} + +/// Frames that share a base name are candidates for one page. Whether they +/// are one is not assumed: +/// +/// - several widths in one section that can merge into one responsive tree +/// are asked, recommending one responsive page; `widthGrouping` in the +/// project config decides this case instead of asking; +/// - the same name in different sections - device-named sections included - +/// is always asked, with the facts and a "fix the Figma" option, because +/// that is a designer's structure and not this server's to correct; +/// - the same name at the same width in one section is not grouped; it is +/// flagged as a possible duplicate by [`structure_questions`]. +fn group_screens(screens: &[PlanScreenInput], config: &PlanConfig) -> Vec { + let mut order = Vec::::new(); + let mut groups = BTreeMap::>::new(); + for screen in screens { + let key = base_words(&screen.name).join(" "); + if !groups.contains_key(&key) { + order.push(key.clone()); + } + groups.entry(key).or_default().push(screen); + } + let make = |members: &[&PlanScreenInput], grouping: Grouping| { + let mut frames = members + .iter() + .map(|screen| Frame { + node_id: screen.node_id.clone(), + name: screen.name.clone(), + width: screen.width, + height: screen.height, + section: screen.section.clone(), + section_name: screen.section_name.clone(), + }) + .collect::>(); + frames.sort_by(|a, b| b.width.total_cmp(&a.width)); + let mut parts = Vec::::new(); + let mut links = Vec::new(); + for screen in members { + for part in &screen.parts { + if !parts.contains(part) { + parts.push(part.clone()); + } + } + links.extend(screen.links.iter().cloned()); + } + DraftScreen { + frames, + words: base_words(&members[0].name), + parts, + links, + grouping, + extra_when: Vec::new(), + structure: Vec::new(), + } + }; + let mut result = Vec::new(); + for key in order { + let members = &groups[&key]; + if members.len() == 1 { + result.push(make(members, Grouping::Fixed("single-frame"))); + continue; + } + // Asked of the base name, not the layer name: `Main - 1440` and + // `Main - 390` are one name across two widths once the width is gone. + let named = members + .iter() + .map(|screen| (key.as_str(), screen.width)) + .collect::>(); + let family = devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible(&named); + let sections = members + .iter() + .map(|screen| screen.section.as_deref()) + .collect::>(); + if sections.len() > 1 { + let probe = make(members, Grouping::Fixed("")); + let mut names = Vec::::new(); + for frame in &probe.frames { + let label = section_label(frame); + if !names.contains(&label) { + names.push(label); + } + } + let all_device = probe + .frames + .iter() + .all(|frame| frame.section_name.as_deref().is_some_and(device_name)); + let mut why = vec!["the same name once widths and device words are removed".to_owned()]; + if family { + why.push(format!( + "their widths ({}) form a desktop-to-mobile pattern", + width_list(&probe.widths()) + )); + } + result.push(make( + members, + Grouping::Sections { + all_device, + sections: names, + why: why.join(", and "), + }, + )); + continue; + } + if !family { + for member in members { + result.push(make(&[member], Grouping::Fixed("single-frame"))); + } + continue; + } + match config.width_grouping.as_deref() { + Some("responsive") => { + result.push(make(members, Grouping::Fixed("config:widthGrouping"))); + } + Some("separate") => { + for member in members { + result.push(make(&[member], Grouping::Fixed("config:widthGrouping"))); + } + } + _ => result.push(make( + members, + Grouping::Ask { + recommend_single: true, + reason: "drawn at several widths in one section with matching names".into(), + }, + )), + } + } + result +} + +/// The base name without a trailing part number: `Main 1` and `Main (2)` +/// are parts of one screen if anything is. +fn part_key(words: &[String]) -> Option { + let (last, rest) = words.split_last()?; + let numbered = + last.chars().all(|c| c.is_ascii_digit()) && last.parse::().is_ok_and(|n| n < 100); + let rest = rest + .iter() + .filter(|word| !matches!(word.as_str(), "part" | "p")) + .cloned() + .collect::>(); + (numbered && !rest.is_empty()).then(|| rest.join(" ")) +} + +const KEEP_SEPARATE: &str = "They are different screens"; +const KEEP_OUTSIDE: &str = "It is its own screen"; +pub(super) const FIX_FIGMA: &str = "The Figma structure is wrong: I will fix it"; + +/// One structural oddity: the frames involved, what looks wrong, and the fix +/// the designer would make. +struct Oddity { + kind: &'static str, + screens: Vec, + facts: String, + keep: &'static str, + keep_description: String, + fix: String, +} + +/// Figma structure that a designer may not have meant, flagged in the same +/// batch with a "fix it" option. Nothing here decides which frame is right. +fn structure_questions(drafts: &[DraftScreen]) -> Vec { + let mut found = Vec::new(); + let singles = drafts + .iter() + .enumerate() + .filter(|(_, screen)| screen.grouping == Grouping::Fixed("single-frame")) + .collect::>(); + // The same name and width in one section. + let mut duplicates = BTreeMap::<(String, Option, u32), Vec>::new(); + for (index, screen) in &singles { + let frame = &screen.frames[0]; + duplicates + .entry(( + screen.words.join(" "), + frame.section.clone(), + widths_of(&screen.frames)[0], + )) + .or_default() + .push(*index); + } + for indices in duplicates.values().filter(|indices| indices.len() > 1) { + let frames = indices + .iter() + .map(|index| drafts[*index].frames[0].clone()) + .collect::>(); + found.push(Oddity { + kind: "duplicate", + screens: indices.clone(), + facts: format!( + "{} frames share one name and one width in one section: {}.", + frames.len(), + describe_frames(&frames) + ), + keep: KEEP_SEPARATE, + keep_description: "Each frame is its own screen and gets its own route.".into(), + fix: format!( + "Delete the stale copy or rename the frames so each screen has one name: {}.", + describe_frames(&frames) + ), + }); + } + // One screen split into numbered parts across sections. + let mut parts = BTreeMap::>::new(); + for (index, screen) in drafts.iter().enumerate() { + if let Some(key) = part_key(&screen.words) { + parts.entry(key).or_default().push(index); + } + } + for indices in parts.values().filter(|indices| indices.len() > 1) { + let frames = indices + .iter() + .flat_map(|index| drafts[*index].frames.clone()) + .collect::>(); + let sections = frames + .iter() + .map(|frame| frame.section.clone()) + .collect::>(); + if sections.len() < 2 { + continue; + } + found.push(Oddity { + kind: "split", + screens: indices.clone(), + facts: format!( + "These numbered frames look like parts of one screen spread across sections: {}.", + describe_frames(&frames) + ), + keep: KEEP_SEPARATE, + keep_description: "Each part is its own screen and gets its own route.".into(), + fix: format!( + "Put the parts of one screen into one frame in one section: {}.", + describe_frames(&frames) + ), + }); + } + // A frame outside any section beside frames that are in sections. + let sectioned = drafts + .iter() + .flat_map(|screen| &screen.frames) + .filter_map(|frame| frame.section.as_ref().map(|_| section_label(frame))) + .collect::>(); + if !sectioned.is_empty() { + for (index, screen) in &singles { + let frame = &screen.frames[0]; + if frame.section.is_some() { + continue; + } + found.push(Oddity { + kind: "loose", + screens: vec![*index], + facts: format!( + "\"{}\" ({}, {}px) sits outside any section, while the other frames are in {}.", + frame.name, + frame.node_id, + frame.width.round(), + sectioned.iter().cloned().collect::>().join(", ") + ), + keep: KEEP_OUTSIDE, + keep_description: "Plan it as a screen of its own, outside the sections.".into(), + fix: format!( + "Move \"{}\" ({}) into the section it belongs to, or out of this area.", + frame.name, frame.node_id + ), + }); + } + } + found +} + +/// Layer names that mark a Figma node as the site's logo or icon, and as its +/// share image. Frames named like this are site assets, not pages. +const LOGO_WORDS: &[&str] = &[ + "logo", + "symbol", + "favicon", + "appicon", + "로고", + "심볼", + "파비콘", +]; +/// Narrow on purpose: a bare "share" or "thumbnail" names real UI - a share +/// sheet, a video thumbnail - and those are screens. +const OG_WORDS: &[&str] = &[ + "og", + "opengraph", + "open graph", + "share image", + "share card", + "social image", + "공유 이미지", + "대표 이미지", +]; +/// Package names that say a project already measures or verifies something. +const ANALYTICS_PACKAGES: &[&str] = &[ + "@vercel/analytics", + "@next/third-parties", + "react-ga4", + "react-gtm-module", + "@amplitude/analytics-browser", + "mixpanel-browser", + "posthog-js", +]; +const ANALYTICS_ENV: &[&str] = &[ + "GA_", + "GTM", + "GOOGLE_ANALYTICS", + "GOOGLE_SITE_VERIFICATION", + "NAVER_SITE_VERIFICATION", +]; + +fn asset_kind(name: &str) -> Option<&'static str> { + if LOGO_WORDS.iter().any(|word| keyword_in(name, word)) || keyword_in(name, "app icon") { + Some("logo") + } else if OG_WORDS.iter().any(|word| keyword_in(name, word)) { + Some("og") + } else { + None + } +} + +/// A Figma node that may become the favicon or the share image. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanAssetInput { + pub node_id: String, + pub name: String, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +/// Files under `directory` (relative to `root`) whose names start with one of +/// `prefixes`, as relative paths. +fn files_named(root: &Path, directory: &str, prefixes: &[&str]) -> Vec { + let Ok(entries) = std::fs::read_dir(join_relative(root, directory)) else { + return Vec::new(); + }; + let mut found = entries + .flatten() + .filter(|entry| entry.path().is_file()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| { + let lowered = name.to_lowercase(); + prefixes.iter().any(|prefix| lowered.starts_with(prefix)) + }) + .map(|name| format!("{directory}/{name}")) + .collect::>(); + found.sort(); + found +} + +fn join_relative(root: &Path, relative: &str) -> PathBuf { + relative + .split('/') + .filter(|part| !part.is_empty()) + .fold(root.to_path_buf(), |path, part| path.join(part)) +} + +fn read_small_up_to(path: &Path, limit: u64) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if metadata.len() > limit { + return None; + } + std::fs::read_to_string(path).ok() +} + +fn read_small(path: &Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if metadata.len() > MAX_SOURCE_BYTES { + return None; + } + std::fs::read_to_string(path).ok() +} + +/// The quoted value after `marker` in `source`, e.g. the URL in +/// `metadataBase: new URL("...")`. +fn quoted_after(source: &str, marker: &str) -> Option { + let rest = &source[source.find(marker)? + marker.len()..]; + let quote_at = rest.find(['"', '\'', '`'])?; + let quote = rest[quote_at..].chars().next()?; + let value = &rest[quote_at + 1..]; + let end = value.find(quote)?; + Some(value[..end].to_owned()).filter(|value| !value.is_empty()) +} + +/// What the project already says about the site as a whole: its icon, share +/// image, name, URL, analytics, language and not-found page. +fn read_site_facts(root: &Path, evidence: &mut ProjectEvidence) { + let app = evidence.app_dir.clone(); + let app_root = evidence.app_root().to_owned(); + let public = if app_root == "." { + "public".to_owned() + } else { + format!("{app_root}/public") + }; + evidence.icon_files = [ + files_named(root, &app, &["icon.", "favicon.", "apple-icon."]), + files_named(root, &public, &["favicon", "icon.", "apple-touch-icon"]), + ] + .concat(); + evidence.og_files = [ + files_named(root, &app, &["opengraph-image.", "twitter-image."]), + files_named(root, &public, &["og", "share", "opengraph"]), + ] + .concat(); + evidence.not_found = ["tsx", "jsx", "js"] + .iter() + .map(|ext| format!("{app}/not-found.{ext}")) + .find(|path| join_relative(root, path).is_file()); + for ext in ["tsx", "jsx", "js"] { + let layout = format!("{app}/layout.{ext}"); + let Some(source) = read_small(&join_relative(root, &layout)) else { + continue; + }; + evidence.lang = quoted_after(&source, "(&text).ok()); + for (name, slot) in [ + ("next", &mut evidence.next_version), + ("react", &mut evidence.react_version), + ("vinext", &mut evidence.vinext_version), + ] { + if let Some(version) = bun_lock.as_deref().and_then(|lock| bun_locked(lock, name)) { + *slot = Some((version, "bun.lock".into())); + } else if let Some(version) = npm_lock + .as_ref() + .and_then(|lock| lock["packages"][format!("node_modules/{name}")]["version"].as_str()) + { + *slot = Some((version.to_owned(), "package-lock.json".into())); + } + } + let env_dirs = if app_root == "." { + vec![".".to_owned()] + } else { + vec![app_root.clone(), ".".to_owned()] + }; + for file in env_dirs.iter().flat_map(|dir| { + [".env", ".env.local", ".env.production"] + .map(|name| join_relative(&PathBuf::from(dir), name)) + }) { + let Some(source) = read_small(&root.join(&file)) else { + continue; + }; + let file = file.display().to_string().replace('\\', "/"); + for line in source.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim().trim_matches(['"', '\'']); + if evidence.site_url.is_none() + && matches!( + key, + "NEXT_PUBLIC_SITE_URL" | "SITE_URL" | "NEXT_PUBLIC_BASE_URL" + ) + && (value.starts_with("http://") || value.starts_with("https://")) + { + evidence.site_url = Some((value.to_owned(), format!("{key} in {file}"))); + } + if evidence.site_name.is_none() + && matches!(key, "NEXT_PUBLIC_SITE_NAME" | "SITE_NAME") + && !value.is_empty() + { + evidence.site_name = Some((value.to_owned(), format!("{key} in {file}"))); + } + if ANALYTICS_ENV.iter().any(|prefix| key.contains(prefix)) { + evidence.analytics.push(format!("{key} in {file}")); + } + } + } + // The app's own package.json first: in a workspace the root one holds + // tooling, the app's holds its framework and React. + for manifest in env_dirs + .iter() + .map(|dir| join_relative(root, dir).join("package.json")) + { + let Some(package) = + read_small(&manifest).and_then(|text| serde_json::from_str::(&text).ok()) + else { + continue; + }; + let shown = manifest + .strip_prefix(root) + .unwrap_or(&manifest) + .display() + .to_string() + .replace('\\', "/"); + if evidence.package_name.is_none() { + evidence.package_name = package["name"].as_str().map(str::to_owned); + } + for section in ["dependencies", "devDependencies"] { + for (name, slot) in [ + ("next", &mut evidence.next_version), + ("react", &mut evidence.react_version), + ("vinext", &mut evidence.vinext_version), + ] { + if slot.is_none() + && let Some(version) = package[section][name].as_str() + { + *slot = Some((version.to_owned(), format!("{shown} {section}"))); + } + } + for peer in ["react-dom", "react-server-dom-webpack"] { + if let Some(version) = package[section][peer].as_str() { + evidence + .react_peers + .push((version.to_owned(), format!("{peer} in {shown} {section}"))); + } + } + for name in MOTION_LIBRARIES { + if package[section].get(*name).is_some() + && !evidence.motion_libraries.iter().any(|known| known == name) + { + evidence.motion_libraries.push((*name).to_owned()); + } + } + } + for section in ["dependencies", "devDependencies"] { + for name in ANALYTICS_PACKAGES { + if package[section].get(*name).is_some() { + evidence.analytics.push(format!("{name} in {shown}")); + } + } + } + } +} + +/// The version bun.lock resolved a package to: its entry reads +/// `"react": ["react@19.3.0", ...]`. +fn bun_locked(lock: &str, name: &str) -> Option { + let marker = format!("\"{name}\": [\"{name}@"); + let start = lock.find(&marker)? + marker.len(); + let end = lock[start..].find('"')? + start; + Some(lock[start..end].to_owned()) + .filter(|version| version.starts_with(|c: char| c.is_ascii_digit())) +} + +/// Libraries that already animate page changes. With one present, adding +/// view transitions is a question, not a default. +const MOTION_LIBRARIES: &[&str] = &[ + "framer-motion", + "motion", + "next-view-transitions", + "react-transition-group", + "@react-spring/web", + "gsap", +]; + +/// The interaction recipe per element role: what each one gets on hover, +/// press and focus. The devup-implementation-plan skill carries the same +/// table with the reasoning; status counts elements against it per role. +const INTERACTION_RECIPES: &[(&str, &str)] = &[ + ( + "primary button", + "hover one token step darker; active scale(0.98) or one more step darker; focusVisible 2px ring with offset; disabled reduced opacity and cursor not-allowed; a loading state (disabled, spinner) while a form it submits is pending", + ), + ( + "secondary / ghost / outline button", + "hover a subtle tinted background; active a slightly stronger tint; the same focus ring and disabled state", + ), + ( + "text link / \"더 보기 >\" link", + "hover a colour change or underline; a trailing chevron moves 2px; focus ring", + ), + ( + "card that links", + "hover a 2-4px lift and a shadow token; the image inside zooms to 1.02-1.04 with overflow hidden; active drops the lift; focus ring on the card", + ), + ( + "nav item / tab", + "hover colour; the current route marked with aria-current=\"page\" and the Figma active style (e.g. the underline the header draws); focus ring", + ), + ( + "icon button", + "hover a background circle or tint; active press; aria-label required", + ), + ( + "input / select / textarea", + "hover border; focus border and ring; invalid state with its message; disabled", + ), + ( + "accordion / disclosure / dropdown", + "aria-expanded; the chevron rotates 180deg", + ), + ( + "mobile menu / drawer", + "an open and close transition, when the design has one", + ), +]; + +const RESTRAINT_RULES: &str = "Figma variant states first, otherwise existing theme tokens only (no new colours; record an assumption when none fits); at most two properties change per state; 150-250ms, ease or ease-out, defined once as a shared token or constant; no bounce and no infinite or attention-seeking animation; transforms of 4px or less and scale between 0.97 and 1.05; everything that moves is off under _motionReduce; non-interactive elements get no hover."; + +/// `major.minor.patch` from a version or range as package.json writes it: +/// `^16.1.0`, `~19.3.0-canary-...`, `16`. `None` for `latest`, +/// `workspace:*`, `catalog:` and the like, which say nothing about the API. +fn version_triple(value: &str) -> Option<(u64, u64, u64)> { + let start = value.find(|c: char| c.is_ascii_digit())?; + if !value[..start] + .chars() + .all(|c| matches!(c, '^' | '~' | '>' | '=' | '<' | 'v' | ' ')) + { + return None; + } + let mut parts = value[start..] + .split(|c: char| !c.is_ascii_digit()) + .take(3) + .map(|part| part.parse::().unwrap_or(0)); + Some(( + parts.next()?, + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + )) +} + +/// The dependency step every job starts with, word for word as the skills +/// give it. It updates every manifest in the repository - every bun +/// workspace and every Cargo.toml - to the latest versions: `-d` finds them +/// all, `-u` applies the updates as one recoverable batch, and `--rm` removes +/// the lockfiles and installed directories next to each (Cargo.lock and +/// target/ included) so the next install resolves everything fresh. +pub(super) const UPDATE_STEP: &str = "bunx @dependency-check-updates/cli -d -u --rm"; + +/// The documents the view-transition decision rests on. Read when it was +/// written; the decision names them so a reader can check them again. +/// +/// - https://nextjs.org/docs/app/guides/view-transitions - App Router, no +/// config, `import { ViewTransition } from 'react'`, React 19.3. +/// - https://react.dev/reference/react/ViewTransition - the component. +/// - https://vinext.dev/compatibility - Next.js's own +/// app-dir/view-transitions e2e suite passes under vinext (App Router). +/// - : +/// vinext's own example imports `ViewTransition` from 'react' and sets +/// `experimental.viewTransition: true` in its next config. +/// - : +/// `vinext check` labels that import "partial (passthrough fallback)" +/// whatever the React version; the runtime audit's view-transition check is +/// what settles whether it animates. +/// - https://github.com/cloudflare/vinext#readme - peer dependency react ^19.3.1. +const VIEW_TRANSITION_DOCS: &[&str] = &[ + "https://nextjs.org/docs/app/guides/view-transitions", + "https://react.dev/reference/react/ViewTransition", + "https://vinext.dev/compatibility", + "https://github.com/cloudflare/vinext/blob/main/examples/app-router-playground/app/view-transitions/_ui/transitions.tsx", + "https://github.com/cloudflare/vinext/blob/main/packages/vinext/src/check.ts", +]; + +/// The view-transition default: always on. DevFive runs the latest versions +/// (the dependency step comes first), and the latest Next App Router and +/// React support `` on vinext and on the `next` CLI alike, so +/// no version is checked. Whether it really animates is not taken on trust +/// either way: the runtime audit's `view-transition` check follows a link and +/// fails when no transition ran. The only "skipped" results are a project +/// that opts out (`viewTransitions: false`) and one that forbids the update +/// (`updateDependencies: false`) while its installed React predates 19.3. +fn view_transition_default(evidence: &ProjectEvidence) -> Value { + let skipped = |why: String| { + json!({"item": "view-transitions", "mechanism": "skipped", + "decision": "No view transitions.", "evidence": why, "docs": VIEW_TRANSITION_DOCS}) + }; + if evidence.config.view_transitions == Some(false) { + return skipped(format!("viewTransitions: false in {CONFIG_FILE}")); + } + if evidence.config.update_dependencies == Some(false) { + let react = std::iter::once(&evidence.react_version) + .flatten() + .chain(evidence.react_peers.iter()) + .filter_map(|(text, _)| version_triple(text)) + .max(); + if react.is_none_or(|react| react < (19, 3, 0)) { + return skipped(format!( + "updateDependencies: false in {CONFIG_FILE} keeps React below 19.3, which has no ViewTransition" + )); + } + } + let runner = if evidence.framework() == "next" + && evidence.config.framework.as_deref() != Some("vinext") + { + "the next CLI" + } else { + "vinext" + }; + json!({"item": "view-transitions", "mechanism": "react-view-transition", + "decision": "import { ViewTransition } from 'react', wrapping each page's content in its page.tsx (not the layout, which persists): a short cross-fade of about 250ms, no big slides; shared elements (page title, hero image, a card image on its detail page) named only where the same element is on both pages - a static name as the devup-ui viewTransitionName prop, a per-record name on ; `::view-transition { pointer-events: none }`; animation-duration 0s under prefers-reduced-motion. Verify it with a runtime-audit view-transition check (follow a link between two routes; it fails when no transition ran) - a failure is a bug to fix. experimental.viewTransition is added to the next config only if that check fails without it (vinext's own example sets it).", + "evidence": format!("runs on {runner} at the latest versions"), "app": evidence.app_root(), "docs": VIEW_TRANSITION_DOCS}) +} + +/// No app, no routes and no framework: a project still to be created. +fn is_new_project(evidence: &ProjectEvidence) -> bool { + evidence.apps.is_empty() + && evidence.routes.is_empty() + && evidence.next_version.is_none() + && evidence.vinext_version.is_none() +} + +/// The defaults that come before any page: start from the template when there +/// is no app, which framework runs the app, and the dependency update. +fn stack_defaults(evidence: &ProjectEvidence) -> Vec { + let mut defaults = Vec::new(); + if is_new_project(evidence) { + defaults.push(json!({"item": "project-start", + "decision": format!("Start from dev-five-git/service-template - bun workspaces apps/*, vinext, devup-ui, oxlint via eslint-plugin-devup, TS 7 - keeping its structure and AGENTS.md conventions, then run the dependency step ({UPDATE_STEP})."), + "evidence": "no app, route or framework in the project"})); + } else if let Some(app) = &evidence.target_app { + defaults.push( + json!({"item": "framework", "decision": format!("{} runs {}", app.dir, app.framework), + "evidence": app.evidence}), + ); + } + defaults.push(if evidence.config.update_dependencies == Some(false) { + json!({"item": "dependencies", "mechanism": "skipped", + "decision": "Dependencies are not updated: the project config forbids it.", + "evidence": format!("updateDependencies: false in {CONFIG_FILE}")}) + } else { + json!({"item": "dependencies", "mechanism": "update", + "decision": format!("Before implementing, at the repository root: {UPDATE_STEP}, which updates every manifest in the repository - every bun workspace and every Cargo.toml - to the latest versions (-d finds them all, -u applies the updates as one recoverable batch, --rm removes each lockfile and installed directory, Cargo.lock and target/ included, so everything resolves fresh); then bun install, build, lint and typecheck. Fix code to new APIs; pin back only when no fix exists, and record the pin as an assumption. Record the outcome with devup_screen_plan assume (dependencyUpdate)."), + "evidence": "DevFive works on the latest versions; API knowledge from memory is assumed stale"}) + }); + defaults +} + +/// Words that mark an app, or a screen, as the public site or the admin +/// console. An app named `admin` is the admin console; a Figma design for a +/// public site is almost never for it. +const PUBLIC_APP_WORDS: &[&str] = &[ + "front", "web", "site", "www", "landing", "home", "public", "client", +]; +const ADMIN_WORDS: &[&str] = &[ + "admin", + "dashboard", + "console", + "backoffice", + "back office", + "cms", + "manager", + "관리자", + "어드민", + "대시보드", + "회원관리", +]; +/// Screen names a public site has and an admin console does not. +const PUBLIC_SCREEN_WORDS: &[&str] = &[ + "home", + "main", + "landing", + "about", + "company", + "service", + "services", + "contact", + "portfolio", + "pricing", + "blog", + "news", + "faq", + "gnb", + "메인", + "홈", + "회사소개", + "서비스", + "문의", + "포트폴리오", + "소개", +]; +/// Routes that say an app is a console: a sign-in gate and a dashboard. +const ADMIN_ROUTE_SEGMENTS: &[&str] = &["signin", "sign-in", "login", "dashboard", "admin"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Side { + Public, + Admin, + Unclear, +} + +fn words_side(names: &[&str], public: &[&str]) -> (usize, usize) { + let admin = names + .iter() + .filter(|name| ADMIN_WORDS.iter().any(|word| keyword_in(name, word))) + .count(); + let public = names + .iter() + .filter(|name| public.iter().any(|word| keyword_in(name, word))) + .count(); + (public, admin) +} + +/// Which side an app serves, and the evidence: its directory and package +/// name, then its routes. +fn app_side(app: &super::framework::App, evidence: &ProjectEvidence) -> (Side, Vec) { + let base = app.dir.rsplit('/').next().unwrap_or(&app.dir).to_owned(); + let mut names = vec![base.as_str()]; + if let Some(name) = &app.name { + names.push(name.as_str()); + } + let (mut public, mut admin) = words_side(&names, PUBLIC_APP_WORDS); + let mut why = Vec::new(); + if public > 0 { + why.push(format!("{} is named like the public site", app.dir)); + } + if admin > 0 { + why.push(format!("{} is named like an admin console", app.dir)); + } + let console_routes = evidence + .routes + .iter() + .filter(|(_, page)| page.starts_with(&format!("{}/", app.dir))) + .filter(|(route, _)| { + route + .trim_matches('/') + .split('/') + .any(|segment| ADMIN_ROUTE_SEGMENTS.contains(&segment)) + }) + .map(|(route, _)| route.clone()) + .collect::>(); + if !console_routes.is_empty() { + admin += 1; + why.push(format!( + "{} has console routes ({})", + app.dir, + console_routes.join(", ") + )); + } + if public == admin { + public = 0; + admin = 0; + } + let side = match public.cmp(&admin) { + std::cmp::Ordering::Greater => Side::Public, + std::cmp::Ordering::Less => Side::Admin, + std::cmp::Ordering::Equal => Side::Unclear, + }; + (side, why) +} + +/// Which side one Figma frame belongs to, from its own name and its +/// section's. Admin markers win: a "관리자 - 메인" frame is the console's. +fn screen_side(screen: &PlanScreenInput) -> Side { + let mut names = vec![screen.name.as_str()]; + names.extend(screen.section_name.as_deref()); + names.extend(screen.breadcrumb.iter().map(String::as_str)); + match words_side(&names, PUBLIC_SCREEN_WORDS) { + (_, admin) if admin > 0 => Side::Admin, + (public, _) if public > 0 => Side::Public, + _ => Side::Unclear, + } +} + +fn app_action(app: &super::framework::App) -> Value { + json!({"app": {"dir": app.dir, "appDir": app.path(app.app_router.as_deref().unwrap_or("src/app"))}}) +} + +/// Which app each screen is built in, by meaning rather than by name order. +/// +/// With one app, or a projectRoot that is an app, there is nothing to pick. +/// Otherwise the screens' own names decide: all public screens go to the +/// public app and all admin screens to the admin app without a question; a +/// file with both maps each screen to its side and asks only about screens +/// whose side is unclear; a file whose screens say nothing either way is the +/// one genuinely ambiguous case, asked once - recommending the public app, +/// never whichever comes first alphabetically. +/// +/// Returns the app per screen (by node id), the questions, and the default +/// that records the evidence. +fn assign_apps( + evidence: &ProjectEvidence, + screens: &[PlanScreenInput], +) -> ( + BTreeMap, + Vec, + Option, +) { + let mut assigned = BTreeMap::new(); + if evidence.target_app.is_some() || evidence.apps.len() < 2 { + return (assigned, Vec::new(), None); + } + let sides = evidence + .apps + .iter() + .map(|app| (app, app_side(app, evidence))) + .collect::>(); + let public_app = sides + .iter() + .find(|(_, (side, _))| *side == Side::Public) + .or_else(|| sides.iter().find(|(_, (side, _))| *side != Side::Admin)) + .map(|(app, _)| (*app).clone()) + .unwrap_or_else(|| evidence.apps[0].clone()); + let admin_app = sides + .iter() + .find(|(_, (side, _))| *side == Side::Admin) + .map(|(app, _)| (*app).clone()); + let app_evidence = sides + .iter() + .flat_map(|(_, (_, why))| why.clone()) + .collect::>(); + let screen_sides = screens + .iter() + .map(|screen| (screen, screen_side(screen))) + .collect::>(); + let admin_screens = screen_sides + .iter() + .filter(|(_, side)| *side == Side::Admin) + .count(); + let public_screens = screen_sides + .iter() + .filter(|(_, side)| *side == Side::Public) + .count(); + let mut questions = Vec::new(); + let options = || { + let mut ordered = vec![public_app.clone()]; + ordered.extend( + evidence + .apps + .iter() + .filter(|app| app.dir != public_app.dir) + .cloned(), + ); + ordered + .iter() + .take(MAX_OPTIONS) + .map(|app| { + let side = sides + .iter() + .find(|(candidate, _)| candidate.dir == app.dir) + .map(|(_, (side, _))| *side); + choice( + app.dir.as_str(), + format!( + "{} - {} app{}", + match side { + Some(Side::Public) => "the public site", + Some(Side::Admin) => "the admin console", + _ => "an app", + }, + app.framework, + app.name + .as_deref() + .map(|name| format!(" ({name})")) + .unwrap_or_default() + ), + app_action(app), + ) + }) + .collect::>() + }; + let has_public = sides.iter().any(|(_, (side, _))| *side == Side::Public); + let decision = match (admin_screens, public_screens, &admin_app) { + // Nothing says admin, so the public site: the bias the user chose. + (0, 0, _) if has_public => { + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), public_app.clone()); + } + json!({"item": "target-app", "mechanism": "decided", "decision": public_app.dir, + "evidence": ([app_evidence, vec!["the frames name neither side; with no admin evidence the public app is taken".to_owned()]].concat())}) + } + (0, 0, _) => { + let mut asked = question( + "site:app".into(), + "Which app".into(), + format!( + "This repository has {} apps ({}), and the frames' names say nothing about which one they are for. Which app does this design build? A custom answer is its directory.", + evidence.apps.len(), + evidence + .apps + .iter() + .map(|app| app.dir.as_str()) + .collect::>() + .join(", ") + ), + options(), + "site", + ); + asked["field"] = json!("app"); + questions.push(asked); + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), public_app.clone()); + } + json!({"item": "target-app", "mechanism": "asked", "question": "site:app", + "decision": format!("Asked; {} is recommended as the public app.", public_app.dir), "evidence": app_evidence}) + } + (admin, public, Some(admin_app)) if admin > 0 && public > 0 => { + for (screen, side) in &screen_sides { + let app = if *side == Side::Admin { + admin_app.clone() + } else { + public_app.clone() + }; + assigned.insert(screen.node_id.clone(), app); + } + json!({"item": "target-app", "mechanism": "per-screen", + "decision": format!("Admin screens go to {}; public screens and screens with no admin evidence go to {}.", admin_app.dir, public_app.dir), + "evidence": app_evidence}) + } + (admin, 0, Some(admin_app)) if admin > 0 => { + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), admin_app.clone()); + } + json!({"item": "target-app", "mechanism": "decided", "decision": admin_app.dir, + "evidence": ([app_evidence, vec![format!("{admin} of {} frames are named for the admin console", screens.len())]].concat())}) + } + _ => { + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), public_app.clone()); + } + json!({"item": "target-app", "mechanism": "decided", "decision": public_app.dir, + "evidence": ([app_evidence, vec![format!("{public_screens} of {} frames are public-site screens and none is named for an admin console", screens.len())]].concat())}) + } + }; + (assigned, questions, Some(decision)) +} + +/// The questions about the stack itself: migrating off the `next` CLI, and +/// for a new project its name and parts. Which app is [`assign_apps`]'s. +fn stack_questions(evidence: &ProjectEvidence) -> Vec { + let mut questions = Vec::new(); + if evidence.framework() == "next" && evidence.config.framework.is_none() { + let app = evidence.target_app.as_ref(); + let mut asked = question( + "site:framework".into(), + "Framework".into(), + format!( + "{} runs on the next CLI ({}). DevFive's default is vinext, which runs the same App Router on Vite. Migrate it, or keep next?", + app.map_or(".", |app| app.dir.as_str()), + app.map(|app| app.evidence.join("; ")).unwrap_or_default() + ), + vec![ + choice( + "Migrate to vinext", + "vite.config.ts with vinext() and the devup-ui Vite plugin, vinext dev/build/start scripts, no next.config; the code and routes stay the App Router. Done before implementing, then the dependency step.", + json!({"framework": {"source": "vinext"}}), + ), + choice( + "Keep next", + "Stay on the next CLI; view transitions follow its version.", + json!({"framework": {"source": "next"}}), + ), + ], + "site", + ); + asked["field"] = json!("framework"); + questions.push(asked); + } + if is_new_project(evidence) { + let mut name = question( + "site:app-name".into(), + "App name".into(), + "The project starts from dev-five-git/service-template. What is the app called? A custom answer is the name.".into(), + vec![ + choice("front", "The template's own app name (apps/front).", json!({"appName": {"source": "template", "value": "front"}})), + choice("Use the site name", "Named after the site name asked with the other site facts.", json!({"appName": {"source": "site-name"}})), + ], + "site", + ); + name["field"] = json!("appName"); + questions.push(name); + let mut parts = question( + "site:parts".into(), + "Template parts".into(), + "Which parts of the template does this project keep?".into(), + vec![ + choice( + "Front only", + "apps/front alone; admin and the Rust API are removed.", + json!({"parts": {"source": "front"}}), + ), + choice( + "Front + admin + API", + "apps/front, apps/admin and apis/api, as the template ships them.", + json!({"parts": {"source": "all"}}), + ), + ], + "site", + ); + parts["field"] = json!("parts"); + questions.push(parts); + } + questions +} + +/// What the agent fills in by itself, without a question. Recorded in the +/// plan's `defaults` and the final report, so nothing it added is a surprise. +fn site_defaults(evidence: &ProjectEvidence, screens: &[PlanScreenInput]) -> Vec { + let lang = evidence.lang.clone().unwrap_or_else(|| { + let korean = screens + .iter() + .any(|screen| screen.name.chars().any(|c| ('가'..='힣').contains(&c))); + if korean { + ( + "ko".into(), + "Korean text in the design's layer names".into(), + ) + } else { + ( + "en".into(), + "no Korean text in the design's layer names".into(), + ) + } + }); + let mut defaults = vec![ + json!({"item": "interaction-recipes", + "decision": "Each interactive element gets its role's recipe; status counts every role against it.", + "recipes": INTERACTION_RECIPES.iter().map(|(role, recipe)| json!({"role": role, "recipe": recipe})).collect::>(), + "restraint": RESTRAINT_RULES, + "evidence": "devfive-frontend critical-rules 4 (interactive states); devup-ui pseudo-selectors; the devup-implementation-plan recipe table"}), + json!({"item": "transition", + "decision": "One shared transition, 150-250ms, ease or ease-out, on the properties that change (at most two per state), defined once as a token or shared constant and not per element.", + "evidence": "devfive-frontend critical-rules 4 (transition 0.15-0.3s ease)"}), + json!({"item": "reduced-motion", + "decision": "_motionReduce removes transforms and transitions (or one globalCss rule does), so prefers-reduced-motion gets no movement.", + "evidence": "devup-ui _motionReduce"}), + json!({"item": "metadata", + "decision": "Root layout metadata with a title template, description and metadataBase when the site URL is known; every page exports title, description and openGraph taken from its Figma heading and lead text.", + "evidence": "devfive-frontend critical-rules 2 (SEO metadata with openGraph)"}), + json!({"item": "lang", "decision": format!("", lang.0), "evidence": lang.1}), + json!({"item": "alt-text", "decision": "alt text from the Figma layer name or its text content on every image.", + "evidence": "devfive-frontend critical-rules 3 (image alt required)"}), + json!({"item": "image-dimensions", "decision": "width and height on every image, so nothing shifts as it loads.", + "evidence": "no layout shift"}), + ]; + let transitions = view_transition_default(evidence); + defaults.push(if evidence.motion_libraries.is_empty() { + transitions + } else { + json!({"item": "view-transitions", "mechanism": "asked", "question": "site:view-transitions", + "decision": "Asked: the project already animates with its own library.", "ifAdded": transitions, + "evidence": evidence.motion_libraries.join(", ")}) + }); + defaults.push(match &evidence.not_found { + Some(path) => { + json!({"item": "not-found", "decision": format!("Keep {path}."), "evidence": path}) + } + None => json!({"item": "not-found", + "decision": format!("Add {}/not-found.tsx in the site's own style.", evidence.app_dir), + "evidence": "no not-found page in the project"}), + }); + defaults +} + +/// One site fact that needs a file or a fact only the user has. +fn site_question( + field: &str, + id: &str, + header: &str, + prompt: String, + choices: Vec, +) -> Value { + let mut asked = question(id.into(), header.into(), prompt, choices, "site"); + asked["field"] = json!(field); + asked +} + +/// The site-level questions and the state of each fact, asked in the same +/// up-front rounds as everything else. Never asked when the project already +/// says it, or the project config pre-answers it. +fn site_questions( + evidence: &ProjectEvidence, + assets: &[(String, String, &'static str)], + cover: Option<(&str, &str)>, +) -> (Value, Vec) { + let config = &evidence.config; + let mut site = serde_json::Map::new(); + let mut questions = Vec::new(); + let mut file_question = |field: &str, + id: &str, + header: &str, + what: &str, + files: &[String], + kind: &'static str| { + let mut choices = Vec::new(); + for file in files.iter().take(2) { + choices.push(choice( + format!("Use {file}"), + "Already in the project.", + json!({field: {"source": "file", "path": file}}), + )); + } + let (format, target) = if kind == "logo" { + ("svg", format!("{}/icon.svg", evidence.app_dir)) + } else { + ("png", format!("{}/opengraph-image.png", evidence.app_dir)) + }; + for (node, name, _) in assets.iter().filter(|(_, _, found)| *found == kind).take(2) { + choices.push(choice( + format!("Export \"{}\" ({node}) as {}", truncate(name, 30), format.to_uppercase()), + format!("A Figma node in the linked scope; exported with devup_figma_export assetRequests (format {format}) to {target}."), + json!({field: {"source": "figma", "nodeId": node, "format": format, "path": target}}), + )); + } + if kind == "og" + && let Some((node, name)) = cover + { + choices.push(choice( + format!("Use the top of \"{}\" ({node})", truncate(name, 24)), + format!("Export the first screen as PNG and crop it to 1200x630 for {target}."), + json!({field: {"source": "figma-cover", "nodeId": node, "format": "png", "path": target}}), + )); + } + choices.truncate(MAX_OPTIONS - 2); + choices.push(choice( + "I will provide a file", + "Answer with its path, e.g. public/favicon.ico. Nothing is generated or drawn.", + json!({field: {"source": "provide"}}), + )); + choices.push(choice( + "Leave it for later", + "Not added now; status reports it missing.", + json!({field: {"source": "later"}}), + )); + let evidence_text = + if files.is_empty() && !assets.iter().any(|(_, _, found)| *found == kind) { + "Nothing in the project or the linked Figma scope looks like one.".to_owned() + } else { + "The options come from the project's files and the linked Figma scope.".to_owned() + }; + questions.push(site_question( + field, + id, + header, + format!("Which file should be the site's {what}? {evidence_text}"), + choices, + )); + }; + match &config.favicon { + Some(value) => { + site.insert( + "favicon".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "favicon".into(), + json!({"state": "asked", "question": "site:favicon", "found": evidence.icon_files}), + ); + file_question( + "favicon", + "site:favicon", + "Favicon / app icon", + "favicon and app icon", + &evidence.icon_files, + "logo", + ); + } + } + match &config.og_image { + Some(value) => { + site.insert( + "ogImage".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "ogImage".into(), + json!({"state": "asked", "question": "site:og", "found": evidence.og_files}), + ); + file_question( + "ogImage", + "site:og", + "Share (OG) image", + "share image (Open Graph)", + &evidence.og_files, + "og", + ); + } + } + match (&config.site_name, &evidence.site_name) { + (Some(value), _) => { + site.insert( + "siteName".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + (None, Some((value, from))) => { + site.insert( + "siteName".into(), + json!({"state": "found", "value": value, "evidence": from}), + ); + } + (None, None) => { + site.insert( + "siteName".into(), + json!({"state": "asked", "question": "site:name"}), + ); + let mut choices = Vec::new(); + if let Some(package) = &evidence.package_name { + choices.push(choice( + package.as_str(), + "The package.json name; it may not be the name people see.", + json!({"siteName": {"source": "package", "value": package}}), + )); + } + choices.push(choice( + "Use the main page's heading", + "Taken from the first page's heading text in Figma while building.", + json!({"siteName": {"source": "heading"}}), + )); + choices.push(choice( + "Leave it for later", + "Titles go without a site suffix until it is known.", + json!({"siteName": {"source": "later"}}), + )); + questions.push(site_question( + "siteName", + "site:name", + "Site name", + "What is the site's name, for the title template (\"Page | Name\")? It is not in the project's metadata or environment. A custom answer is the name itself.".into(), + choices, + )); + } + } + match (&config.site_url, &evidence.site_url) { + (Some(value), _) => { + site.insert( + "siteUrl".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + (None, Some((value, from))) => { + site.insert( + "siteUrl".into(), + json!({"state": "found", "value": value, "evidence": from}), + ); + } + (None, None) => { + site.insert( + "siteUrl".into(), + json!({"state": "asked", "question": "site:url"}), + ); + questions.push(site_question( + "siteUrl", + "site:url", + "Production URL", + "What is the production URL, for metadataBase and absolute share links? It is not in the project's metadata or .env files. A custom answer is the URL itself.".into(), + vec![ + choice( + "No production URL yet", + "metadataBase is left out; share images use relative URLs until it is set.", + json!({"siteUrl": {"source": "none"}}), + ), + choice( + "Read it from NEXT_PUBLIC_SITE_URL", + "metadataBase comes from that environment variable, set per deployment.", + json!({"siteUrl": {"source": "env", "name": "NEXT_PUBLIC_SITE_URL"}}), + ), + ], + )); + } + } + if !evidence.analytics.is_empty() { + site.insert( + "analytics".into(), + json!({"state": "asked", "question": "site:analytics", "found": evidence.analytics}), + ); + questions.push(site_question( + "analytics", + "site:analytics", + "Analytics / verification", + format!( + "The project shows analytics or verification setup: {}. Wire it into the new root layout?", + evidence.analytics.join(", ") + ), + vec![ + choice("Keep the existing setup", "Carried into the new layout as it is configured.", json!({"analytics": {"source": "keep"}})), + choice("Leave it out", "The new layout has no analytics or verification tags.", json!({"analytics": {"source": "none"}})), + ], + )); + } else { + site.insert("analytics".into(), json!({"state": "not-needed", "evidence": "no analytics or verification signs in the project"})); + } + if !evidence.motion_libraries.is_empty() { + let libraries = evidence.motion_libraries.join(", "); + site.insert("viewTransitions".into(), json!({"state": "asked", "question": "site:view-transitions", "found": evidence.motion_libraries})); + questions.push(site_question( + "viewTransitions", + "site:view-transitions", + "View transitions", + format!("The project already animates with {libraries}. Add view transitions between pages as well, or keep page motion to {libraries}?"), + vec![ + choice(format!("Keep {libraries} only"), "No view transitions; the existing motion stays the only page motion.", json!({"viewTransitions": {"source": "keep-library"}})), + choice("Add view transitions too", "A short cross-fade between pages, beside the existing library.", json!({"viewTransitions": {"source": "add"}})), + ], + )); + } + match &config.motion { + Some(value) => { + site.insert( + "motion".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "motion".into(), + json!({"state": "asked", "question": "site:motion"}), + ); + questions.push(site_question( + "motion", + "site:motion", + "Motion beyond hover", + "Beyond hover, focus and page transitions, should sections reveal on scroll? Nothing in the plan shows designed motion.".into(), + vec![ + choice("None", "Recommended unless the design shows motion; hover and focus transitions are still added.", json!({"motion": {"source": "none"}})), + choice("Subtle reveal on scroll", "Sections fade and slide in once as they enter; off under prefers-reduced-motion.", json!({"motion": {"source": "reveal"}})), + ], + )); + } + } + (Value::Object(site), questions) +} + +/// The site completeness block of `status`: each item filled, asked and +/// answered, or missing, read from the files as they are now. +fn site_completeness(plan: &Value, evidence: &ProjectEvidence) -> Vec { + let site = &plan["site"]; + let decided = |field: &str| { + let entry = &site[field]; + let source = entry["answer"][field]["source"] + .as_str() + .or_else(|| entry["answer"]["source"].as_str()); + entry["state"] == "config" || (entry["state"] == "answered" && source != Some("later")) + }; + let file_item = |item: &str, field: &str, files: &[String]| { + if !files.is_empty() { + json!({"item": item, "state": "filled", "detail": files.join(", ")}) + } else if decided(field) { + json!({"item": item, "state": "asked-and-answered", "detail": "decided, but no file is in place yet"}) + } else { + json!({"item": item, "state": "missing", "detail": "no file, and no answer that provides one"}) + } + }; + let mut items = vec![ + file_item("favicon", "favicon", &evidence.icon_files), + file_item("og-image", "ogImage", &evidence.og_files), + ]; + for row in array(&plan["routes"]) + .iter() + .filter(|row| row["implementation"] == "page") + { + let url = text(&row["url"]); + let page = evidence + .routes + .iter() + .find(|(pattern, _)| pattern == url) + .map(|(_, page)| page.clone()); + let state = match &page { + Some(page) => match read_small(&join_relative(&evidence.root, page)) { + Some(source) + if devup_mcp_devup_ui::ui_validate::site::exports_metadata(&source) => + { + "filled" + } + _ => "missing", + }, + None => "missing", + }; + items.push(json!({"item": format!("metadata {url}"), "state": state, + "detail": page.unwrap_or_else(|| "page not implemented".into())})); + } + let mut roles = BTreeMap::::new(); + let mut motion = false; + let mut reduced = false; + let mut transitions = false; + for relative in &evidence.sources { + let Some(source) = read_small(&join_relative(&evidence.root, relative)) else { + continue; + }; + for row in devup_mcp_devup_ui::ui_validate::site::role_coverage(relative, &source) { + let entry = roles.entry(row.role).or_default(); + entry.0 += row.total; + entry.1 += row.complete; + } + motion |= source.contains("transition") + || source.contains("transform") + || source.contains("animation"); + reduced |= source.contains("_motionReduce") || source.contains("prefers-reduced-motion"); + transitions |= source.contains("ViewTransition") + || source.contains("viewTransitionName") + || source.contains("@view-transition"); + } + let complete = roles.values().all(|(total, done)| total == done); + items.push(json!({"item": "interaction-roles", + "state": if roles.is_empty() { "not-applicable" } else if complete { "filled" } else { "missing" }, + "detail": if roles.is_empty() { + "no interactive elements in the sources".to_owned() + } else { + roles.iter().map(|(role, (total, done))| format!("{} {done}/{total} {}", role.as_str(), role.requirement())).collect::>().join(", ") + }})); + items.push(json!({"item": "reduced-motion", + "state": if reduced { "filled" } else if motion { "missing" } else { "not-applicable" }, + "detail": if reduced { "prefers-reduced-motion is handled" } else if motion { "transitions or transforms with no prefers-reduced-motion handling" } else { "no motion in the sources" }})); + // Decided again from what is installed now: the plan was drafted before + // the dependency update, and the update is what makes it possible. + let planned = view_transition_default(evidence); + let mechanism = text(&planned["mechanism"]).to_owned(); + let declined = plan["site"]["viewTransitions"]["answer"]["source"] == "keep-library"; + items.push(match mechanism.as_str() { + "skipped" | "" => json!({"item": "view-transitions", "state": "not-applicable", + "detail": format!("skipped: {}", text(&planned["evidence"]))}), + _ if declined => json!({"item": "view-transitions", "state": "not-applicable", + "detail": "skipped: the user kept the project's own page motion"}), + _ if transitions => json!({"item": "view-transitions", "state": "filled", + "detail": format!("enabled ({mechanism}); the runtime audit's view-transition check proves it runs")}), + _ => json!({"item": "view-transitions", "state": "missing", + "detail": format!("planned ({mechanism}) but not in the sources yet")}), + }); + let skipped_update = array(&plan["defaults"]) + .iter() + .any(|default| default["item"] == "dependencies" && default["mechanism"] == "skipped"); + let record = &plan["dependencies"]; + items.push(if skipped_update { + json!({"item": "dependencies", "state": "not-applicable", "detail": format!("not updated: updateDependencies is false in {CONFIG_FILE}")}) + } else if record.is_object() { + let pins = array(&record["pins"]) + .iter() + .map(|pin| format!("{}@{} ({})", text(&pin["package"]), text(&pin["version"]), text(&pin["reason"]))) + .collect::>(); + json!({"item": "dependencies", "state": "filled", + "detail": format!("updated {}: {} packages bumped, {}", text(&record["updatedAt"]), record["bumped"], + if pins.is_empty() { "no pins".to_owned() } else { format!("pinned {}", pins.join(", ")) })}) + } else { + json!({"item": "dependencies", "state": "missing", + "detail": format!("not updated yet: run {UPDATE_STEP}, then record it with assume (dependencyUpdate)")}) + }); + for (item, field) in [("site-name", "siteName"), ("site-url", "siteUrl")] { + let entry = &site[field]; + let state = match entry["state"].as_str() { + Some("found" | "config") => "filled", + Some("answered") if decided(field) => "asked-and-answered", + _ => "missing", + }; + items.push(json!({"item": item, "state": state, "detail": entry.get("value").or(entry.get("answer")).cloned()})); + } + items +} + +/// Names that mark a page, section or frame as not for implementation. Words +/// in ASCII match whole words; the rest match anywhere in the name. +const EXCLUDE_WORDS: &[&str] = &[ + "trash", + "휴지통", + "archive", + "archived", + "아카이브", + "old", + "구버전", + "이전", + "deprecated", + "사용안함", + "미사용", + "backup", + "백업", + "draft", + "wip", + "작업중", + "test", + "테스트", + "sandbox", + "playground", + "copy", + "사본", +]; + +fn keyword_in(name: &str, keyword: &str) -> bool { + let name = name.to_lowercase(); + let keyword = keyword.trim().to_lowercase(); + if keyword.is_empty() { + return false; + } + if keyword.chars().all(|c| c.is_ascii_alphanumeric()) { + name.split(|c: char| !c.is_alphanumeric()) + .any(|word| word == keyword) + } else { + name.contains(&keyword) + } +} + +/// The first exclude keyword in any of `names`, unless an include keyword +/// forces them in. +fn excluded_by<'a>(names: &[(&'a str, &'a str)], config: &PlanConfig) -> Option { + let include = config.include_keywords.as_deref().unwrap_or_default(); + if names + .iter() + .any(|(_, name)| include.iter().any(|keyword| keyword_in(name, keyword))) + { + return None; + } + let defaults = EXCLUDE_WORDS + .iter() + .map(|word| (*word).to_owned()) + .collect::>(); + let keywords = config.exclude_keywords.as_ref().unwrap_or(&defaults); + names.iter().find_map(|(place, name)| { + keywords + .iter() + .find(|keyword| keyword_in(name, keyword)) + .map(|keyword| format!("{place} \"{name}\" contains \"{keyword}\"")) + }) +} + +/// Why a frame is not for implementation, or `None`. The names of the +/// linked node and its ancestors are left out: the user pointed there, which +/// is asked once rather than excluded (see the `scope` question). +fn exclusion_reason( + screen: &PlanScreenInput, + linked: &[String], + config: &PlanConfig, +) -> Option { + if screen.visible == Some(false) { + return Some("hidden in Figma".into()); + } + if screen.opacity.is_some_and(|opacity| opacity <= 0.0) { + return Some("zero opacity in Figma".into()); + } + let mut names = vec![("frame", screen.name.as_str())]; + if let Some(section) = screen.section_name.as_deref() { + names.push(("section", section)); + } + for ancestor in screen + .breadcrumb + .split_last() + .map(|(_, ancestors)| ancestors) + .unwrap_or_default() + { + names.push(("parent", ancestor.as_str())); + } + names.retain(|(place, name)| *place == "frame" || !linked.iter().any(|link| link == name)); + let mut seen = BTreeSet::new(); + names.retain(|(_, name)| seen.insert(*name)); + excluded_by(&names, config) +} + +/// One devup_figma_explore candidate node as a screen to plan. Its parent +/// counts as its section only when the breadcrumb shows one between the page +/// and the frame; a frame drawn straight on the page is in no section. +pub(super) fn screen_from_explore(node: &Value) -> Option { + let breadcrumb = array(&node["breadcrumb"]) + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>(); + let parent = node["parentId"].as_str().map(str::to_owned); + let in_section = breadcrumb.len() >= 3; + Some(PlanScreenInput { + node_id: node["nodeId"].as_str()?.to_owned(), + name: text(&node["name"]).to_owned(), + width: node["bounds"]["width"].as_f64()?, + height: node["bounds"]["height"].as_f64(), + section: parent.clone().filter(|_| in_section), + section_name: in_section.then(|| breadcrumb[breadcrumb.len() - 2].clone()), + ancestors: parent.into_iter().collect(), + visible: node["visible"].as_bool(), + opacity: node["opacity"].as_f64(), + breadcrumb, + ..PlanScreenInput::default() + }) +} + +/// Whether a frame is under the node the user linked. A frame that carries +/// no ancestry at all came from the caller's own list and is taken as given; +/// one whose ancestry is known and does not reach the link is never a +/// candidate. +pub(super) fn in_scope( + screen: &PlanScreenInput, + anchor_id: Option<&str>, + anchor_name: Option<&str>, +) -> bool { + let Some(anchor) = anchor_id else { + return true; + }; + if screen.node_id == anchor + || screen.section.as_deref() == Some(anchor) + || screen.ancestors.iter().any(|id| id == anchor) + { + return true; + } + let ancestor_names = screen + .breadcrumb + .split_last() + .map(|(_, ancestors)| ancestors) + .unwrap_or_default(); + if anchor_name.is_some_and(|name| ancestor_names.iter().any(|ancestor| ancestor == name)) { + return true; + } + screen.section.is_none() && screen.ancestors.is_empty() && screen.breadcrumb.is_empty() +} + +fn with_when(mut question: Value, conditions: &[Value]) -> Value { + if !conditions.is_empty() { + let mut all = array(&question["when"]).to_vec(); + all.extend(conditions.iter().cloned()); + question["when"] = json!(all); + } + question +} + +const SECTIONS_SEPARATE: &str = "Separate pages: the sections are different pages"; +const SECTIONS_SINGLE: &str = "One responsive page: the sections only split by device"; +const KEEP_EXCLUDED: &str = "Keep it excluded"; +const BUILD_IT: &str = "Build it"; +const ACCEPT_EXCLUSIONS: &str = "Accept the exclusions"; +const PLAN_ANYWAY: &str = "Plan them: this is what I linked"; +const NOTHING_HERE: &str = "Nothing here is for implementation"; + +/// One route the evidence names, what kind of evidence it is, and where. +#[derive(Debug, Clone)] +struct RouteHit { + route: String, + kind: &'static str, + detail: String, +} + +/// `name-match` is the only kind that is a guess: a layer name compared with +/// a route segment. Every other kind is something the project or the caller +/// already says. +const NAME_MATCH: &str = "name-match"; + +/// Every route the evidence supports for these frames. +fn route_hits( + frames: &[Frame], + words: &[String], + targets: &BTreeMap, + evidence: &ProjectEvidence, + notes: &mut Vec, +) -> Vec { + let mut hits = Vec::new(); + for frame in frames { + if let Some(target) = targets.get(&frame.node_id) { + match normalize_route(target) { + Some(route) => hits.push(RouteHit { + route, + kind: "navigationTargets", + detail: format!("navigationTargets[{}]", frame.node_id), + }), + None => notes.push(format!( + "navigationTargets[{}] = {target:?} is not an app route and was not used.", + frame.node_id + )), + } + } + if let Some((route, file)) = evidence.figma_nodes.get(&frame.node_id) { + hits.push(RouteHit { + route: route.clone(), + kind: "figma-node", + detail: format!("data-figma-node-id=\"{}\" in {file}", frame.node_id), + }); + } + for (alias, route) in &evidence.config.route_aliases { + if alias == &frame.name || compact(alias) == compact(&words.join("")) { + hits.push(RouteHit { + route: route.clone(), + kind: "config", + detail: format!("{CONFIG_FILE} routeAliases[{alias:?}]"), + }); + } + } + } + let name = compact(&words.join("")); + for (label, href, location) in &evidence.nav_links { + if compact(label).is_empty() || compact(label) != name { + continue; + } + if let Some((route, _)) = evidence + .routes + .iter() + .find(|(route, _)| super::navigation::matches_route(route, href)) + { + hits.push(RouteHit { + route: route.clone(), + kind: "nav-label", + detail: format!("nav-label: {label:?} -> {href} ({location})"), + }); + } + } + if hits.is_empty() { + let screen_slug = slug(words); + let is_root = words.iter().any(|word| ROOT_WORDS.contains(&word.as_str())); + for (route, page) in &evidence.routes { + // The last segment only: `/news/[id]` is a detail page, and a + // screen named News is the list, not that page. + let last = route + .trim_matches('/') + .rsplit('/') + .next() + .unwrap_or_default(); + if (!screen_slug.is_empty() && last == screen_slug) || (route == "/" && is_root) { + hits.push(RouteHit { + route: route.clone(), + kind: NAME_MATCH, + detail: format!("layer name matches the existing route {route} ({page})"), + }); + } + } + } + hits +} + +/// The route the hits agree on, if they agree. +fn agreed(hits: &[RouteHit]) -> Option<&RouteHit> { + let distinct = hits + .iter() + .map(|hit| hit.route.as_str()) + .collect::>(); + (distinct.len() == 1).then(|| &hits[0]) +} + +fn existing_page<'a>(route: &str, evidence: &'a ProjectEvidence) -> Option<&'a str> { + evidence + .routes + .iter() + .find(|(candidate, _)| candidate == route) + .map(|(_, page)| page.as_str()) +} + +fn planned_page(route: &str, app_dir: &str) -> String { + let route = route.trim_matches('/'); + if route.is_empty() { + format!("{app_dir}/page.tsx") + } else { + format!("{app_dir}/{route}/page.tsx") + } +} + +/// A condition on another question's answer: this question is asked only +/// when that one resolved to `action`. The caller sees the label. +fn when(question: &str, label: &str, action: Value) -> Value { + json!({"question": question, "answer": label, "action": action}) +} + +const SINGLE: &str = "One responsive page"; +const SEPARATE: &str = "Separate pages"; +const ACCEPT_ALL: &str = "Accept all"; +const REVIEW: &str = "Review individually"; + +/// Builds the draft plan. Pure: every input is passed in, so the rules are +/// tested without Figma or a project on disk. +#[cfg(test)] +pub(super) fn draft_plan( + screens: &[PlanScreenInput], + targets: &BTreeMap, + evidence: &ProjectEvidence, + source: Value, + notes: Option<&str>, +) -> Result { + draft_plan_with(screens, &[], &[], targets, evidence, source, notes) +} + +/// [`draft_plan`] with Figma nodes that may become the favicon or share +/// image. +pub(super) fn draft_plan_with( + screens: &[PlanScreenInput], + assets: &[PlanAssetInput], + extra: &[ExtraQuestionInput], + targets: &BTreeMap, + evidence: &ProjectEvidence, + source: Value, + notes: Option<&str>, +) -> Result { + if screens.is_empty() { + return Err(invalid( + "draft found no screens. Link a Section or frame, or pass screens from devup_figma_explore.", + )); + } + if screens.len() > MAX_SCREENS { + return Err(invalid(format!( + "draft plans at most {MAX_SCREENS} screens; link one Section at a time." + ))); + } + // Only what the user pointed at, and never what is marked as not for + // implementation. Both are listed, not dropped. + let anchor_id = source["nodeId"].as_str(); + let anchor_name = source["anchorName"].as_str(); + let linked = array(&source["anchorBreadcrumb"]) + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .chain(anchor_name.map(str::to_owned)) + .collect::>(); + let mut out_of_scope = Vec::new(); + let mut excluded = Vec::new(); + let mut kept = Vec::new(); + for screen in screens { + if !in_scope(screen, anchor_id, anchor_name) { + out_of_scope.push(json!({"nodeId": screen.node_id, "name": screen.name, + "reason": "not under the linked node; never a candidate"})); + continue; + } + match exclusion_reason(screen, &linked, &evidence.config) { + Some(reason) => excluded.push((screen, reason)), + None => kept.push(screen.clone()), + } + } + if kept.is_empty() && excluded.is_empty() { + return Err(invalid( + "No screen is under the linked node. Link the Section or frame to plan; the plan never widens past it.", + )); + } + let mut questions = Vec::new(); + let mut base_when = Vec::new(); + let linked_names = linked + .iter() + .map(|name| ("linked node", name.as_str())) + .collect::>(); + if let Some(reason) = excluded_by(&linked_names, &evidence.config) { + questions.push(question( + "scope".into(), + "Linked area: build it?".into(), + format!( + "The link you gave points inside an area whose name marks it as not for implementation: {reason}. Plan the screens under it anyway?" + ), + vec![ + choice( + PLAN_ANYWAY, + "You linked this area on purpose; plan its screens.", + json!({"scope": "use"}), + ), + choice( + NOTHING_HERE, + "Build nothing from this link.", + json!({"scope": "none"}), + ), + ], + "scope", + )); + base_when.push(when("scope", PLAN_ANYWAY, json!({"scope": "use"}))); + } + if !excluded.is_empty() { + let listed = excluded + .iter() + .take(8) + .map(|(screen, reason)| format!("\"{}\" ({}): {reason}", screen.name, screen.node_id)) + .collect::>() + .join("; "); + let more = if excluded.len() > 8 { + format!("; and {} more listed in the plan", excluded.len() - 8) + } else { + String::new() + }; + questions.push(with_when( + question( + "exclusions".into(), + "Excluded areas".into(), + format!( + "These {} frames were excluded as trash, archive, draft, copy or hidden, and will not be built: {listed}{more}. Accept the exclusions?", + excluded.len() + ), + vec![ + choice( + ACCEPT_EXCLUSIONS, + "None of them is built; they stay listed in the plan.", + json!({"exclusions": "accept"}), + ), + choice( + REVIEW, + "Ask about each one in the next round.", + json!({"exclusions": "review"}), + ), + ], + "exclusions", + ), + &base_when, + )); + let review = when("exclusions", REVIEW, json!({"exclusions": "review"})); + for (screen, reason) in &excluded { + let id = &screen.node_id; + let conditions = [base_when.clone(), vec![review.clone()]].concat(); + questions.push(with_when( + question( + format!("exclusion:{id}"), + header("Excluded", &screen.name), + format!( + "\"{}\" ({id}) was excluded: {reason}. Keep it out of the build?", + screen.name + ), + vec![ + choice( + KEEP_EXCLUDED, + "Not built; listed as excluded.", + json!({"build": false}), + ), + choice( + BUILD_IT, + "Build it; its URL is asked next.", + json!({"build": true}), + ), + ], + "exclusion", + ), + &conditions, + )); + let screen_slug = slug(&base_words(&screen.name)); + let mut choices = Vec::new(); + if !screen_slug.is_empty() { + choices.push(choice( + format!("/{screen_slug}"), + "Named after the layer.", + json!({"route": format!("/{screen_slug}")}), + )); + } + choices.push(choice( + SKIP, + "Leave it out after all.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let built = [ + conditions, + vec![when( + &format!("exclusion:{id}"), + BUILD_IT, + json!({"build": true}), + )], + ] + .concat(); + questions.push(with_when( + question( + format!("exclusion-route:{id}"), + header("Route", &screen.name), + format!("Which URL should \"{}\" ({id}) be served at?", screen.name), + choices, + "route", + ), + &built, + )); + } + } + let excluded_rows = excluded + .iter() + .map(|(screen, reason)| { + json!({"nodeId": screen.node_id, "name": screen.name, "width": screen.width, + "section": screen.section_name.clone().or_else(|| screen.section.clone()), + "reason": reason}) + }) + .collect::>(); + // A frame named like a logo or a share image is a site asset, not a page. + let mut site_assets = assets + .iter() + .filter_map(|asset| { + asset_kind(&asset.name).map(|kind| (asset.node_id.clone(), asset.name.clone(), kind)) + }) + .collect::>(); + kept.retain(|screen| match asset_kind(&screen.name) { + Some(kind) => { + site_assets.push((screen.node_id.clone(), screen.name.clone(), kind)); + false + } + None => true, + }); + for asked in extra_questions(extra)? { + questions.push(with_when(asked, &base_when)); + } + let (screen_apps, app_questions, app_default) = assign_apps(evidence, &kept); + for asked in app_questions { + questions.push(with_when(asked, &base_when)); + } + // One app for the whole plan: its routes are the only route evidence. + let chosen = screen_apps + .values() + .map(|app| app.dir.clone()) + .collect::>(); + let scoped; + let evidence = match (chosen.len(), screen_apps.values().next()) { + (1, Some(app)) + if app_default + .as_ref() + .is_some_and(|item| item["mechanism"] == "decided") => + { + let mut narrowed = evidence.clone(); + narrowed + .routes + .retain(|(_, page)| page.starts_with(&format!("{}/", app.dir))); + narrowed.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); + narrowed.target_app = Some(app.clone()); + scoped = narrowed; + &scoped + } + _ => evidence, + }; + // A mixed file builds into several apps: each screen's routes come from + // its own app, never from another app's pages. + let per_app = if chosen.len() > 1 { + chosen + .iter() + .filter_map(|dir| { + let app = screen_apps.values().find(|app| &app.dir == dir)?; + let mut narrowed = evidence.clone(); + narrowed + .routes + .retain(|(_, page)| page.starts_with(&format!("{dir}/"))); + narrowed.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); + narrowed.target_app = Some(app.clone()); + Some((dir.clone(), narrowed)) + }) + .collect::>() + } else { + BTreeMap::new() + }; + let evidence_for = |frames: &[Frame]| -> &ProjectEvidence { + frames + .first() + .and_then(|frame| screen_apps.get(&frame.node_id)) + .and_then(|app| per_app.get(&app.dir)) + .unwrap_or(evidence) + }; + for asked in stack_questions(evidence) { + questions.push(with_when(asked, &base_when)); + } + let defaults = [ + app_default.into_iter().collect::>(), + stack_defaults(evidence), + site_defaults(evidence, &kept), + ] + .concat(); + let cover = kept + .first() + .map(|screen| (screen.node_id.clone(), screen.name.clone())); + let (site, site_asked) = site_questions( + evidence, + &site_assets, + cover + .as_ref() + .map(|(node, name)| (node.as_str(), name.as_str())), + ); + for asked in site_asked { + questions.push(with_when(asked, &base_when)); + } + let mut drafts = group_screens(&kept, &evidence.config); + for draft in &mut drafts { + draft.extra_when.extend(base_when.iter().cloned()); + } + for oddity in structure_questions(&drafts) { + let first = drafts[oddity.screens[0]].id().to_owned(); + let id = format!("structure:{}:{first}", oddity.kind); + let frames = oddity + .screens + .iter() + .flat_map(|index| drafts[*index].frames.iter().map(Frame::json)) + .collect::>(); + let mut asked = question( + id.clone(), + header("Figma structure", drafts[oddity.screens[0]].name()), + format!( + "{} Is that intended, or should the Figma file be fixed? This plan does not decide which frame is right.", + oddity.facts + ), + vec![ + choice( + oddity.keep, + oddity.keep_description.clone(), + json!({"structure": "keep"}), + ), + choice( + FIX_FIGMA, + "These frames are not built. They are listed under Design fixes requested; everything else goes on.", + json!({"designFix": true}), + ), + ], + "structure", + ); + asked["fix"] = json!({"frames": frames, "text": oddity.fix}); + questions.push(with_when(asked, &base_when)); + for index in &oddity.screens { + drafts[*index] + .extra_when + .push(when(&id, oddity.keep, json!({"structure": "keep"}))); + drafts[*index].structure.push(id.clone()); + } + } + let mut screen_notes = vec![Vec::new(); drafts.len()]; + let mut decided = drafts + .iter() + .enumerate() + .map(|(index, screen)| { + let hits = route_hits( + &screen.frames, + &screen.words, + targets, + evidence_for(&screen.frames), + &mut screen_notes[index], + ); + let proposed = agreed(&hits).map(|hit| (hit.route.clone(), hit.kind)); + (hits, proposed) + }) + .collect::>(); + // Two screens on one URL is a decision, not a fact: neither keeps it unasked. + let mut owners = BTreeMap::>::new(); + for (index, (_, proposed)) in decided.iter().enumerate() { + if let Some((route, _)) = proposed { + owners.entry(route.clone()).or_default().push(index); + } + } + for indices in owners.values().filter(|indices| indices.len() > 1) { + for index in indices { + if let Some((route, _)) = decided[*index].1.take() { + screen_notes[*index].push(format!( + "{route} is also the route of another screen in this plan." + )); + } + } + } + let claimed = decided + .iter() + .filter_map(|(_, proposed)| proposed.as_ref().map(|(route, _)| route.clone())) + .collect::>(); + let modal_words = evidence + .config + .modal_keywords + .clone() + .unwrap_or_else(|| MODAL_WORDS.iter().map(|word| (*word).to_owned()).collect()); + let kind_evidence = if evidence.config.modal_keywords.is_some() { + "config" + } else { + NAME_MATCH + }; + let is_modal = |name: &str| { + let lowered = name.to_lowercase(); + modal_words + .iter() + .any(|word| lowered.contains(&word.to_lowercase())) + }; + let page_screens = drafts + .iter() + .enumerate() + .filter(|(_, screen)| !is_modal(screen.name())) + .map(|(index, _)| index) + .collect::>(); + let frame_owner = drafts + .iter() + .enumerate() + .flat_map(|(index, screen)| { + screen + .frames + .iter() + .map(move |frame| (frame.node_id.clone(), index)) + }) + .collect::>(); + let route_choice = |evidence: &ProjectEvidence, route: &str, why: &str, extra: Value| { + let description = match existing_page(route, evidence) { + Some(page) => format!("{why} Exists at {page}; the screen is merged into it."), + None => format!( + "{why} New page at {}.", + planned_page(route, &evidence.app_dir) + ), + }; + let mut action = json!({"route": route}); + if let (Some(object), Some(extra)) = (action.as_object_mut(), extra.as_object()) { + object.extend(extra.clone()); + } + choice(route, description, action) + }; + let unclaimed_for = + |evidence: &ProjectEvidence, words: &[String], exclude: &BTreeSet| { + let mut unclaimed = evidence + .routes + .iter() + .filter(|(route, _)| !claimed.contains(route) && !exclude.contains(route)) + .map(|(route, _)| { + let score = route + .trim_matches('/') + .split(['/', '-']) + .filter(|segment| words.iter().any(|word| word == segment)) + .count(); + (score, route.clone()) + }) + .collect::>(); + unclaimed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + unclaimed + }; + let mut rows = Vec::new(); + let mut heuristics = Vec::new(); + let mut part_screens = BTreeMap::>::new(); + for (index, screen) in drafts.iter().enumerate() { + let evidence = evidence_for(&screen.frames); + let (hits, proposed) = &decided[index]; + let modal = is_modal(screen.name()); + let id = screen.id().to_owned(); + let name = screen.name().to_owned(); + let widths = screen.widths(); + let mut decisions = Vec::new(); + let candidates = hits + .iter() + .map(|hit| hit.route.clone()) + .collect::>(); + let screen_slug = slug(&screen.words); + let new_route = (!screen_slug.is_empty()).then(|| format!("/{screen_slug}")); + // Questions about this screen as one page apply only if the group is + // one page; when that is still being asked, they carry the condition. + let group_id = screen.grouping_question().unwrap_or_default(); + let (single_label, separate_label) = match screen.grouping { + Grouping::Sections { .. } => (SECTIONS_SINGLE, SECTIONS_SEPARATE), + _ => (SINGLE, SEPARATE), + }; + let mut single_when = screen.extra_when.clone(); + if !matches!(screen.grouping, Grouping::Fixed(_)) { + single_when.push(when(&group_id, single_label, json!({"group": "single"}))); + } + let push = |questions: &mut Vec, mut question: Value, extra: &[Value]| { + let conditions = single_when.iter().chain(extra).cloned().collect::>(); + if !conditions.is_empty() { + question["when"] = json!(conditions); + } + questions.push(question); + }; + let grouping_question = match &screen.grouping { + Grouping::Fixed(_) => None, + Grouping::Ask { + recommend_single, + reason, + } => { + let single = choice( + SINGLE, + "One URL; the widths become devup-ui responsive arrays (responsiveTsx).", + json!({"group": "single"}), + ); + let separate = choice( + SEPARATE, + "Each frame is its own page and gets its own route, asked next.", + json!({"group": "separate"}), + ); + Some(question( + group_id.clone(), + header("Pages", &name), + format!( + "These frames share the name \"{name}\" and are {reason}: {}. One responsive page, or separate pages? A custom answer may list one route per frame, e.g. \"/a, /b\".", + describe_frames(&screen.frames) + ), + if *recommend_single { + vec![single, separate] + } else { + vec![separate, single] + }, + "group", + )) + } + Grouping::Sections { + all_device, + sections, + why, + } => { + let separate = choice( + SECTIONS_SEPARATE, + "Each frame is its own page; its URL is asked next.", + json!({"group": "separate"}), + ); + let single = choice( + SECTIONS_SINGLE, + if *all_device { + format!( + "Inferred from the section names ({}), which name only devices.", + sections.join(", ") + ) + } else { + "The sections would only split one page by device.".to_owned() + }, + json!({"group": "single"}), + ); + let fix = choice( + FIX_FIGMA, + "These frames are not built. They are listed under Design fixes requested; everything else goes on.", + json!({"designFix": true}), + ); + let mut asked = question( + group_id.clone(), + header("Sections", &name), + format!( + "Frames that look like the same screen sit in different sections ({}): {}. They look related because of {why}. Which is it? A custom answer may list one route per frame, e.g. \"/a, /b\".", + sections.join(", "), + describe_frames(&screen.frames) + ), + if *all_device { + vec![single, separate, fix] + } else { + vec![separate, single, fix] + }, + "group", + ); + asked["fix"] = json!({ + "frames": screen.frames.iter().map(Frame::json).collect::>(), + "text": format!( + "Frames {} share the name \"{name}\" but sit in sections {}. Move them into one section if they are one page, or rename them if they are different pages.", + describe_frames(&screen.frames), + sections.join(", ") + ), + }); + Some(asked) + } + }; + if let Some(asked) = grouping_question { + decisions.push("one-page-or-separate"); + questions.push(with_when(asked, &screen.extra_when)); + for frame in &screen.frames { + let mut frame_notes = Vec::new(); + let frame_hits = route_hits( + std::slice::from_ref(frame), + &screen.words, + targets, + evidence, + &mut frame_notes, + ); + let mut choices = Vec::new(); + for hit in frame_hits.iter().filter(|hit| hit.kind != NAME_MATCH) { + choices.push(route_choice( + evidence, + &hit.route, + &format!("From {}.", hit.detail), + json!({}), + )); + } + for hit in frame_hits.iter().filter(|hit| hit.kind == NAME_MATCH) { + choices.push(route_choice( + evidence, + &hit.route, + "Inferred from the layer name; only one frame can take it.", + json!({}), + )); + } + let section_slug = frame + .section_name + .as_deref() + .map(|section| slug(&base_words(section))) + .unwrap_or_default(); + if !section_slug.is_empty() && !screen_slug.is_empty() { + choices.push(route_choice( + evidence, + &format!("/{section_slug}/{screen_slug}"), + "Named after its Figma section and layer.", + json!({}), + )); + } + for (_, route) in unclaimed_for(evidence, &screen.words, &candidates) + .iter() + .filter(|(score, _)| *score > 0) + { + choices.push(route_choice( + evidence, + route, + "Existing route with a similar name.", + json!({}), + )); + } + choices.truncate(MAX_OPTIONS - 1); + choices.push(choice( + SKIP, + "Leave this frame out of this build.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let asked = question( + format!("split-route:{}", frame.node_id), + header("Route", &format!("{} {}", frame.name, frame.width.round())), + format!( + "As a separate page, which URL should \"{}\" ({}, {}px) be served at?", + frame.name, + frame.node_id, + frame.width.round() + ), + choices, + "route", + ); + let separate_when = [ + screen.extra_when.clone(), + vec![when( + &group_id, + separate_label, + json!({"group": "separate"}), + )], + ] + .concat(); + questions.push(with_when(asked, &separate_when)); + } + } + let route_evidence = match proposed { + Some((_, kind)) if *kind == NAME_MATCH => json!(NAME_MATCH), + Some(_) => json!( + hits.iter() + .map(|hit| hit.detail.clone()) + .collect::>() + .join("; ") + ), + None => json!("UNVERIFIED"), + }; + if modal { + decisions.push("modal-or-route"); + let mut hosts = page_screens + .iter() + .filter(|other| **other != index) + .map(|other| &drafts[*other]) + .map(|host| { + let shared = host + .words + .iter() + .filter(|word| screen.words.contains(word)) + .count(); + (shared, host) + }) + .collect::>(); + hosts.sort_by_key(|host| std::cmp::Reverse(host.0)); + let state_choices = hosts + .iter() + .take(2) + .map(|(_, host)| { + choice( + format!("State on {}", truncate(host.name(), 40)), + format!( + "Open/close state inside the page built from {} ({}); no URL of its own.", + host.name(), + host.id() + ), + json!({"state": host.id()}), + ) + }) + .collect::>(); + let own_route = proposed + .as_ref() + .map(|(route, _)| { + route_choice(evidence, route, "A page with its own URL.", json!({})) + }) + .or_else(|| { + new_route.as_ref().map(|route| { + route_choice( + evidence, + route, + "A page with its own URL, named after the layer.", + json!({}), + ) + }) + }); + let mut choices = Vec::new(); + if proposed + .as_ref() + .is_some_and(|(_, kind)| *kind != NAME_MATCH) + { + choices.extend(own_route); + choices.extend(state_choices); + } else { + choices.extend(state_choices); + choices.extend(own_route); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + let why = if kind_evidence == NAME_MATCH { + "Its layer name suggests a modal or overlay (name-match)" + } else { + "Its layer name matches the project's modalKeywords" + }; + push( + &mut questions, + question( + format!("modal:{id}"), + header("Modal", &name), + format!( + "\"{name}\" ({id}): {why}. Is it a state of another screen, or a page with its own URL?" + ), + choices, + "modal", + ), + &[], + ); + if kind_evidence == NAME_MATCH { + heuristics.push(json!({"screen": id, "name": name, "decision": "kind: modal", "evidence": NAME_MATCH, "askedIn": format!("modal:{id}")})); + } + } else if let Some((route, NAME_MATCH)) = proposed { + decisions.push("route-from-name-match"); + heuristics.push(json!({"screen": id, "name": name, "decision": format!("route: {route}"), "evidence": NAME_MATCH, "askedIn": format!("review:{id}")})); + let mut choices = vec![ + route_choice( + evidence, + route, + "Keep the inferred route.", + json!({"collision": "merge"}), + ), + choice( + format!("Overwrite {route}"), + "Keep the inferred route, but replace the existing page file outright.", + json!({"route": route, "collision": "overwrite"}), + ), + ]; + if let Some(alternative) = new_route + .as_ref() + .filter(|candidate| *candidate != route && !claimed.contains(*candidate)) + { + choices.push(route_choice( + evidence, + alternative, + "Named after the layer.", + json!({}), + )); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + push( + &mut questions, + question( + format!("review:{id}"), + header("Check route", &name), + format!( + "\"{name}\" ({id}) was matched to {route} by its layer name only. Keep it?" + ), + choices, + "route", + ), + &[when("heuristics", REVIEW, json!({"heuristics": "review"}))], + ); + } else if proposed.is_none() { + decisions.push(if candidates.len() > 1 { + "conflicting-route-evidence" + } else { + "no-route-evidence" + }); + let mut choices = Vec::new(); + for route in &candidates { + choices.push(route_choice( + evidence, + route, + "Named by conflicting evidence.", + json!({}), + )); + } + let unclaimed = unclaimed_for(evidence, &screen.words, &candidates); + for (_, route) in unclaimed.iter().filter(|(score, _)| *score > 0) { + choices.push(route_choice( + evidence, + route, + "Existing route with a similar name.", + json!({}), + )); + } + if let Some(route) = new_route.as_ref().filter(|route| !claimed.contains(*route)) { + choices.push(route_choice( + evidence, + route, + "Named after the layer.", + json!({}), + )); + } + for (_, route) in unclaimed.iter().filter(|(score, _)| *score == 0).take(1) { + choices.push(route_choice( + evidence, + route, + "Existing route not claimed by another screen.", + json!({}), + )); + } + choices.truncate(MAX_OPTIONS - 1); + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let reason = if candidates.len() > 1 { + format!( + "The evidence names {} different routes.", + candidates.iter().cloned().collect::>().join(", ") + ) + } else { + "No existing page, nav link, navigationTargets entry or routeAliases entry names it." + .to_owned() + }; + push( + &mut questions, + question( + format!("route:{id}"), + header("Route", &name), + format!( + "Which URL should the Figma screen \"{name}\" ({id}, drawn at {}) be served at? {reason}", + width_list(&widths) + ), + choices, + "route", + ), + &[], + ); + } + let route = proposed.as_ref().map(|(route, _)| route.as_str()); + let existing = route.and_then(|route| existing_page(route, evidence)); + let name_matched = proposed + .as_ref() + .is_some_and(|(_, kind)| *kind == NAME_MATCH); + if !modal + && !name_matched + && let (Some(route), Some(page)) = (route, existing) + { + decisions.push("collides-with-existing-page"); + push( + &mut questions, + question( + format!("collision:{id}"), + header("Existing page", route), + format!( + "{route} already has a page ({page}). What should building \"{name}\" ({id}) do to it?" + ), + vec![ + choice( + "Merge into the existing page", + "Keep the page file and its data wiring; replace its markup with the screen.", + json!({"collision": "merge"}), + ), + choice( + "Overwrite the existing page", + "Replace the page file outright; existing wiring is lost.", + json!({"collision": "overwrite"}), + ), + choice( + SKIP, + "Leave the existing page as it is.", + json!({"collision": "skip"}), + ), + ], + "collision", + ), + &[], + ); + } + if let Some(route) = route.filter(|route| route.contains('[')) { + decisions.push("dynamic-segment"); + push( + &mut questions, + question( + format!("dynamic:{id}"), + header("Dynamic route", route), + format!( + "{route} has a dynamic segment. Where does \"{name}\" get the record it shows?" + ), + vec![ + choice( + "Sample data from the design", + "Render the Figma content as fixture data for one id; wire real data later.", + json!({"data": "sample"}), + ), + choice( + "Wire to the API now", + "Fetch the record by the segment value; needs the endpoint from devup_project_context scope api.", + json!({"data": "api"}), + ), + ], + "dynamic", + ), + &[], + ); + } + if widths.len() > 1 { + decisions.push("several-widths"); + } + let mut links = Vec::new(); + for (link_index, link) in screen.links.iter().enumerate() { + let resolved = link + .url + .as_deref() + .map(|url| json!({"url": url, "evidence": "figma-url"})) + .or_else(|| { + let destination = link.destination_node_id.as_deref()?; + targets + .get(destination) + .map(|url| json!({"url": url, "evidence": format!("navigationTargets[{destination}]")})) + .or_else(|| { + frame_owner.get(destination).map(|owner| { + json!({"screen": drafts[*owner].id(), "evidence": "destination frame in this plan"}) + }) + }) + }); + if resolved.is_none() { + decisions.push("unresolved-link"); + let mut choices = vec![choice( + "Leave unlinked", + "Render it without an href; wire it once the destination exists.", + json!({"link": null}), + )]; + for (route, _) in evidence.routes.iter().take(MAX_OPTIONS - 1) { + choices.push(choice( + route.as_str(), + format!("Link to the existing route {route}."), + json!({"link": route}), + )); + } + if choices.len() < 2 { + choices.push(choice( + "Remove the link", + "Render the label as plain text.", + json!({"link": "remove"}), + )); + } + questions.push(with_when(question( + format!("link:{id}:{link_index}"), + header("Link", &link.label), + format!( + "\"{}\" on \"{name}\" ({id}) links somewhere this plan cannot see{}. Where should it go?", + link.label, + link.destination_node_id + .as_deref() + .map(|node| format!(" (Figma destination {node})")) + .unwrap_or_default() + ), + choices, + "link", + ), &screen.extra_when)); + } + links.push(json!({"label": link.label, "destinationNodeId": link.destination_node_id, "resolved": resolved})); + } + let mut shared = evidence.layout_parts.clone(); + for part in &screen.parts { + part_screens + .entry(part.clone()) + .or_default() + .push(id.clone()); + if !shared.contains(part) { + shared.push(part.clone()); + } + } + rows.push(json!({ + "id": id, + "name": name, + "kind": if modal { "modal" } else { "page" }, + "kindEvidence": if modal { json!(kind_evidence) } else { Value::Null }, + "frames": screen.frames.iter().map(Frame::json).collect::>(), + "drawnWidths": widths, + "app": screen_apps.get(screen.id()).map(|app| app.dir.clone()), + "appDir": screen_apps.get(screen.id()).map(|app| app.path(app.app_router.as_deref().unwrap_or("src/app"))), + "groupingQuestion": screen.grouping_question(), + "structureQuestions": screen.structure, + "grouping": match &screen.grouping { + Grouping::Fixed(evidence) => json!({"mode": "decided", "evidence": evidence}), + Grouping::Sections { all_device, sections, why } => json!({"mode": "ask-sections", "question": group_id, + "sections": sections, "allDeviceNames": all_device, "why": why}), + Grouping::Ask { recommend_single, reason } => json!({"mode": "ask", "question": group_id, + "recommended": if *recommend_single { "single" } else { "separate" }, "reason": reason}), + }, + "proposedRoute": route, + "routeEvidence": route_evidence, + "routeCandidates": candidates, + "existingFile": existing, + "sharedParts": shared, + "links": links, + "openDecisions": decisions, + "notes": screen_notes[index], + })); + } + let route_heuristics = heuristics + .iter() + .filter(|item| text(&item["askedIn"]).starts_with("review:")) + .collect::>(); + if !route_heuristics.is_empty() { + let listed = route_heuristics + .iter() + .map(|item| format!("{} -> {}", text(&item["name"]), text(&item["decision"]))) + .collect::>() + .join(", "); + questions.push(question( + "heuristics".into(), + "Inferred from layer names".into(), + format!( + "These were inferred from layer names only: {listed}. Accept all (merged into the existing pages), or review each one?" + ), + vec![ + choice( + ACCEPT_ALL, + "Use every inferred route; existing pages are merged into, not overwritten.", + json!({"heuristics": "accept"}), + ), + choice( + REVIEW, + "Ask one question per inferred route, in the next round.", + json!({"heuristics": "review"}), + ), + ], + "heuristics", + )); + } + for (part, owners) in part_screens.iter().filter(|(part, owners)| { + owners.len() > 1 + && !evidence + .layout_parts + .iter() + .any(|existing| compact(existing) == compact(part)) + }) { + questions.push(question( + format!("layout:{}", compact(part)), + header("Shared part", part), + format!( + "\"{part}\" appears on {} screens and no layout renders it yet. Extract it once into a shared layout?", + owners.len() + ), + vec![ + choice( + "Extract into a shared layout", + "Build it once and render it from the App Router layout.", + json!({"layout": "shared"}), + ), + choice( + "Keep it in each page", + "Every page renders its own copy.", + json!({"layout": "per-page"}), + ), + ], + "layout", + )); + } + // Questions about the whole plan wait on the linked area being planned. + for asked in questions + .iter_mut() + .filter(|asked| asked["id"] == "heuristics" || text(&asked["id"]).starts_with("layout:")) + { + *asked = with_when(asked.clone(), &base_when); + } + let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes}); + let plan_id = format!( + "sp-{}", + &sha256(&serde_json::to_vec(&body).unwrap_or_default())[..16] + ); + Ok(json!({ + "kind": PLAN_KIND, + "schemaVersion": SCHEMA_VERSION, + "planId": plan_id, + "status": "draft", + "source": body["source"], + "notes": notes, + "appDir": evidence.app_dir, + "inventoryNote": evidence.inventory_note, + "config": evidence.config_file, + "screens": body["screens"], + "heuristics": heuristics, + "excluded": excluded_rows, + "outOfScope": out_of_scope, + "site": site, + "defaults": defaults, + "apps": evidence.apps.iter().map(super::framework::App::json).collect::>(), + "targetApp": evidence.target_app.as_ref().map(super::framework::App::json), + "siteAssets": site_assets.iter().map(|(node, name, kind)| json!({"nodeId": node, "name": name, "kind": kind})).collect::>(), + "questions": body["questions"], + "answers": {}, + "routes": [], + "assumptions": [], + })) +} + +/// How deep a question sits behind the answers it depends on. A question is +/// asked in a round after every question it depends on. +fn depth(question: &Value, questions: &[Value]) -> usize { + array(&question["when"]) + .iter() + .filter_map(|condition| { + questions + .iter() + .find(|other| other["id"] == condition["question"]) + }) + .map(|parent| depth(parent, questions) + 1) + .max() + .unwrap_or(0) +} + +/// The questions split into rounds a question tool takes in one call each: +/// at most four questions of at most four options, every question exactly +/// once, and a conditional question always in a later round than the one it +/// depends on. +pub(super) fn question_rounds(questions: &[Value], per_round: usize) -> Vec { + let mut by_depth = BTreeMap::>::new(); + for question in questions { + by_depth + .entry(depth(question, questions)) + .or_default() + .push(question); + } + let mut rounds = Vec::new(); + for level in by_depth.values() { + for chunk in level.chunks(per_round.max(1)) { + let shaped = ask_shape_with( + questions, + &chunk + .iter() + .map(|question| (*question).clone()) + .collect::>(), + ); + rounds.push(json!({"round": rounds.len() + 1, "questions": shaped})); + } + } + rounds +} + +fn plan_path(project_root: &str, relative: &str) -> String { + Path::new(project_root) + .join(relative) + .to_string_lossy() + .into_owned() +} + +/// Writes the plan JSON and its Markdown twin in one transaction, so the two +/// never describe different plans. +fn write_plan( + policy: &OutputPolicy, + project_root: &str, + plan: &Value, +) -> Result { + let json_target = policy.resolve(&plan_path(project_root, PLAN_FILE))?; + let markdown_target = policy.resolve(&plan_path(project_root, PLAN_MARKDOWN))?; + let written = json!([ + json_target.display_path().display().to_string(), + markdown_target.display_path().display().to_string(), + ]); + let bytes = serde_json::to_vec_pretty(plan) + .map_err(|error| invalid(format!("Cannot serialize the screen plan: {error}")))?; + let mut transaction = OutputTransaction::new(); + transaction.stage("screenPlan", json_target, &bytes)?; + transaction.stage( + "screenPlanMarkdown", + markdown_target, + markdown(plan).as_bytes(), + )?; + transaction.commit()?; + Ok(written) +} + +/// Reads `/.devup/screen-plan.json`, or `None` when there is +/// none. A file that exists but is not a plan is an error, never "no plan". +pub(super) fn read_plan(project_root: &str) -> Result, DevupError> { + let path = Path::new(project_root).join(PLAN_FILE); + let Ok(metadata) = std::fs::metadata(&path) else { + return Ok(None); + }; + if metadata.len() > MAX_PLAN_BYTES as u64 { + return Err(invalid(format!( + "{} exceeds the plan size limit.", + path.display() + ))); + } + let bytes = std::fs::read(&path) + .map_err(|error| invalid(format!("Cannot read {}: {error}", path.display())))?; + let plan: Value = serde_json::from_slice(&bytes) + .map_err(|error| invalid(format!("{} is not valid JSON: {error}", path.display())))?; + if plan["kind"] != PLAN_KIND || plan["schemaVersion"] != SCHEMA_VERSION { + return Err(invalid(format!( + "{} is not a devup screen plan; create one with devup_screen_plan action draft.", + path.display() + ))); + } + Ok(Some(plan)) +} + +fn require_root(input: &ScreenPlanInput) -> Result<&str, DevupError> { + input + .project_root + .as_deref() + .filter(|root| !root.trim().is_empty()) + .ok_or_else(|| invalid(format!("action {} needs projectRoot.", input.action))) +} + +/// `draft`: plan the given screens against the project and write both files. +#[cfg(test)] +pub(super) async fn draft( + input: &ScreenPlanInput, + screens: &[PlanScreenInput], + source: Value, + policy: &OutputPolicy, +) -> Result { + draft_with(input, screens, &input.assets, source, policy).await +} + +/// [`draft`] with the Figma asset candidates an explore found beside the +/// screens. +pub(super) async fn draft_with( + input: &ScreenPlanInput, + screens: &[PlanScreenInput], + assets: &[PlanAssetInput], + source: Value, + policy: &OutputPolicy, +) -> Result { + let root = require_root(input)?; + let evidence = project_evidence(root).await?; + let mut plan = draft_plan_with( + screens, + assets, + &input.extra_questions, + &input.navigation_targets, + &evidence, + source, + input.notes.as_deref(), + )?; + let per_round = input + .max_questions_per_round + .unwrap_or(MAX_QUESTIONS_PER_ROUND); + if !(1..=50).contains(&per_round) { + return Err(invalid("maxQuestionsPerRound must be between 1 and 50.")); + } + plan["maxQuestionsPerRound"] = json!(per_round); + plan["createdAt"] = json!(now()); + let replaced = read_plan(root) + .ok() + .flatten() + .filter(|previous| previous["status"] == "confirmed") + .map(|previous| previous["planId"].clone()); + let written = write_plan(policy, root, &plan)?; + let questions = ask_shape(array(&plan["questions"])); + let rounds = question_rounds(array(&plan["questions"]), per_round); + let unverified = array(&plan["screens"]) + .iter() + .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") + .count(); + Ok(json!({ + "status": "draft", + "planId": plan["planId"], + "screens": screen_table(&plan), + "questionRounds": rounds, + "questions": questions, + "questionCount": questions.len(), + "askInstruction": ASK_INSTRUCTION, + "maxQuestionsPerRound": per_round, + "site": plan["site"], + "defaults": plan["defaults"], + "heuristics": plan["heuristics"], + "excluded": plan["excluded"], + "outOfScope": plan["outOfScope"], + "config": plan["config"], + "unverifiedRoutes": unverified, + "inventoryNote": plan["inventoryNote"], + "writtenTo": written, + "replacedConfirmedPlan": replaced, + "nextAction": if questions.is_empty() { + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": {}}, + "how": "Nothing is open. Show the user the screens table, then confirm with empty answers."}) + } else { + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": " chosen label, for every question that applies>"}, + "how": ASK_INSTRUCTION}) + }, + })) +} + +fn strip_recommended(label: &str) -> &str { + label + .trim() + .strip_suffix(RECOMMENDED) + .unwrap_or(label.trim()) +} + +/// The action an answer selects, or the custom text when it matches no +/// option. A custom answer to a question that places a screen must itself be +/// a route; anything else is recorded and the recommended action applies. +fn resolve_answer( + question: &Value, + answer: &AnswerInput, +) -> Result<(Value, Option), DevupError> { + let id = text(&question["id"]); + let values = answer.values(); + if question["multiple"] == true { + let labels = values + .iter() + .map(|value| strip_recommended(value)) + .collect::>(); + return Ok((json!({"extra": labels}), None)); + } + let [value] = values.as_slice() else { + return Err(invalid(format!("{id} takes exactly one answer."))); + }; + let label = strip_recommended(value); + if let Some(found) = array(&question["resolve"]) + .iter() + .find(|option| text(&option["label"]) == label) + { + return Ok((found["action"].clone(), None)); + } + let route_of = |value: &str| normalize_route(value).filter(|route| valid_route(route)); + match text(&question["kind"]) { + "route" | "modal" => route_of(label) + .map(|route| (json!({"route": route}), Some(label.to_owned()))) + .ok_or_else(|| { + invalid(format!( + "{id}: a custom answer must be an app route such as /about, not {label:?}." + )) + }), + // One route is one page at that URL; one route per frame, in frame + // order, is separate pages at those URLs. + "group" => { + let routes = label + .split([',', ';', '\n']) + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| route_of(part.rsplit('=').next().unwrap_or(part).trim())) + .collect::>>() + .ok_or_else(|| { + invalid(format!( + "{id}: a custom answer must be one route, or one route per frame such as \"/a, /b\"; not {label:?}." + )) + })?; + match routes.as_slice() { + [route] => Ok(( + json!({"group": "single", "route": route}), + Some(label.to_owned()), + )), + _ => Ok(( + json!({"group": "separate", "routes": routes}), + Some(label.to_owned()), + )), + } + } + "link" => Ok(( + json!({"link": normalize_route(label)}), + Some(label.to_owned()), + )), + "extra" => Ok((json!({"extra": label}), Some(label.to_owned()))), + // A custom site answer is the file, name or URL itself. + "site" => { + let field = text(&question["field"]); + match field { + "favicon" | "ogImage" => Ok(( + json!({field: {"source": "provide", "path": label}}), + Some(label.to_owned()), + )), + "siteName" | "appName" => Ok(( + json!({field: {"source": "answer", "value": label}}), + Some(label.to_owned()), + )), + "app" => Ok(( + json!({field: {"dir": label, "appDir": format!("{}/src/app", label.trim_end_matches('/'))}}), + Some(label.to_owned()), + )), + "siteUrl" if label.starts_with("https://") || label.starts_with("http://") => Ok(( + json!({field: {"source": "answer", "value": label}}), + Some(label.to_owned()), + )), + "siteUrl" => Err(invalid(format!( + "{id}: a custom answer must be an http(s) URL, not {label:?}." + ))), + _ => Ok(( + array(&question["resolve"]) + .first() + .map(|option| option["action"].clone()) + .unwrap_or(Value::Null), + Some(label.to_owned()), + )), + } + } + _ => Ok(( + array(&question["resolve"]) + .first() + .map(|option| option["action"].clone()) + .unwrap_or(Value::Null), + Some(label.to_owned()), + )), + } +} + +fn per_round(plan: &Value) -> usize { + plan["maxQuestionsPerRound"] + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(MAX_QUESTIONS_PER_ROUND) +} + +/// Whether a question has to be answered, given the answers before it. +fn required(question: &Value, actions: &BTreeMap) -> bool { + array(&question["when"]) + .iter() + .all(|condition| actions.get(text(&condition["question"])) == Some(&condition["action"])) +} + +/// `confirm`: every question that applies answered, in one call, or nothing +/// changes. +pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + let plan_id = input + .plan_id + .as_deref() + .ok_or_else(|| invalid("action confirm needs planId."))?; + if plan["planId"] != plan_id { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "planId does not match the plan on disk; it was redrafted. Ask the questions of the current draft.", + false, + json!({"planId": plan_id, "currentPlanId": plan["planId"]}), + )); + } + let mut questions = array(&plan["questions"]).to_vec(); + let ordered = questions.clone(); + questions.sort_by_key(|question| depth(question, &ordered)); + let known = questions + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect::>(); + let unknown = input + .answers + .keys() + .filter(|id| !known.contains(*id)) + .cloned() + .collect::>(); + let mut actions = BTreeMap::new(); + let mut custom = Vec::new(); + let mut missing = Vec::new(); + for question in &questions { + let id = text(&question["id"]).to_owned(); + if !required(question, &actions) { + continue; + } + let Some(answer) = input.answers.get(&id) else { + missing.push(id); + continue; + }; + let (action, note) = resolve_answer(question, answer)?; + if let Some(note) = note { + custom.push(json!({"question": id, "answer": note})); + } + actions.insert(id, action); + } + if !missing.is_empty() || !unknown.is_empty() { + let pending = questions + .iter() + .filter(|question| missing.iter().any(|id| question["id"] == id.as_str())) + .cloned() + .collect::>(); + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "confirm needs an answer to every question that applies. Missing: {}.", + if missing.is_empty() { + "none".to_owned() + } else { + missing.join(", ") + } + ), + false, + json!({"missing": missing, "unknown": unknown, "askInstruction": ASK_INSTRUCTION, + "questionRounds": question_rounds(&pending, per_round(&plan))}), + )); + } + let accepted = actions + .get("heuristics") + .is_none_or(|action| action["heuristics"] == "accept"); + let nothing_here = actions + .get("scope") + .is_some_and(|action| action["scope"] == "none"); + // Every "the Figma structure is wrong" answer: its frames are not built, + // and the fix is reported. The work goes on without them. + let design_fixes = questions + .iter() + .filter(|question| { + actions + .get(text(&question["id"])) + .is_some_and(|action| action["designFix"] == true) + }) + .map(|question| { + json!({"question": question["id"], "frames": question["fix"]["frames"], + "fix": question["fix"]["text"]}) + }) + .collect::>(); + let fixed = |ids: &[&str]| { + ids.iter().any(|id| { + actions + .get(*id) + .is_some_and(|action| action["designFix"] == true) + }) + }; + let app_dir = actions + .get("site:app") + .and_then(|action| action["app"]["appDir"].as_str()) + .unwrap_or(text(&plan["appDir"])) + .to_owned(); + plan["appDir"] = json!(app_dir); + let mut routes = Vec::new(); + let mut screen_routes = BTreeMap::new(); + let screens = array(&plan["screens"]).to_vec(); + for screen in &screens { + let id = text(&screen["id"]).to_owned(); + let frames = array(&screen["frames"]).to_vec(); + let mut deciding = array(&screen["structureQuestions"]) + .iter() + .map(text) + .collect::>(); + deciding.extend(screen["groupingQuestion"].as_str()); + if nothing_here || fixed(&deciding) { + continue; + } + let chosen_app = actions + .get(&format!("app:{id}")) + .or_else(|| actions.get("site:app")) + .map(|action| action["app"].clone()); + let screen_app = chosen_app + .as_ref() + .and_then(|app| app["dir"].as_str()) + .or(screen["app"].as_str()) + .map(str::to_owned); + let screen_app_dir = chosen_app + .as_ref() + .and_then(|app| app["appDir"].as_str()) + .or(screen["appDir"].as_str()) + .unwrap_or(&app_dir) + .to_owned(); + let group = screen["groupingQuestion"] + .as_str() + .and_then(|question| actions.get(question)); + let separate = group.is_some_and(|action| action["group"] == "separate"); + let data = actions + .get(&format!("dynamic:{id}")) + .map(|action| action["data"].clone()); + // Each placement is (frames, implementation, route, host, collision). + let mut placements = Vec::new(); + if separate { + let explicit = group.and_then(|action| action["routes"].as_array()); + if explicit.is_some_and(|routes| routes.len() != frames.len()) { + return Err(invalid(format!( + "{}: list exactly one route per frame ({} frames).", + text(&screen["groupingQuestion"]), + frames.len() + ))); + } + for (index, frame) in frames.iter().enumerate() { + let node = text(&frame["nodeId"]); + let action = explicit + .map(|routes| json!({"route": routes[index]})) + .or_else(|| actions.get(&format!("split-route:{node}")).cloned()) + .unwrap_or(Value::Null); + placements.push((vec![frame.clone()], action, None)); + } + } else { + let placed = group + .filter(|action| action["route"].is_string()) + .or_else(|| actions.get(&format!("route:{id}"))) + .or_else(|| actions.get(&format!("modal:{id}"))) + .or_else(|| actions.get(&format!("review:{id}"))) + .cloned(); + let action = match placed { + Some(action) => action, + None if screen["routeEvidence"] == NAME_MATCH && accepted => { + json!({"route": screen["proposedRoute"], "collision": "merge", "acceptedHeuristic": true}) + } + None => json!({"route": screen["proposedRoute"]}), + }; + let collision = actions + .get(&format!("collision:{id}")) + .map(|action| text(&action["collision"]).to_owned()); + placements.push((frames.clone(), action, collision)); + } + for (group_frames, action, collision) in placements { + let collision = collision.or_else(|| action["collision"].as_str().map(str::to_owned)); + let (implementation, route, host) = + if action["skip"] == true || collision.as_deref() == Some("skip") { + ("skip", None, None) + } else if action["notPage"] == true { + ("not-a-page", None, None) + } else if let Some(host) = action["state"].as_str() { + ("state", None, Some(host.to_owned())) + } else { + ("page", action["route"].as_str().map(str::to_owned), None) + }; + let first = text(&group_frames[0]["nodeId"]).to_owned(); + if implementation == "page" && route.is_none() { + return Err(invalid(format!( + "{first} has no route after the answers; answer its route question with a route." + ))); + } + if let Some(url) = &route { + screen_routes + .entry(id.clone()) + .or_insert_with(|| url.clone()); + } + let existing = route.as_deref().and_then(|url| { + (screen["proposedRoute"] == url && !screen["existingFile"].is_null()) + .then(|| screen["existingFile"].clone()) + }); + routes.push(json!({ + "url": route, + "screen": if separate { first.clone() } else { id.clone() }, + "name": group_frames[0]["name"], + "nodeIds": group_frames.iter().map(|frame| frame["nodeId"].clone()).collect::>(), + "drawnWidths": group_frames.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), + "implementation": implementation, + "hostScreen": host, + "app": screen_app, + "file": existing.clone().unwrap_or_else(|| route.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &screen_app_dir)))), + "fileExists": existing.is_some(), + "collisionPolicy": collision, + "data": data, + "routeEvidence": if action["acceptedHeuristic"] == true { json!("name-match (accepted)") } else { screen["routeEvidence"].clone() }, + })); + } + } + let mut excluded = Vec::new(); + for row in array(&plan["excluded"]) { + let node = text(&row["nodeId"]).to_owned(); + let built = !nothing_here + && actions + .get(&format!("exclusion:{node}")) + .is_some_and(|action| action["build"] == true); + let action = actions.get(&format!("exclusion-route:{node}")); + match action.and_then(|action| action["route"].as_str()) { + Some(route) if built => routes.push(json!({ + "url": route, "screen": node, "name": row["name"], "nodeIds": [node], + "drawnWidths": [row["width"].as_f64().map(|width| width.round() as i64)], + "implementation": "page", "hostScreen": null, + "file": planned_page(route, &app_dir), "fileExists": false, + "collisionPolicy": null, "data": null, + "routeEvidence": "confirmed answer (was excluded)", + })), + _ => { + let mut row = row.clone(); + row["decision"] = json!("excluded"); + excluded.push(row); + } + } + } + if nothing_here { + for screen in &screens { + for frame in array(&screen["frames"]) { + excluded.push(json!({"nodeId": frame["nodeId"], "name": frame["name"], "width": frame["width"], + "section": frame["sectionName"], "reason": "the linked area is not for implementation (answered)", + "decision": "excluded"})); + } + } + } + // One URL per app: the public site and the console may both have `/`. + let mut seen = BTreeMap::<(String, String), String>::new(); + for row in &routes { + if let Some(url) = row["url"].as_str() + && let Some(other) = seen.insert( + (text(&row["app"]).to_owned(), url.to_owned()), + text(&row["screen"]).to_owned(), + ) + && other != text(&row["screen"]) + { + return Err(invalid(format!( + "Two screens resolve to {url} ({other} and {}); answer their route questions with different URLs.", + text(&row["screen"]) + ))); + } + } + for row in routes + .iter_mut() + .filter(|row| row["implementation"] == "state") + { + row["hostUrl"] = json!(screen_routes.get(text(&row["hostScreen"]))); + } + if let Some(rows) = plan["screens"].as_array_mut() { + for screen in rows { + let id = text(&screen["id"]).to_owned(); + let mut resolved = Vec::new(); + for (index, link) in array(&screen["links"]).iter().enumerate() { + let mut link = link.clone(); + if let Some(action) = actions.get(&format!("link:{id}:{index}")) { + link["resolved"] = + json!({"url": action["link"], "evidence": "confirmed answer"}); + } else if let Some(target) = link["resolved"]["screen"].as_str() { + link["resolved"]["url"] = json!(screen_routes.get(target)); + } + resolved.push(link); + } + screen["links"] = json!(resolved); + } + } + plan["answers"] = json!(input.answers); + plan["customAnswers"] = json!(custom); + plan["heuristicsAccepted"] = json!(accepted); + plan["layout"] = json!( + actions + .iter() + .filter(|(id, _)| id.starts_with("layout:")) + .map(|(id, action)| (id.clone(), action["layout"].clone())) + .collect::>() + ); + for asked in questions + .iter() + .filter(|asked| text(&asked["id"]).starts_with("site:")) + { + let field = text(&asked["field"]).to_owned(); + if let Some(action) = actions.get(text(&asked["id"])) { + plan["site"][&field]["state"] = json!("answered"); + plan["site"][&field]["answer"] = action[&field].clone(); + } + } + plan["extraAnswers"] = json!( + actions + .iter() + .filter(|(id, _)| id.starts_with("extra:")) + .map(|(id, action)| (id.clone(), action["extra"].clone())) + .collect::>() + ); + plan["routes"] = json!(routes); + plan["excluded"] = json!(excluded); + plan["designFixes"] = json!(design_fixes); + plan["status"] = json!("confirmed"); + plan["confirmedAt"] = json!(now()); + if !plan["assumptions"].is_array() { + plan["assumptions"] = json!([]); + } + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "planId": plan["planId"], + "routes": routes, + "markdownTable": route_markdown(&routes), + "excluded": excluded, + "designFixes": design_fixes, + "site": plan["site"], + "defaults": plan["defaults"], + "extraAnswers": plan["extraAnswers"], + "customAnswers": custom, + "rule": IMPLEMENT_INSTRUCTION, + "writtenTo": written, + "nextAction": {"how": IMPLEMENT_INSTRUCTION}, + })) +} + +/// `assume`: what implementation decided without asking, appended to the +/// confirmed plan so status and the final report carry it. +pub(super) fn assume(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Err(invalid( + "Assumptions belong to implementation, after confirm. Before confirm, put the decision to the user in the question rounds instead.", + )); + } + if input.assumptions.is_empty() && input.dependency_update.is_none() { + return Err(invalid( + "action assume needs assumptions or dependencyUpdate.", + )); + } + let recorded_at = now(); + // An assumption about a pending decision takes it, unless it is + // destructive: that one stays unbuilt and goes into the final report. + let mut decisions = array(&plan["pendingDecisions"]).to_vec(); + for decision in &mut decisions { + let Some(taken) = input + .assumptions + .iter() + .find(|assumption| decision["id"] == assumption.about.as_str()) + else { + continue; + }; + if decision["destructive"] == true { + decision["status"] = json!("left-unbuilt"); + } else { + decision["status"] = json!("taken"); + decision["taken"] = json!(taken.decision); + } + decision["takenAt"] = json!(recorded_at); + } + if !decisions.is_empty() { + plan["pendingDecisions"] = json!(decisions); + } + if let Some(update) = &input.dependency_update { + plan["dependencies"] = json!({"updatedAt": recorded_at, "bumped": update.bumped, + "pins": update.pins, "command": UPDATE_STEP}); + } + let mut list = array(&plan["assumptions"]).to_vec(); + for pin in input + .dependency_update + .iter() + .flat_map(|update| &update.pins) + { + list.push(json!({"about": format!("dependency {}", pin.package), + "decision": format!("pinned to {}", pin.version), "reason": pin.reason, "recordedAt": recorded_at})); + } + for assumption in &input.assumptions { + if assumption.about.trim().is_empty() || assumption.decision.trim().is_empty() { + return Err(invalid("Every assumption needs about and decision.")); + } + list.push( + json!({"about": assumption.about, "decision": assumption.decision, + "reason": assumption.reason, "recordedAt": recorded_at}), + ); + } + plan["assumptions"] = json!(list); + // An assumption never builds what the plan excluded or sent back for a + // design fix; the export gate refuses those frames whatever is recorded. + let blocked = array(&plan["excluded"]) + .iter() + .map(|row| text(&row["nodeId"]).to_owned()) + .chain(array(&plan["designFixes"]).iter().flat_map(|fix| { + array(&fix["frames"]) + .iter() + .map(|frame| text(&frame["nodeId"]).to_owned()) + .collect::>() + })) + .filter(|id| { + !id.is_empty() + && input + .assumptions + .iter() + .any(|assumption| assumption.about.contains(id.as_str())) + }) + .collect::>(); + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "planId": plan["planId"], + "pendingDecisions": plan["pendingDecisions"], + "stillNotBuilt": blocked.iter().map(|id| json!({"frame": id, + "why": "Excluded or sent back for a design fix; an assumption cannot build it, and the export gate refuses it."})).collect::>(), + "assumptions": plan["assumptions"], + "dependencies": plan["dependencies"], + "writtenTo": written, + "how": "Keep implementing. Every assumption appears in status and must be in your final report.", + })) +} + +/// Records a decision the work needs without stopping it. +pub(super) fn defer(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Err(invalid( + "A decision before confirm is a question: put it in extraQuestions on draft.", + )); + } + let decision = input + .decision + .as_ref() + .ok_or_else(|| invalid("action defer needs decision."))?; + for (field, value) in [ + ("id", &decision.id), + ("about", &decision.about), + ("question", &decision.question), + ("recommended", &decision.recommended), + ] { + if value.trim().is_empty() { + return Err(invalid(format!("decision.{field} is empty."))); + } + } + let mut decisions = array(&plan["pendingDecisions"]).to_vec(); + decisions.retain(|existing| existing["id"] != decision.id.as_str()); + decisions.push( + json!({"id": decision.id, "about": decision.about, "question": decision.question, + "options": decision.options, "recommended": decision.recommended, + "destructive": decision.destructive, "status": "open", "raisedAt": now()}), + ); + plan["pendingDecisions"] = json!(decisions); + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "pendingDecisions": plan["pendingDecisions"], + "writtenTo": written, + "how": if decision.destructive { + "Recorded. Do not build what it touches; carry on with everything else. It stays unbuilt and is listed in the final report." + } else { + "Recorded. Carry on with everything else; items it blocks move to the back of the work queue. When the rest is done, take it with action assume (about: the decision id, decision: the recommended option)." + }, + })) +} + +/// Writes one runtime-audit plan per confirmed URL and records where. +pub(super) async fn audit_plan( + input: &ScreenPlanInput, + policy: &OutputPolicy, + artifacts: &ArtifactStore, +) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Err(invalid( + "auditPlan needs the confirmed plan: answer the questions and confirm first.", + )); + } + let config = read_config(root)?.unwrap_or_default(); + let thresholds = config.visual_thresholds.clone().unwrap_or_default(); + let mut result = super::audit_plan::write( + &plan, + &super::audit_plan::AuditRequest { + root, + base_url: input.base_url.as_deref().unwrap_or("http://localhost:3000"), + base_urls: &input.base_urls, + only: &input.urls, + artifact_ids: &input.artifacts, + thresholds: &thresholds, + }, + policy, + artifacts, + ) + .await?; + let mut entries = array(&plan["audit"]["plans"]).to_vec(); + for written in array(&result["plans"]) { + entries.retain(|entry| entry["url"] != written["url"]); + entries.push(json!({"url": written["url"], "screen": written["screen"], + "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); + } + // A dynamic route sampled from its Figma frame is an assumption, and is + // reported as one. + let mut assumptions = array(&plan["assumptions"]).to_vec(); + for written in array(&result["plans"]) { + if let Some(assumption) = written["dynamic"]["assumption"].as_str() + && !assumptions + .iter() + .any(|existing| existing["decision"] == assumption) + { + assumptions.push(json!({"about": written["dynamic"]["pattern"], "decision": assumption, + "reason": "no generateStaticParams and no link to the route on an audited page yet", "recordedAt": now()})); + } + } + plan["assumptions"] = json!(assumptions); + let max_fix_attempts = config.max_fix_attempts.unwrap_or(3); + plan["audit"]["plans"] = json!(entries); + plan["audit"]["maxFixAttempts"] = json!(max_fix_attempts); + plan["audit"]["generatedAt"] = json!(now()); + result["writtenTo"] = write_plan(policy, root, &plan)?; + result["maxFixAttempts"] = json!(max_fix_attempts); + result["doneWhen"] = json!(DONE_RULE); + Ok(result) +} + +pub(super) const DONE_RULE: &str = "A URL is done when every check and comparison passes at its drawn widths and health and overflow pass at every width, or each remaining finding is a converter bug with its repro filed, or unresolved with its evidence after maxFixAttempts fix-and-rerun rounds. Layout is exact: a geometry mismatch is code drift (restore the export) or a converter bug, never tuned by hand."; + +fn screen_table(plan: &Value) -> Vec { + array(&plan["screens"]) + .iter() + .map(|screen| { + json!({ + "id": screen["id"], + "name": screen["name"], + "kind": screen["kind"], + "drawnWidths": screen["drawnWidths"], + "proposedRoute": screen["proposedRoute"], + "routeEvidence": screen["routeEvidence"], + "existingFile": screen["existingFile"], + "sharedParts": screen["sharedParts"], + "openDecisions": screen["openDecisions"], + }) + }) + .collect() +} + +fn cell(value: &Value) -> String { + let rendered = match value { + Value::Null => "-".to_owned(), + Value::String(text) => text.clone(), + Value::Array(items) if items.is_empty() => "-".to_owned(), + Value::Array(items) => items.iter().map(cell).collect::>().join(", "), + other => other.to_string(), + }; + rendered.replace('|', "\\|").replace('\n', " ") +} + +fn route_markdown(routes: &[Value]) -> String { + let mut out = String::from( + "| URL | Figma screen | nodeIds | widths | file | implementation | app |\n|---|---|---|---|---|---|---|\n", + ); + for row in routes { + let url = if row["implementation"] == "state" { + format!("(state on {})", cell(&row["hostUrl"])) + } else { + cell(&row["url"]) + }; + out.push_str(&format!( + "| {url} | {} | {} | {} | {} | {} | {} |\n", + cell(&row["name"]), + cell(&row["nodeIds"]), + cell(&row["drawnWidths"]), + cell(&row["file"]), + cell(&row["implementation"]), + cell(&row["app"]), + )); + } + out +} + +/// The Markdown twin of the plan: the same content, for a person. +fn markdown(plan: &Value) -> String { + let mut out = format!( + "# Screen plan {}\n\nStatus: **{}**. Source: {}.\n", + cell(&plan["planId"]), + cell(&plan["status"]), + cell(&plan["source"]["url"]), + ); + if let Some(notes) = plan["notes"].as_str() { + out.push_str(&format!("\nNotes: {notes}\n")); + } + if let Some(note) = plan["inventoryNote"].as_str() { + out.push_str(&format!("\n> {note}\n")); + } + out.push_str("\n## Screens\n\n| Figma screen | nodeId | widths | proposed route | evidence | existing file | shared parts | open decisions |\n|---|---|---|---|---|---|---|---|\n"); + for screen in array(&plan["screens"]) { + out.push_str(&format!( + "| {} | {} | {} | {} | {} | {} | {} | {} |\n", + cell(&screen["name"]), + cell(&screen["id"]), + cell(&screen["drawnWidths"]), + cell(&screen["proposedRoute"]), + cell(&screen["routeEvidence"]), + cell(&screen["existingFile"]), + cell(&screen["sharedParts"]), + cell(&screen["openDecisions"]), + )); + } + let questions = array(&plan["questions"]); + if !questions.is_empty() { + out.push_str(&format!("\n## Open questions\n\n{ASK_INSTRUCTION}\n\n")); + for question in questions { + let id = text(&question["id"]); + out.push_str(&format!( + "- **{}** (`{id}`): {}\n", + cell(&question["header"]), + text(&question["question"]) + )); + for option in array(&question["options"]) { + out.push_str(&format!( + " - {}: {}\n", + text(&option["label"]), + text(&option["description"]) + )); + } + if let Some(answer) = plan["answers"].get(id) { + out.push_str(&format!(" - Answer: {}\n", cell(answer))); + } + } + } + let routes = array(&plan["routes"]); + if !routes.is_empty() { + out.push_str("\n## Confirmed routes\n\n"); + out.push_str(&route_markdown(routes)); + } + let lists = trailing_lists(plan); + if !lists.is_empty() { + out.push_str("\n## Not built, and why\n"); + out.push_str(&lists); + } + out +} + +fn worst(a: &str, b: &str) -> &'static str { + let rank = |status: &str| match status { + "fail" => 3, + "inconclusive" => 2, + "not-run" => 1, + _ => 0, + }; + match rank(a).max(rank(b)) { + 3 => "fail", + 2 => "inconclusive", + 1 => "not-run", + _ => "pass", + } +} + +/// `status`: the confirmed plan read against the routes on disk and, when +/// given, a runtime audit. A width without an audit item is `not-run`. +pub(super) async fn status( + input: &ScreenPlanInput, + policy: &OutputPolicy, + artifacts: &ArtifactStore, +) -> Result { + let root = require_root(input)?; + let plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Ok(json!({ + "status": "not-confirmed", + "planId": plan["planId"], + "questionRounds": question_rounds(array(&plan["questions"]), per_round(&plan)), + "questions": ask_shape(array(&plan["questions"])), + "askInstruction": ASK_INSTRUCTION, + "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"]}, "how": ASK_INSTRUCTION}, + })); + } + let mut plan = plan; + let audit = match ( + &input.audit_plan_path, + &input.audit_report_path, + &input.tooling_unavailable, + ) { + (Some(plan_path), Some(report_path), None) => Some(( + super::runtime_audit::ingest_with_artifacts(policy, plan_path, report_path, artifacts) + .await?, + sha256(&policy.resolve(report_path)?.read_bounded(8 * 1024 * 1024)?), + )), + (Some(plan_path), None, Some(reason)) => { + let bytes = policy.resolve(plan_path)?.read_bounded(MAX_PLAN_BYTES)?; + let audit_plan: Value = serde_json::from_slice(&bytes) + .map_err(|_| invalid("auditPlanPath is not a runtime audit plan."))?; + for screen in array(&audit_plan["plan"]["screens"]) { + if let Some(route) = screen["routePath"].as_str() { + plan["audit"]["inconclusive"][route] = json!(reason); + } + } + write_plan(policy, root, &plan)?; + None + } + (None, None, None) => None, + _ => { + return Err(invalid( + "status takes auditPlanPath with auditReportPath, or auditPlanPath with toolingUnavailable, or neither.", + )); + } + }; + let evidence = project_evidence(root).await?; + let mut audit = audit; + if let Some((audit, _)) = &mut audit { + super::audit_plan::classify_geometry(audit, root, &evidence.sources, policy); + } + let round = match &audit { + Some((audit, report_sha)) if audit["verdict"] != "rejected" => { + let routes = array(&audit["screenTable"]) + .iter() + .filter_map(|row| row["routePath"].as_str()) + .map(str::to_owned) + .collect::>(); + if let Some(inconclusive) = plan["audit"]["inconclusive"].as_object_mut() { + inconclusive.retain(|route, _| !routes.contains(route)); + } + // The links each page holds, for auditPlan's next pass over the + // dynamic routes. + for (route, hrefs) in audit["links"].as_object().into_iter().flatten() { + plan["audit"]["links"][route] = hrefs.clone(); + } + let max = plan["audit"]["maxFixAttempts"] + .as_u64() + .and_then(|max| u32::try_from(max).ok()) + .or(evidence.config.max_fix_attempts) + .unwrap_or(3); + let round = super::audit_plan::record_round( + &mut plan, + audit, + report_sha, + &input.visual_results, + &evidence.figma_nodes, + max, + ); + write_plan(policy, root, &plan)?; + Some(round) + } + _ => None, + }; + let mut report = status_report(&plan, &evidence, audit.as_ref().map(|(audit, _)| audit)); + if let Some((audit, _)) = &audit { + report["visualComparisons"] = audit["visualComparisons"].clone(); + report["missingNodes"] = audit["missingNodes"].clone(); + } + if let Some(round) = round { + report["pendingComparisons"] = round["pendingComparisons"].clone(); + report["inconclusiveComparisons"] = round["inconclusiveComparisons"].clone(); + } + Ok(report) +} + +/// The pure half of `status`, so the not-run rule is tested on its own. +pub(super) fn status_report( + plan: &Value, + evidence: &ProjectEvidence, + audit: Option<&Value>, +) -> Value { + let audit_rows = audit + .map(|audit| array(&audit["screenTable"])) + .unwrap_or_default(); + let mut widths = audit + .map(|audit| { + array(&audit["widthTable"]) + .iter() + .filter_map(|row| row["width"].as_u64()) + .collect::>() + }) + .unwrap_or_default(); + let audited = !widths.is_empty(); + let mut rows = Vec::new(); + let mut states = Vec::new(); + for route in array(&plan["routes"]) { + if route["implementation"] == "state" { + states.push(json!({"screen": route["screen"], "name": route["name"], "hostUrl": route["hostUrl"]})); + continue; + } + let Some(url) = route["url"].as_str() else { + continue; + }; + let page = evidence + .routes + .iter() + .find(|(pattern, _)| pattern == url || super::navigation::matches_route(pattern, url)) + .map(|(_, page)| page.clone()); + let mut row_widths = if audited { + widths.clone() + } else { + DEFAULT_WIDTHS + .iter() + .map(|width| u64::from(*width)) + .chain( + array(&route["drawnWidths"]) + .iter() + .filter_map(Value::as_u64), + ) + .collect::>() + }; + row_widths.sort_unstable_by(|a, b| b.cmp(a)); + row_widths.dedup(); + let per_width = row_widths + .iter() + .map(|width| { + let matching = audit_rows + .iter() + .filter(|row| { + row["width"] == *width + && row["routePath"] + .as_str() + .and_then(normalize_route) + .is_some_and(|path| path == url) + }) + .collect::>(); + if matching.is_empty() { + return json!({"width": width, "result": "not-run", + "reasons": [if audited { "no runtime audit item for this URL at this width" } else { "no runtime audit report supplied" }]}); + } + let result = matching + .iter() + .fold("pass", |acc, row| worst(acc, text(&row["status"]))); + json!({ + "width": width, + "result": result, + "columns": matching[0]["columns"], + "reasons": matching.iter().flat_map(|row| array(&row["reasons"]).to_vec()).collect::>(), + "screenshots": matching.iter().filter_map(|row| row["screenshot"].as_str()).collect::>(), + }) + }) + .collect::>(); + rows.push(json!({ + "url": url, + "screen": route["screen"], + "figmaScreen": route["name"], + "nodeIds": route["nodeIds"], + "file": page.clone().map_or_else(|| route["file"].clone(), Value::from), + "implemented": if page.is_some() { "yes" } else { "no" }, + "widths": per_width, + })); + } + if !audited { + widths = DEFAULT_WIDTHS + .iter() + .map(|width| u64::from(*width)) + .collect(); + } + let implemented = rows + .iter() + .filter(|row| row["implemented"] == "yes") + .count(); + let verified = rows + .iter() + .filter(|row| { + array(&row["widths"]) + .iter() + .all(|width| width["result"] == "pass") + }) + .count(); + let mut problems = Vec::new(); + for row in &rows { + let mut by_kind = BTreeMap::<&str, Vec>::new(); + for width in array(&row["widths"]) { + if width["result"] != "fail" { + continue; + } + let reasons = array(&width["reasons"]) + .iter() + .map(text) + .collect::>(); + let kind = if reasons + .iter() + .any(|reason| reason.starts_with("horizontal overflow")) + { + "overflows" + } else if reasons.iter().any(|reason| { + reason.starts_with("page error") || reason.starts_with("screen not reached") + }) { + "fails to load" + } else { + "fails checks" + }; + by_kind + .entry(kind) + .or_default() + .push(width["width"].to_string()); + } + for (kind, at) in by_kind { + problems.push(format!("{} {kind} at {}", text(&row["url"]), at.join(", "))); + } + } + let mut summary = format!( + "{} URLs: {implemented} implemented, {verified} verified at all widths", + rows.len() + ); + for problem in &problems { + summary.push_str(&format!(", {problem}")); + } + if !audited { + summary.push_str("; no runtime audit report, so every width is not-run"); + } + let assumptions = array(&plan["assumptions"]); + if !assumptions.is_empty() { + summary.push_str(&format!( + "; {} assumption{} made during implementation", + assumptions.len(), + if assumptions.len() == 1 { "" } else { "s" } + )); + } + let design_fixes = array(&plan["designFixes"]); + if !design_fixes.is_empty() { + summary.push_str(&format!( + "; {} design fix{} requested, not built", + design_fixes.len(), + if design_fixes.len() == 1 { "" } else { "es" } + )); + } + let excluded = array(&plan["excluded"]) + .iter() + .filter(|row| row["decision"] == "excluded") + .cloned() + .collect::>(); + if !excluded.is_empty() { + summary.push_str(&format!("; {} frames excluded, not built", excluded.len())); + } + let mut table = String::from( + "| URL | Figma screen | file | status | widths verified | screenshots |\n|---|---|---|---|---|---|\n", + ); + for row in &rows { + let row_widths = array(&row["widths"]); + let passed = row_widths + .iter() + .filter(|width| width["result"] == "pass") + .map(|width| width["width"].to_string()) + .collect::>(); + let failing = row_widths + .iter() + .filter(|width| width["result"] == "fail") + .map(|width| width["width"].to_string()) + .collect::>(); + let state = if row["implemented"] != "yes" { + "not implemented".to_owned() + } else if !failing.is_empty() { + format!("fails at {}", failing.join(", ")) + } else if passed.len() == row_widths.len() { + "verified".to_owned() + } else { + "implemented, not verified".to_owned() + }; + let shots = row_widths + .iter() + .flat_map(|width| array(&width["screenshots"]).to_vec()) + .collect::>(); + table.push_str(&format!( + "| {} | {} | {} | {state} | {} of {} | {} |\n", + cell(&row["url"]), + cell(&row["figmaScreen"]), + cell(&row["file"]), + if passed.is_empty() { + "none".to_owned() + } else { + passed.join(", ") + }, + row_widths + .iter() + .map(|width| width["width"].to_string()) + .collect::>() + .join("/"), + cell(&json!(shots)), + )); + } + let completeness = site_completeness(plan, evidence); + let incomplete = completeness + .iter() + .filter(|item| item["state"] == "missing") + .count(); + if incomplete > 0 { + summary.push_str(&format!("; site completeness: {incomplete} missing")); + } + table.push_str("\nSite completeness:\n"); + for item in &completeness { + table.push_str(&format!( + "- {}: {} ({})\n", + text(&item["item"]), + text(&item["state"]), + cell(&item["detail"]) + )); + } + let trailing = trailing_lists(plan); + table.push_str(&trailing); + // Visual failures and fixed findings change a width's verdict after the + // run: a failing comparison fails its width, and the columns say which + // level failed. + let open = super::audit_plan::findings_by_status(plan, "open"); + let unresolved = super::audit_plan::findings_by_status(plan, "unresolved"); + for row in &mut rows { + let url = text(&row["url"]).to_owned(); + for width in row["widths"].as_array_mut().into_iter().flatten() { + let at = width["width"].clone(); + for finding in open.iter().chain(&unresolved).filter(|finding| { + finding["kind"] == "visual" + && finding["routePath"] == url.as_str() + && finding["width"] == at + }) { + width["result"] = json!("fail"); + let column = match text(&finding["class"]) { + "asset-mismatch" => "asset", + "section-visual" => "section", + _ => "page", + }; + width["columns"][column] = json!("fail"); + if let Some(reasons) = width["reasons"].as_array_mut() { + reasons.push(json!(format!( + "{}: {}", + text(&finding["class"]), + text(&finding["reason"]) + ))); + } + } + } + } + let queue = super::audit_plan::work_queue(plan, &rows, &[]); + let final_report = super::audit_plan::final_report(plan, &rows, &trailing); + let converter_bugs = plan["audit"]["converterBugs"] + .as_object() + .map(|bugs| bugs.values().cloned().collect::>()) + .unwrap_or_default(); + json!({ + "findings": open, + "unresolved": unresolved, + "converterBugs": converter_bugs, + "workQueue": queue, + "pendingDecisions": plan["pendingDecisions"], + "finalReport": final_report, + "doneWhen": DONE_RULE, + "status": "confirmed", + "planId": plan["planId"], + "summary": summary, + "rows": rows, + "states": states, + "widths": widths, + "audit": audit.map(|audit| json!({"verdict": audit["verdict"], "planId": audit["planId"], "reason": audit["reason"]})), + "markdownTable": table, + "assumptions": assumptions, + "siteCompleteness": completeness, + "defaults": plan["defaults"], + "designFixes": design_fixes, + "excluded": excluded, + "inventoryNote": evidence.inventory_note, + "rule": "A width without a runtime audit item is not-run, never pass. Implemented means an App Router page exists for the URL, not that it renders.", + }) +} + +/// The lists every final report ends with: design fixes requested, frames +/// excluded, and assumptions made during implementation. Shared by the plan +/// file and `status`, so the two never disagree about what was not built. +fn trailing_lists(plan: &Value) -> String { + let mut out = String::new(); + let defaults = array(&plan["defaults"]); + if !defaults.is_empty() { + out.push_str("\nFilled in by default (no question):\n"); + for default in defaults { + out.push_str(&format!( + "- {}: {}\n", + text(&default["item"]), + text(&default["decision"]) + )); + } + } + let fixes = array(&plan["designFixes"]); + if !fixes.is_empty() { + out.push_str("\nDesign fixes requested (not built):\n"); + for fix in fixes { + out.push_str(&format!("- {}\n", text(&fix["fix"]))); + } + } + let excluded = array(&plan["excluded"]) + .iter() + .filter(|row| row["decision"] == "excluded" || row["decision"].is_null()) + .collect::>(); + if !excluded.is_empty() { + out.push_str("\nExcluded (never built):\n"); + for row in excluded { + out.push_str(&format!( + "- \"{}\" ({}): {}\n", + text(&row["name"]), + text(&row["nodeId"]), + text(&row["reason"]) + )); + } + } + let assumptions = array(&plan["assumptions"]); + if !assumptions.is_empty() { + out.push_str("\nAssumptions made during implementation:\n"); + for assumption in assumptions { + out.push_str(&format!( + "- {}: {}{}\n", + text(&assumption["about"]), + text(&assumption["decision"]), + assumption["reason"] + .as_str() + .map(|reason| format!(" ({reason})")) + .unwrap_or_default() + )); + } + } + out +} + +/// `app-icon` for `devup_ui_validate` with a projectRoot: an App Router app +/// with no icon or favicon. Read from disk here because the devup-ui checks +/// open no file. +pub(super) fn missing_app_icon( + project_root: &str, +) -> Option { + let root = Path::new(project_root); + let app = ["src/app", "app"] + .into_iter() + .find(|app| join_relative(root, app).is_dir())?; + let mut evidence = ProjectEvidence { + app_dir: app.to_owned(), + ..ProjectEvidence::default() + }; + read_site_facts(root, &mut evidence); + if !evidence.icon_files.is_empty() { + return None; + } + let mut context = serde_json::Map::new(); + context.insert("appDir".into(), json!(app)); + Some(devup_mcp_devup_ui::ui_validate::Violation { + rule: "app-icon", + severity: devup_mcp_devup_ui::ui_validate::Severity::Warning, + byte_range: [0, 0], + context, + message: format!("{app} has no icon.* or favicon, and public/ has no favicon either, so the browser tab shows a blank icon."), + suggestion: Some("Add app/icon.svg (or favicon.ico) from a file the user provides or a Figma logo node exported as SVG; devup_screen_plan asks which. Never draw one.".into()), + }) +} + +/// What the export gate decided. +pub(super) enum Gate { + /// Not a page write, or no plan applies. + Open, + /// A confirmed plan covers the frame and names this route for pageScaffold. + Route(String), + /// Not written: the response to return instead. + Blocked(Value), +} + +/// The facts of one export call the gate decides on. +pub(super) struct ExportRequest<'a> { + pub project_root: &'a str, + pub url: Option<&'a str>, + /// Frame ids, or the one node the url or artifact names when there are none. + pub frames: Vec, + pub all_screens: bool, + pub scaffold_write: bool, + pub scaffold_route: Option<&'a str>, + pub output_paths: Vec<&'a str>, +} + +/// Whether a write lands in the App Router tree. Judged below the project +/// root, so a checkout that happens to sit in a folder named `app` is not one. +fn under_app(project_root: &str, path: &str) -> bool { + let path = Path::new(path); + path.strip_prefix(project_root) + .unwrap_or(path) + .components() + .any(|component| component.as_os_str() == "app") +} + +fn plan_required(request: &ExportRequest<'_>, reason: &str, details: Value) -> Gate { + let mut arguments = json!({"action": "draft", "projectRoot": request.project_root}); + if let Some(url) = request.url { + arguments["url"] = json!(url); + } + Gate::Blocked(json!({ + "status": "plan_required", + "written": false, + "planRequired": {"reason": reason, "details": details}, + "nextAction": {"tool": "devup_screen_plan", "arguments": arguments, + "how": "Draft the screen plan, ask every round of its questions back to back before implementing, confirm, then repeat this export. Nothing was collected or written."}, + })) +} + +/// The soft gate in front of `devup_figma_export`. A call that would write a +/// page - a pageScaffold write, an outputPath under app/, or several screens +/// with outputPaths - needs a confirmed plan covering its frames. A single +/// component, or a preview that writes nothing, is never blocked. +pub(super) fn export_gate(request: &ExportRequest<'_>) -> Result { + let several = request.all_screens || request.frames.len() > 1; + let writes = request.scaffold_write || !request.output_paths.is_empty(); + let gated = request.scaffold_write + || request + .output_paths + .iter() + .any(|path| under_app(request.project_root, path)) + || (several && writes); + if !gated && request.scaffold_route.is_none() { + return Ok(Gate::Open); + } + let plan = match read_plan(request.project_root)? { + Some(plan) if plan["status"] == "confirmed" => plan, + Some(plan) if gated => { + return Ok(plan_required( + request, + "The screen plan is a draft; its questions are unanswered.", + json!({"planId": plan["planId"], "askInstruction": ASK_INSTRUCTION}), + )); + } + None if gated => { + return Ok(plan_required( + request, + "This call writes a page and the project has no confirmed screen plan.", + json!({"planFile": PLAN_FILE}), + )); + } + _ => return Ok(Gate::Open), + }; + // Never built, whatever else the call says: not by this export, and not + // by an assumption recorded during implementation. + let refused = request + .frames + .iter() + .filter_map(|frame| { + let excluded = array(&plan["excluded"]) + .iter() + .find(|row| row["nodeId"] == frame.as_str()) + .map(|row| format!("excluded: {}", text(&row["reason"]))); + let fix = array(&plan["designFixes"]) + .iter() + .find(|fix| { + array(&fix["frames"]) + .iter() + .any(|f| f["nodeId"] == frame.as_str()) + }) + .map(|fix| format!("design fix requested: {}", text(&fix["fix"]))); + excluded + .or(fix) + .map(|reason| json!({"frame": frame, "reason": reason})) + }) + .collect::>(); + if !refused.is_empty() && (gated || request.scaffold_route.is_some()) { + return Ok(Gate::Blocked(json!({ + "status": "refused", + "written": false, + "refused": refused, + "how": "Do not build these frames and do not record an assumption to build them. They are listed in devup_screen_plan status; continue with the rest of the plan.", + }))); + } + let routes = array(&plan["routes"]); + let row_of = |frame: &str| { + routes + .iter() + .find(|row| array(&row["nodeIds"]).iter().any(|id| id == frame)) + }; + if request.all_screens { + let section = request + .url + .and_then(|url| FigmaTarget::parse(url).ok()) + .and_then(|target| target.node_id); + let planned = plan["source"]["nodeId"].as_str(); + if section.is_some() && planned.is_some() && section.as_deref() != planned { + return Ok(plan_required( + request, + "allScreens names a Section the confirmed plan was not drafted from.", + json!({"requested": section, "planned": planned}), + )); + } + return Ok(Gate::Open); + } else if request.frames.is_empty() { + if gated { + return Ok(plan_required( + request, + "The frames this call writes are not named, so the plan cannot be checked. Pass frameIds or a frame url.", + json!({}), + )); + } + return Ok(Gate::Open); + } + let uncovered = request + .frames + .iter() + .filter(|frame| { + row_of(frame) + .is_none_or(|row| !matches!(text(&row["implementation"]), "page" | "state")) + }) + .cloned() + .collect::>(); + if !uncovered.is_empty() { + if !gated { + return Ok(Gate::Open); + } + // The plan is confirmed, so this is implementation: nobody is asked + // now. The conservative option is to leave the frame unbuilt. + return Ok(Gate::Blocked(json!({ + "status": "plan_required", + "written": false, + "planRequired": {"reason": "The confirmed screen plan does not place these frames on a page.", + "details": {"uncoveredFrames": uncovered, "planId": plan["planId"]}}, + "nextAction": {"tool": "devup_screen_plan", + "arguments": {"action": "assume", "projectRoot": request.project_root, + "assumptions": [{"about": uncovered.join(", "), "decision": "Not built: outside the confirmed screen plan."}]}, + "how": "Do not ask the user during implementation. Leave these frames unbuilt, record that with action assume, keep going, and list it in the final report."}, + }))); + } + let Some(requested) = request.scaffold_route else { + return Ok(Gate::Open); + }; + let [frame] = request.frames.as_slice() else { + return Ok(Gate::Open); + }; + let Some(row) = row_of(frame) else { + return Ok(Gate::Open); + }; + let Some(planned) = row["url"].as_str() else { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "The confirmed plan builds {frame} as a state of another page, not as its own route; export it without pageScaffold." + ), + false, + json!({"planId": plan["planId"], "hostUrl": row["hostUrl"]}), + )); + }; + if !requested.is_empty() && normalize_route(requested).as_deref() != Some(planned) { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "pageScaffold.route {requested} does not match the confirmed plan, which serves {frame} at {planned}." + ), + false, + json!({"routeMismatch": {"requested": requested, "planned": planned, "frame": frame, "planId": plan["planId"]}, + "how": "Omit pageScaffold.route to take the plan's route, or redraft and confirm the plan if the URL should change."}), + )); + } + Ok(Gate::Route(planned.to_owned())) +} + +#[cfg(test)] +#[path = "screen_plan_tests.rs"] +mod tests; diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs new file mode 100644 index 00000000..719760b4 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -0,0 +1,2263 @@ +use super::*; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Fixture(std::path::PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "screen-plan-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + let fixture = Self(dunce::canonicalize(&root).unwrap()); + fixture.write("package.json", "{}"); + fixture.write(CONFIG_FILE, SITE_CONFIG); + fixture.write( + "src/app/layout.tsx", + "import { Header } from '../components/Header';\nexport default function RootLayout({ children }) { return
{children}; }", + ); + fixture.write( + "src/components/Header.tsx", + "import Link from 'next/link';\nexport function Header() { return ; }", + ); + fixture.write( + "src/app/page.tsx", + "export default function Page() { return
; }", + ); + fixture.write( + "src/app/company/page.tsx", + "export default function Page() { return
; }", + ); + fixture + } + + fn write(&self, path: &str, text: &str) { + let target = self.0.join(path); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(target, text).unwrap(); + } + + fn root(&self) -> &str { + self.0.to_str().unwrap() + } + + fn policy(&self) -> OutputPolicy { + OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn screen(node_id: &str, name: &str, width: f64) -> PlanScreenInput { + PlanScreenInput { + node_id: node_id.into(), + name: name.into(), + width, + height: Some(900.0), + ..PlanScreenInput::default() + } +} + +fn evidence() -> ProjectEvidence { + ProjectEvidence { + routes: vec![ + ("/".into(), "src/app/page.tsx".into()), + ("/company".into(), "src/app/company/page.tsx".into()), + ("/news/[id]".into(), "src/app/news/[id]/page.tsx".into()), + ], + nav_links: vec![( + "회사소개".into(), + "/company".into(), + "src/components/Header.tsx:2".into(), + )], + layout_parts: vec!["Header".into()], + app_dir: "src/app".into(), + // The site questions are pre-answered here so each test below is + // about its own questions; `site_*` tests start from bare evidence. + config: answered_site(), + ..ProjectEvidence::default() + } +} + +fn answered_site() -> PlanConfig { + PlanConfig { + favicon: Some("app/icon.svg".into()), + og_image: Some("public/og.png".into()), + site_name: Some("Prime".into()), + site_url: Some("https://example.com".into()), + motion: Some("none".into()), + ..PlanConfig::default() + } +} + +/// The site config the fixtures write, matching [`answered_site`]. +const SITE_CONFIG: &str = "{\"favicon\": \"app/icon.svg\", \"ogImage\": \"public/og.png\", \"siteName\": \"Prime\", \"siteUrl\": \"https://example.com\", \"motion\": \"none\"}"; + +fn sectioned(node_id: &str, name: &str, width: f64, section: &str) -> PlanScreenInput { + PlanScreenInput { + section: Some(format!("section-{section}")), + section_name: Some(section.into()), + ..screen(node_id, name, width) + } +} + +fn find<'a>(plan: &'a Value, id: &str) -> &'a Value { + array(&plan["questions"]) + .iter() + .find(|question| question["id"] == id) + .unwrap_or_else(|| panic!("{id} is asked: {}", plan["questions"])) +} + +fn first_label(question: &Value) -> &str { + text(&question["options"][0]["label"]) +} + +fn question_ids(plan: &Value) -> Vec { + array(&plan["questions"]) + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect() +} + +fn input(fixture: &Fixture, action: &str) -> ScreenPlanInput { + ScreenPlanInput { + action: action.into(), + project_root: Some(fixture.root().into()), + ..ScreenPlanInput::default() + } +} + +/// The route the user never saw being chosen is the failure this tool exists +/// for, so a screen nothing names gets no route and a question instead. +#[test] +fn an_unverified_route_is_null_and_becomes_a_question() { + let plan = draft_plan( + &[screen("1:2", "Pricing", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let row = &plan["screens"][0]; + assert_eq!(row["proposedRoute"], Value::Null); + assert_eq!(row["routeEvidence"], "UNVERIFIED"); + let question = array(&plan["questions"]) + .iter() + .find(|question| question["id"] == "route:1:2") + .expect("an unverified route is asked"); + let labels = array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert_eq!(labels[0], "/pricing (Recommended)"); + assert!(labels.contains(&SKIP)); +} + +/// Each kind of evidence proposes the route on its own, and a screen it +/// proposes is not asked about its route. +#[test] +fn an_evidence_backed_route_is_proposed_without_a_route_question() { + let targets = BTreeMap::from([("2:1".to_owned(), "/contact?from=nav".to_owned())]); + let plan = draft_plan( + &[ + screen("2:1", "Contact", 1440.0), + screen("2:2", "회사소개", 1440.0), + screen("2:3", "Home", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows[0]["proposedRoute"], "/contact"); + assert_eq!(rows[0]["routeEvidence"], "navigationTargets[2:1]"); + assert_eq!(rows[1]["proposedRoute"], "/company"); + assert!(text(&rows[1]["routeEvidence"]).starts_with("nav-label")); + assert_eq!(rows[2]["proposedRoute"], "/"); + // Home -> / is a guess from the layer name, so it is shown and put to + // the user in one compact question rather than decided silently. + assert_eq!(rows[2]["routeEvidence"], NAME_MATCH); + assert_eq!(plan["heuristics"][0]["screen"], "2:3"); + let ids = question_ids(&plan); + assert!(ids.contains(&"heuristics".to_owned()), "{ids:?}"); + assert!(ids.contains(&"review:2:3".to_owned()), "{ids:?}"); + assert_eq!( + first_label(find(&plan, "heuristics")), + "Accept all (Recommended)" + ); + assert!( + !ids.iter() + .any(|id| id == "route:2:1" || id.ends_with(":2:1")), + "{ids:?}" + ); + assert!(!ids.iter().any(|id| id.starts_with("route:")), "{ids:?}"); + // An existing page is a different question: what building over it does. + assert!(ids.iter().any(|id| id == "collision:2:2")); + assert_eq!(rows[1]["existingFile"], "src/app/company/page.tsx"); + assert_eq!(rows[0]["sharedParts"], json!(["Header"])); +} + +/// Two screens the evidence puts on one URL leave neither decided. +#[test] +fn two_screens_on_one_evidence_route_are_both_asked() { + let targets = BTreeMap::from([ + ("3:1".to_owned(), "/about".to_owned()), + ("3:2".to_owned(), "/about".to_owned()), + ]); + let plan = draft_plan( + &[ + screen("3:1", "About A", 1440.0), + screen("3:2", "About B", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let ids = question_ids(&plan); + assert!(ids.contains(&"route:3:1".to_owned()) && ids.contains(&"route:3:2".to_owned())); +} + +#[test] +fn widths_in_one_section_recommend_one_page_and_modals_are_asked() { + let plan = draft_plan( + &[ + screen("4:1", "Main - 1440", 1440.0), + screen("4:2", "Main - 390", 390.0), + screen("4:3", "Login Modal", 480.0), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); + assert_eq!(rows[0]["proposedRoute"], "/"); + assert_eq!(rows[0]["grouping"]["recommended"], "single"); + let group = find(&plan, "group:4:1"); + assert_eq!(first_label(group), "One responsive page (Recommended)"); + // Separate pages need their own routes, asked in the same up-front batch + // and only if separate is chosen. No URL is made up for the 390 frame. + let split = find(&plan, "split-route:4:2"); + assert_eq!(split["when"][0]["question"], "group:4:1"); + assert_eq!(split["when"][0]["answer"], "Separate pages"); + assert!(!plan.to_string().contains("/390")); + let modal = find(&plan, "modal:4:3"); + assert!(first_label(modal).starts_with("State on Main")); + assert_eq!(rows[1]["kindEvidence"], NAME_MATCH); + assert!(text(&modal["question"]).contains("name-match")); +} + +/// Every question must be accepted as-is by a structured question tool. +#[test] +fn every_question_has_the_question_tool_shape() { + let mut linked = screen("5:1", "Gallery", 1440.0); + linked.links = vec![PlanLinkInput { + label: "More".into(), + destination_node_id: Some("9:9".into()), + url: None, + }]; + linked.parts = vec!["Footer".into()]; + let mut other = screen("5:2", "News", 1440.0); + other.parts = vec!["Footer".into()]; + let targets = BTreeMap::from([ + ("5:3".to_owned(), "/news/[id]".to_owned()), + ("5:7".to_owned(), "/".to_owned()), + ]); + let plan = draft_plan( + &[ + linked, + other, + screen("5:3", "News detail", 1440.0), + screen("5:4", "공지 팝업", 400.0), + screen("5:5", "Company - 1440", 1440.0), + screen("5:6", "Company - 390", 390.0), + screen("5:7", "Landing", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let questions = ask_shape(array(&plan["questions"])); + let ids = questions + .iter() + .map(|question| text(&question["id"])) + .collect::>(); + assert_eq!(ids.len(), questions.len(), "question ids are unique"); + for kind in [ + "route:", + "link:", + "layout:", + "dynamic:", + "modal:", + "group:", + "split-route:", + "heuristics", + "review:", + "collision:", + ] { + assert!( + ids.iter().any(|id| id.starts_with(kind)), + "{kind} missing from {ids:?}" + ); + } + for question in &questions { + let header = text(&question["header"]); + assert!( + !header.is_empty() && header.chars().count() <= HEADER_LIMIT, + "{header}" + ); + assert!(!text(&question["question"]).is_empty()); + assert_eq!(question["multiple"], false); + assert!( + question.get("resolve").is_none(), + "resolution stays in the plan file" + ); + let options = array(&question["options"]); + assert!((2..=MAX_OPTIONS).contains(&options.len()), "{question}"); + assert!( + text(&options[0]["label"]).ends_with(RECOMMENDED), + "{question}" + ); + for option in &options[1..] { + assert!( + !text(&option["label"]).contains("(Recommended)"), + "{question}" + ); + } + assert!( + options + .iter() + .all(|option| !text(&option["description"]).is_empty()) + ); + for condition in array(&question["when"]) { + assert!(condition.get("action").is_none(), "{question}"); + assert!(ids.contains(text(&condition["question"])), "{question}"); + } + } + assert_rounds_cover(&plan); +} + +/// Every question in exactly one round, rounds within the tool's limits, and +/// a conditional question always after the question it depends on. +fn assert_rounds_cover(plan: &Value) { + let questions = array(&plan["questions"]); + let rounds = question_rounds(questions, MAX_QUESTIONS_PER_ROUND); + let mut seen = Vec::new(); + let mut round_of = BTreeMap::new(); + for (index, round) in rounds.iter().enumerate() { + assert_eq!(round["round"], index + 1); + let asked = array(&round["questions"]); + assert!( + (1..=MAX_QUESTIONS_PER_ROUND).contains(&asked.len()), + "{round}" + ); + for question in asked { + assert!(array(&question["options"]).len() <= MAX_OPTIONS); + let id = text(&question["id"]).to_owned(); + round_of.insert(id.clone(), index); + seen.push(id); + } + } + let mut expected = questions + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect::>(); + let mut got = seen.clone(); + expected.sort(); + got.sort(); + assert_eq!(got, expected, "every question exactly once"); + for question in questions { + for condition in array(&question["when"]) { + assert!( + round_of[text(&condition["question"])] < round_of[text(&question["id"])], + "{} must come after {}", + question["id"], + condition["question"] + ); + } + } +} + +#[test] +fn rounds_split_many_questions_into_fours() { + let screens = (0..11) + .map(|index| screen(&format!("8:{index}"), &format!("Page{index}"), 1440.0)) + .collect::>(); + let plan = draft_plan(&screens, &BTreeMap::new(), &evidence(), json!({}), None).unwrap(); + let rounds = question_rounds(array(&plan["questions"]), MAX_QUESTIONS_PER_ROUND); + assert_eq!(rounds.len(), 3); + assert_rounds_cover(&plan); + // A runtime with a larger cap gets fewer, larger rounds of the same + // questions; one with no cap asks the flat list. + let wide = question_rounds(array(&plan["questions"]), 10); + assert_eq!(wide.len(), 2); + assert_eq!(array(&wide[0]["questions"]).len(), 10); + assert_eq!(question_rounds(array(&plan["questions"]), 50).len(), 1); +} + +/// Frames of one name in different sections are never resolved here: the +/// question states the sections, frames, widths and ids, and offers a Figma +/// fix. Separate pages are recommended unless every section names a device. +#[test] +fn sections_are_asked_explicitly_with_a_fix_option() { + let plan = draft_plan( + &[ + sectioned("9:1", "About", 1440.0, "Company"), + sectioned("9:2", "About", 390.0, "Recruit"), + sectioned("9:3", "Pricing", 1440.0, "PC"), + sectioned("9:4", "Pricing", 390.0, "Mobile"), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let company = find(&plan, "sections:9:1"); + assert_eq!( + first_label(company), + "Separate pages: the sections are different pages (Recommended)" + ); + let device = find(&plan, "sections:9:3"); + assert_eq!( + first_label(device), + "One responsive page: the sections only split by device (Recommended)" + ); + let prompt = text(&device["question"]); + for fact in ["PC", "Mobile", "9:3", "9:4", "1440px", "390px", "Pricing"] { + assert!(prompt.contains(fact), "{fact} missing from {prompt}"); + } + assert!( + text(&device["options"][0]["description"]) + .contains("Inferred from the section names (PC, Mobile)") + ); + let labels = array(&device["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert!(labels.contains(&FIX_FIGMA), "{labels:?}"); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("group:")) + ); + let split = find(&plan, "split-route:9:2"); + assert!( + array(&split["options"]) + .iter() + .any(|option| text(&option["label"]).starts_with("/recruit/about")), + "{split}" + ); + assert_rounds_cover(&plan); +} + +#[test] +fn project_config_counts_as_evidence_and_suppresses_its_questions() { + let mut evidence = evidence(); + evidence.config = PlanConfig { + modal_keywords: Some(vec!["sheet".into()]), + route_aliases: BTreeMap::from([("요금제".to_owned(), "/pricing".to_owned())]), + width_grouping: Some("responsive".into()), + ..answered_site() + }; + let plan = draft_plan( + &[ + screen("10:1", "요금제 - 1440", 1440.0), + screen("10:2", "요금제 - 390", 390.0), + screen("10:3", "Share sheet", 400.0), + screen("10:4", "Login popup", 400.0), + ], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); + assert_eq!(rows[0]["grouping"]["evidence"], "config:widthGrouping"); + assert_eq!(rows[0]["proposedRoute"], "/pricing"); + assert!(text(&rows[0]["routeEvidence"]).contains("routeAliases")); + assert_eq!(rows[1]["kind"], "modal"); + assert_eq!(rows[1]["kindEvidence"], "config"); + // The project's keywords replace the built-in ones. + assert_eq!(rows[2]["kind"], "page"); + let ids = question_ids(&plan); + assert!( + !ids.iter().any(|id| id.starts_with("group:") + || id.starts_with("route:10:1") + || id == "heuristics"), + "{ids:?}" + ); +} + +#[test] +fn a_frame_id_already_in_a_page_is_evidence() { + let mut evidence = evidence(); + evidence.figma_nodes.insert( + "11:1".into(), + ("/company".into(), "src/app/company/page.tsx".into()), + ); + let plan = draft_plan( + &[screen("11:1", "Intro", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + assert_eq!(plan["screens"][0]["proposedRoute"], "/company"); + assert!(text(&plan["screens"][0]["routeEvidence"]).contains("data-figma-node-id")); + assert_eq!(question_ids(&plan), ["collision:11:1"]); + assert_eq!( + figma_node_ids(""), + ["1:2", "3:4"] + ); +} + +#[tokio::test] +async fn confirm_rejects_missing_answers_by_id_and_accepts_a_complete_set() { + let fixture = Fixture::new(); + let mut draft_input = input(&fixture, "draft"); + draft_input.navigation_targets = BTreeMap::from([("6:3".to_owned(), "/contact".to_owned())]); + let drafted = draft( + &draft_input, + &[ + screen("6:1", "Pricing", 1440.0), + screen("6:2", "Careers", 1440.0), + screen("6:3", "Contact", 1440.0), + ], + json!({"from": "caller"}), + &fixture.policy(), + ) + .await + .unwrap(); + assert_eq!(drafted["askInstruction"], ASK_INSTRUCTION); + assert!(fixture.0.join(".devup/screen-plan.json").is_file()); + assert!(fixture.0.join(".devup/screen-plan.md").is_file()); + let plan_id = text(&drafted["planId"]).to_owned(); + + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = Some(plan_id.clone()); + confirm_input.answers = BTreeMap::from([( + "route:6:1".to_owned(), + AnswerInput::One("/pricing (Recommended)".into()), + )]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["route:6:2"])); + assert!(error.message.contains("route:6:2")); + assert_eq!( + read_plan(fixture.root()).unwrap().unwrap()["status"], + "draft" + ); + + confirm_input + .answers + .insert("route:6:2".into(), AnswerInput::One("/jobs".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| row["url"].clone()) + .collect::>(); + assert_eq!(urls, [json!("/pricing"), json!("/jobs"), json!("/contact")]); + assert_eq!(confirmed["customAnswers"][0]["answer"], "/jobs"); + let saved = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(saved["status"], "confirmed"); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("| /jobs | Careers |"), "{markdown}"); +} + +#[tokio::test] +async fn a_custom_route_answer_must_be_a_route() { + let fixture = Fixture::new(); + let drafted = draft( + &input(&fixture, "draft"), + &[screen("7:1", "Pricing", 1440.0)], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + confirm_input.answers = BTreeMap::from([( + "route:7:1".to_owned(), + AnswerInput::One("the pricing page".into()), + )]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert!(error.message.contains("custom answer must be an app route")); +} + +fn confirmed_plan() -> Value { + json!({ + "kind": PLAN_KIND, "schemaVersion": SCHEMA_VERSION, "planId": "sp-test", "status": "confirmed", + "source": {"nodeId": "10:0"}, + "routes": [ + {"url": "/", "screen": "10:1", "name": "Main", "nodeIds": ["10:1", "10:2"], "drawnWidths": [1440, 390], "implementation": "page", "file": "src/app/page.tsx"}, + {"url": "/about", "screen": "10:3", "name": "About", "nodeIds": ["10:3"], "drawnWidths": [1440], "implementation": "page", "file": "src/app/about/page.tsx"}, + {"url": null, "screen": "10:4", "name": "Login modal", "nodeIds": ["10:4"], "drawnWidths": [480], "implementation": "state", "hostUrl": "/"}, + ], + }) +} + +/// A width the audit has no item for is not-run, and a URL with a failing +/// width is never counted as verified. +#[test] +fn status_marks_widths_without_an_audit_item_not_run() { + let audit = json!({ + "verdict": "fail", + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "screenshot": "1440x900@1-main.png"}, + {"screen": "about", "routePath": "/about", "width": 1440, "status": "pass", "reasons": []}, + {"screen": "about", "routePath": "/about/", "width": 390, "status": "fail", "reasons": ["horizontal overflow: 42px past the viewport"]}, + ], + }); + let report = status_report(&confirmed_plan(), &evidence(), Some(&audit)); + let rows = array(&report["rows"]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["implemented"], "yes"); + assert_eq!(rows[1]["implemented"], "no"); + assert_eq!(rows[0]["widths"][0]["result"], "pass"); + assert_eq!(rows[0]["widths"][1]["result"], "not-run"); + assert_eq!(rows[1]["widths"][1]["result"], "fail"); + assert!( + text(&report["summary"]).starts_with( + "2 URLs: 1 implemented, 0 verified at all widths, /about overflows at 390" + ), + "{}", + report["summary"] + ); + assert_eq!(report["states"][0]["hostUrl"], "/"); + assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); + + let without = status_report(&confirmed_plan(), &evidence(), None); + assert!( + array(&without["rows"]) + .iter() + .flat_map(|row| array(&row["widths"])) + .all(|width| width["result"] == "not-run") + ); + assert!(text(&without["summary"]).contains("every width is not-run")); +} + +fn write_plan_file(fixture: &Fixture, plan: &Value) { + fixture.write(PLAN_FILE, &serde_json::to_string(plan).unwrap()); +} + +fn export<'a>(fixture: &'a Fixture, frames: &[&str]) -> ExportRequest<'a> { + ExportRequest { + project_root: fixture.root(), + url: None, + frames: frames.iter().map(|frame| (*frame).to_owned()).collect(), + all_screens: false, + scaffold_write: false, + scaffold_route: None, + output_paths: Vec::new(), + } +} + +#[test] +fn the_export_gate_blocks_page_writes_without_a_confirmed_plan() { + let fixture = Fixture::new(); + let mut scaffold = export(&fixture, &["10:3"]); + scaffold.scaffold_write = true; + scaffold.scaffold_route = Some("/about"); + let Gate::Blocked(response) = export_gate(&scaffold).unwrap() else { + panic!("a pageScaffold write without a plan is blocked"); + }; + assert_eq!(response["status"], "plan_required"); + assert_eq!(response["nextAction"]["tool"], "devup_screen_plan"); + assert_eq!(response["nextAction"]["arguments"]["action"], "draft"); + + let mut app_path = export(&fixture, &["10:3"]); + app_path.output_paths = vec!["src/app/about/page.tsx"]; + assert!(matches!(export_gate(&app_path).unwrap(), Gate::Blocked(_))); + + let mut several = export(&fixture, &["10:1", "10:3"]); + several.output_paths = vec!["src/components/Screens.tsx"]; + assert!(matches!(export_gate(&several).unwrap(), Gate::Blocked(_))); + + let mut draft_only = confirmed_plan(); + draft_only["status"] = json!("draft"); + write_plan_file(&fixture, &draft_only); + assert!(matches!(export_gate(&scaffold).unwrap(), Gate::Blocked(_))); +} + +#[test] +fn the_export_gate_never_blocks_a_component_or_a_preview() { + let fixture = Fixture::new(); + let mut component = export(&fixture, &["10:3"]); + component.output_paths = vec!["src/components/Card.tsx"]; + assert!(matches!(export_gate(&component).unwrap(), Gate::Open)); + let preview = export(&fixture, &["10:1", "10:3"]); + assert!(matches!(export_gate(&preview).unwrap(), Gate::Open)); + let mut scaffold_preview = export(&fixture, &["10:3"]); + scaffold_preview.scaffold_route = Some("/about"); + assert!(matches!( + export_gate(&scaffold_preview).unwrap(), + Gate::Open + )); +} + +#[test] +fn a_confirmed_plan_supplies_the_scaffold_route_and_refuses_another() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &confirmed_plan()); + let mut scaffold = export(&fixture, &["10:2"]); + scaffold.scaffold_write = true; + scaffold.scaffold_route = Some(""); + let Gate::Route(route) = export_gate(&scaffold).unwrap() else { + panic!("the plan covers 10:2"); + }; + assert_eq!(route, "/"); + scaffold.scaffold_route = Some("/home"); + let error = export_gate(&scaffold) + .err() + .expect("a different route is a mismatch"); + assert_eq!(error.details["routeMismatch"]["planned"], "/"); + + let mut uncovered = export(&fixture, &["99:1"]); + uncovered.scaffold_write = true; + uncovered.scaffold_route = Some("/x"); + let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { + panic!("a frame the plan does not place is blocked"); + }; + assert_eq!( + response["planRequired"]["details"]["uncoveredFrames"], + json!(["99:1"]) + ); + + let mut several = export(&fixture, &["10:1", "10:3"]); + several.output_paths = vec!["src/components/Screens.tsx"]; + assert!(matches!(export_gate(&several).unwrap(), Gate::Open)); +} + +#[tokio::test] +async fn project_evidence_reads_routes_layout_parts_and_nav_links() { + let fixture = Fixture::new(); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let routes = evidence + .routes + .iter() + .map(|(route, _)| route.as_str()) + .collect::>(); + assert!( + routes.contains(&"/") && routes.contains(&"/company"), + "{routes:?}" + ); + assert_eq!(evidence.app_dir, "src/app"); + assert!(evidence.layout_parts.contains(&"Header".to_owned())); + assert!( + evidence + .nav_links + .iter() + .any(|(label, href, _)| label == "회사소개" && href == "/company"), + "{:?}", + evidence.nav_links + ); +} + +fn answers(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(id, answer)| ((*id).to_owned(), AnswerInput::One((*answer).to_owned()))) + .collect() +} + +async fn drafted(fixture: &Fixture, screens: &[PlanScreenInput]) -> ScreenPlanInput { + let plan = draft( + &input(fixture, "draft"), + screens, + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(fixture, "confirm"); + confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); + confirm_input +} + +fn main_widths() -> [PlanScreenInput; 2] { + [ + screen("12:1", "Main - 1440", 1440.0), + screen("12:2", "Main - 390", 390.0), + ] +} + +/// Separate pages bring their own route questions; the answers decide the +/// URLs, and nothing is derived from a width. +#[tokio::test] +async fn separate_pages_need_a_route_per_frame_and_get_exactly_those() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "Separate pages"), + ("split-route:12:1", "/"), + ("heuristics", "Accept all (Recommended)"), + ]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["split-route:12:2"])); + assert!(error.details["questionRounds"][0]["questions"][0]["id"] == "split-route:12:2"); + + confirm_input + .answers + .insert("split-route:12:2".into(), AnswerInput::One("/m".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| (row["url"].clone(), row["nodeIds"].clone())) + .collect::>(); + assert_eq!( + urls, + [ + (json!("/"), json!(["12:1"])), + (json!("/m"), json!(["12:2"])) + ] + ); +} + +#[tokio::test] +async fn one_responsive_page_skips_the_split_routes_and_accepts_the_inferred_route() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "One responsive page (Recommended)"), + ("heuristics", "Accept all (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let route = &confirmed["routes"][0]; + assert_eq!(route["url"], "/"); + assert_eq!(route["nodeIds"], json!(["12:1", "12:2"])); + assert_eq!(route["collisionPolicy"], "merge"); + assert_eq!(route["routeEvidence"], "name-match (accepted)"); +} + +#[tokio::test] +async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "One responsive page (Recommended)"), + ("heuristics", "Review individually"), + ]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["review:12:1"])); + confirm_input + .answers + .insert("review:12:1".into(), AnswerInput::One("/home".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(confirmed["routes"][0]["url"], "/home"); +} + +#[tokio::test] +async fn a_custom_group_answer_can_name_one_route_per_frame() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "/desktop, /mobile"), + ("heuristics", "Accept all (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| row["url"].clone()) + .collect::>(); + assert_eq!(urls, [json!("/desktop"), json!("/mobile")]); +} + +/// After confirm nothing is asked: what implementation decides on its own is +/// recorded, and status and the final table carry it. +#[tokio::test] +async fn assumptions_are_recorded_after_confirm_and_reported_by_status() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &[screen("13:1", "Pricing", 1440.0)]).await; + let mut assume_input = input(&fixture, "assume"); + assume_input.assumptions = vec![AssumptionInput { + about: "13:9".into(), + decision: "Not built: outside the confirmed screen plan.".into(), + reason: Some("found during implementation".into()), + }]; + let early = assume(&assume_input, &fixture.policy()).unwrap_err(); + assert!(early.message.contains("after confirm")); + + confirm_input.answers = answers(&[("route:13:1", "/pricing (Recommended)")]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert!(text(&confirmed["rule"]).contains("without asking")); + let recorded = assume(&assume_input, &fixture.policy()).unwrap(); + assert_eq!(recorded["assumptions"][0]["about"], "13:9"); + + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + assert_eq!( + report["assumptions"][0]["decision"], + "Not built: outside the confirmed screen plan." + ); + assert!(text(&report["summary"]).contains("; 1 assumption made during implementation")); + assert!(text(&report["markdownTable"]).contains("- 13:9: Not built")); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("Assumptions made during implementation:")); + + let mut uncovered = export(&fixture, &["13:9"]); + uncovered.scaffold_write = true; + uncovered.scaffold_route = Some("/x"); + let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { + panic!("a frame outside the confirmed plan is not written"); + }; + assert_eq!(response["nextAction"]["arguments"]["action"], "assume"); + assert!(text(&response["nextAction"]["how"]).contains("Do not ask the user")); +} + +#[tokio::test] +async fn an_invalid_config_is_refused_rather_than_ignored() { + let fixture = Fixture::new(); + fixture.write(CONFIG_FILE, r#"{"widthGrouping": "sometimes"}"#); + let error = project_evidence(fixture.root()).await.unwrap_err(); + assert!(error.message.contains("widthGrouping")); + fixture.write( + CONFIG_FILE, + r#"{"routeAliases": {"메인": "/"}, "widthGrouping": "ask", "siteName": "Prime"}"#, + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + assert_eq!(evidence.config_file.as_deref(), Some(CONFIG_FILE)); + assert_eq!(evidence.config.route_aliases["메인"], "/"); +} + +fn in_trash(node_id: &str, name: &str, page: &str, area: &str) -> PlanScreenInput { + PlanScreenInput { + section: Some(format!("section-{area}")), + section_name: Some(area.into()), + breadcrumb: vec![page.into(), area.into(), name.into()], + ..screen(node_id, name, 1440.0) + } +} + +fn page_screen(node_id: &str, name: &str, page: &str, area: &str) -> PlanScreenInput { + in_trash(node_id, name, page, area) +} + +/// Picking the Figma fix marks the group: its frames are not built, the rest +/// goes on, and status reports exactly what to move or rename. +#[tokio::test] +async fn a_requested_design_fix_is_reported_not_built() { + let fixture = Fixture::new(); + let mut confirm_input = drafted( + &fixture, + &[ + sectioned("14:1", "Pricing", 1440.0, "PC"), + sectioned("14:2", "Pricing", 390.0, "Mobile"), + sectioned("14:3", "Careers", 1440.0, "PC"), + ], + ) + .await; + confirm_input.answers = answers(&[ + ("sections:14:1", FIX_FIGMA), + ("route:14:3", "/careers (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let built = array(&confirmed["routes"]) + .iter() + .flat_map(|row| array(&row["nodeIds"]).to_vec()) + .collect::>(); + assert_eq!(built, [json!("14:3")]); + let fix = &confirmed["designFixes"][0]; + assert_eq!(fix["question"], "sections:14:1"); + assert_eq!(array(&fix["frames"]).len(), 2); + assert!(text(&fix["fix"]).contains("PC, Mobile"), "{fix}"); + + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + assert!(text(&report["summary"]).contains("1 design fix requested, not built")); + let table = text(&report["markdownTable"]); + assert!( + table.contains("Design fixes requested (not built):"), + "{table}" + ); + assert!(table.contains("14:1") && table.contains("14:2"), "{table}"); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("Design fixes requested"), "{markdown}"); + + let mut fixed = export(&fixture, &["14:2"]); + fixed.scaffold_write = true; + let Gate::Blocked(response) = export_gate(&fixed).unwrap() else { + panic!("a frame marked for a design fix is never written"); + }; + assert_eq!(response["status"], "refused"); +} + +/// Trash, archive and copies are never built and never silently dropped. +#[test] +fn trash_and_archive_frames_are_excluded_and_asked_about_once() { + let plan = draft_plan( + &[ + page_screen("15:1", "Home", "Site", "Pages"), + in_trash("15:2", "Home", "Site", "휴지통"), + in_trash("15:3", "About", "Site", "Archive"), + page_screen("15:4", "Home copy", "Site", "Pages"), + ], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "page-1", "anchorName": "Site", "anchorBreadcrumb": ["Site"]}), + None, + ) + .unwrap(); + let excluded = array(&plan["excluded"]); + let ids = excluded + .iter() + .map(|row| text(&row["nodeId"])) + .collect::>(); + assert_eq!(ids, ["15:2", "15:3", "15:4"]); + assert!(text(&excluded[0]["reason"]).contains("휴지통")); + assert!(text(&excluded[1]["reason"]).contains("\"archive\"")); + assert!(text(&excluded[2]["reason"]).contains("\"copy\"")); + let question = find(&plan, "exclusions"); + assert_eq!(first_label(question), "Accept the exclusions (Recommended)"); + for id in ["15:2", "15:3", "15:4"] { + assert!(text(&question["question"]).contains(id)); + } + assert_eq!(array(&plan["screens"]).len(), 1); + assert!(!question_ids(&plan).iter().any(|id| id == "route:15:3")); + assert_rounds_cover(&plan); +} + +#[test] +fn the_config_can_change_and_override_the_exclusion_words() { + let mut evidence = evidence(); + evidence.config.exclude_keywords = Some(vec!["legacy".into()]); + evidence.config.include_keywords = None; + evidence.config.include_keywords = Some(vec!["test drive".into()]); + let plan = draft_plan( + &[ + page_screen("16:1", "Archive", "Site", "Pages"), + page_screen("16:2", "Legacy home", "Site", "Pages"), + page_screen("16:3", "Test drive", "Site", "Legacy"), + ], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let excluded = array(&plan["excluded"]) + .iter() + .map(|row| text(&row["nodeId"])) + .collect::>(); + assert_eq!(excluded, ["16:2"]); +} + +/// A link that itself points into an archive is asked once, not excluded. +#[test] +fn a_link_into_an_archive_is_asked_once_instead_of_excluded() { + let plan = draft_plan( + &[in_trash("17:1", "Home", "Site", "Archive")], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "section-Archive", "anchorName": "Archive", "anchorBreadcrumb": ["Site", "Archive"]}), + None, + ) + .unwrap(); + assert!(array(&plan["excluded"]).is_empty()); + let scope = find(&plan, "scope"); + assert!(text(&scope["question"]).contains("Archive")); + assert_eq!(find(&plan, "heuristics")["when"][0]["question"], "scope"); +} + +/// Never wider than the link: a frame whose ancestry does not reach the +/// linked node is not a candidate. +#[test] +fn frames_outside_the_linked_node_are_never_candidates() { + let plan = draft_plan( + &[ + page_screen("18:1", "About", "Site", "Company"), + page_screen("18:2", "Pricing", "Site", "Sales"), + ], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "section-Company", "anchorName": "Company", "anchorBreadcrumb": ["Site", "Company"]}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["id"], "18:1"); + assert_eq!(plan["outOfScope"][0]["nodeId"], "18:2"); + assert!(!plan["questions"].to_string().contains("18:2")); + // Explore's own answer maps the same way: a frame straight on the page + // is in no section, and ancestry keeps it in or out of scope. + let on_page = screen_from_explore(&json!({"nodeId": "19:1", "name": "Loose", "parentId": "0:1", + "bounds": {"width": 1440.0, "height": 900.0}, "breadcrumb": ["Site", "Loose"], "visible": true})) + .unwrap(); + assert_eq!(on_page.section, None); + assert!(in_scope(&on_page, Some("0:1"), Some("Site"))); + assert!(!in_scope( + &on_page, + Some("section-Company"), + Some("Company") + )); + let hidden = screen_from_explore(&json!({"nodeId": "19:2", "name": "Old", "parentId": "s", + "bounds": {"width": 1440.0}, "breadcrumb": ["Site", "Pages", "Old"], "visible": false})) + .unwrap(); + assert_eq!(hidden.section.as_deref(), Some("s")); + assert_eq!( + exclusion_reason(&hidden, &[], &PlanConfig::default()).as_deref(), + Some("hidden in Figma") + ); +} + +#[test] +fn other_structural_oddities_are_flagged_with_the_same_fix_option() { + let plan = draft_plan( + &[ + sectioned("20:1", "Notice", 1440.0, "Board"), + sectioned("20:2", "Notice", 1440.0, "Board"), + sectioned("20:3", "Guide 1", 1440.0, "Help"), + sectioned("20:4", "Guide 2", 1440.0, "Support"), + screen("20:5", "Contact", 1440.0), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + for id in [ + "structure:duplicate:20:1", + "structure:split:20:3", + "structure:loose:20:5", + ] { + let question = find(&plan, id); + let labels = array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert!(labels.contains(&FIX_FIGMA), "{id}: {labels:?}"); + } + // The duplicates' own route questions wait on the structure answer. + let route = find(&plan, "route:20:2"); + assert!( + array(&route["when"]) + .iter() + .any(|condition| condition["question"] == "structure:duplicate:20:1"), + "{route}" + ); + assert_rounds_cover(&plan); +} + +#[tokio::test] +async fn the_gate_refuses_an_excluded_frame_even_with_an_assumption() { + let fixture = Fixture::new(); + let plan = draft( + &input(&fixture, "draft"), + &[ + page_screen("21:1", "Pricing", "Site", "Pages"), + in_trash("21:2", "Pricing", "Site", "Trash"), + ], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); + confirm_input.answers = answers(&[ + ("route:21:1", "/pricing (Recommended)"), + ("exclusions", "Accept the exclusions (Recommended)"), + ]); + confirm(&confirm_input, &fixture.policy()).unwrap(); + let mut assume_input = input(&fixture, "assume"); + assume_input.assumptions = vec![AssumptionInput { + about: "21:2".into(), + decision: "Build it as /pricing-old".into(), + reason: None, + }]; + let recorded = assume(&assume_input, &fixture.policy()).unwrap(); + assert_eq!(recorded["stillNotBuilt"][0]["frame"], "21:2"); + let mut excluded = export(&fixture, &["21:2"]); + excluded.scaffold_write = true; + let Gate::Blocked(response) = export_gate(&excluded).unwrap() else { + panic!("an excluded frame is never written"); + }; + assert_eq!(response["status"], "refused"); + assert!(text(&response["refused"][0]["reason"]).starts_with("excluded")); +} + +fn bare_site() -> ProjectEvidence { + ProjectEvidence { + config: PlanConfig::default(), + ..evidence() + } +} + +fn labels(question: &Value) -> Vec<&str> { + array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect() +} + +/// The favicon and share image are files only the user can pick, so they +/// are asked - with the candidates the project and the linked Figma scope +/// actually hold, and never a generated one. +#[test] +fn site_files_are_asked_with_evidence_backed_candidates() { + let mut evidence = bare_site(); + evidence.icon_files = vec!["src/app/icon.svg".into()]; + evidence.package_name = Some("prime-web".into()); + let plan = draft_plan_with( + &[ + screen("30:3", "Home", 1440.0), + screen("30:2", "OG image", 1200.0), + ], + &[PlanAssetInput { + node_id: "30:1".into(), + name: "Logo".into(), + ..PlanAssetInput::default() + }], + &[], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let favicon = find(&plan, "site:favicon"); + assert_eq!( + labels(favicon), + [ + "Use src/app/icon.svg (Recommended)", + "Export \"Logo\" (30:1) as SVG", + "I will provide a file", + "Leave it for later" + ] + ); + assert!(text(&favicon["options"][2]["description"]).contains("Nothing is generated or drawn")); + let og = find(&plan, "site:og"); + assert_eq!( + labels(og)[..2], + [ + "Export \"OG image\" (30:2) as PNG (Recommended)", + "Use the top of \"Home\" (30:3)" + ] + ); + // A frame named like a share image is an asset, not a page. + assert_eq!(array(&plan["screens"]).len(), 1); + assert_eq!( + labels(find(&plan, "site:name"))[0], + "prime-web (Recommended)" + ); + assert_eq!(labels(find(&plan, "site:motion"))[0], "None (Recommended)"); + assert!(!question_ids(&plan).contains(&"site:analytics".to_owned())); + let defaults = array(&plan["defaults"]) + .iter() + .map(|default| text(&default["item"])) + .collect::>(); + for item in [ + "interaction-recipes", + "view-transitions", + "transition", + "reduced-motion", + "metadata", + "lang", + "alt-text", + "not-found", + "image-dimensions", + ] { + assert!(defaults.contains(&item), "{item} missing from {defaults:?}"); + } + assert_eq!(plan["site"]["favicon"]["state"], "asked"); + assert_rounds_cover(&plan); +} + +#[test] +fn the_config_and_the_project_pre_answer_the_site_questions() { + let plan = draft_plan( + &[screen("31:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + assert!( + !question_ids(&plan).iter().any(|id| id.starts_with("site:")), + "{:?}", + question_ids(&plan) + ); + assert_eq!(plan["site"]["siteName"]["state"], "config"); + + let mut found = bare_site(); + found.site_name = Some(( + "Prime".into(), + "title template in src/app/layout.tsx".into(), + )); + found.site_url = Some(( + "https://prime.example".into(), + "NEXT_PUBLIC_SITE_URL in .env".into(), + )); + found.analytics = vec!["@vercel/analytics in package.json".into()]; + let plan = draft_plan( + &[screen("31:1", "Home", 1440.0)], + &BTreeMap::new(), + &found, + json!({}), + None, + ) + .unwrap(); + let ids = question_ids(&plan); + assert!( + !ids.contains(&"site:name".to_owned()) && !ids.contains(&"site:url".to_owned()), + "{ids:?}" + ); + assert_eq!( + plan["site"]["siteUrl"]["evidence"], + "NEXT_PUBLIC_SITE_URL in .env" + ); + let analytics = find(&plan, "site:analytics"); + assert!(text(&analytics["question"]).contains("@vercel/analytics")); +} + +#[tokio::test] +async fn project_evidence_reads_the_site_facts() { + let fixture = Fixture::new(); + fixture.write("public/favicon.ico", "ico"); + fixture.write( + ".env", + "NEXT_PUBLIC_SITE_URL=https://prime.example\nNEXT_PUBLIC_GA_ID=G-1\n", + ); + fixture.write( + "src/app/layout.tsx", + "export const metadata = { title: { template: '%s | Prime' } };\nexport default function RootLayout({ children }) { return {children}; }", + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + assert_eq!(evidence.icon_files, ["public/favicon.ico"]); + assert_eq!( + evidence.site_url.as_ref().unwrap().0, + "https://prime.example" + ); + assert_eq!(evidence.site_name.as_ref().unwrap().0, "Prime"); + assert_eq!(evidence.lang.as_ref().unwrap().0, "ko"); + assert!( + evidence + .analytics + .iter() + .any(|sign| sign.contains("NEXT_PUBLIC_GA_ID")) + ); + assert!(evidence.not_found.is_none()); +} + +/// Status says, per item, whether it is filled, decided but not in place, +/// or missing - read from the files as they are now. +#[tokio::test] +async fn status_reports_site_completeness() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &[screen("32:1", "Company", 1440.0)]).await; + confirm_input.answers = answers(&[("heuristics", "Accept all (Recommended)")]); + confirm(&confirm_input, &fixture.policy()).unwrap(); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let report = status_report(&plan, &evidence, None); + let item = |name: &str| { + array(&report["siteCompleteness"]) + .iter() + .find(|item| item["item"] == name) + .cloned() + .unwrap_or_else(|| panic!("{name} missing: {}", report["siteCompleteness"])) + }; + assert_eq!(item("favicon")["state"], "asked-and-answered"); + assert_eq!(item("metadata /company")["state"], "missing"); + // The fixture header has two bare links in a nav. + assert_eq!(item("interaction-roles")["state"], "missing"); + assert_eq!( + item("interaction-roles")["detail"], + "nav items 0/2 hover+focus+aria-current" + ); + // Always planned; missing until the sources carry it. + assert_eq!(item("view-transitions")["state"], "missing"); + assert_eq!( + item("view-transitions")["detail"], + "planned (react-view-transition) but not in the sources yet" + ); + assert_eq!(item("site-name")["state"], "filled"); + let table = text(&report["markdownTable"]); + assert!(table.contains("Site completeness:"), "{table}"); + assert!( + table.contains("Filled in by default (no question):"), + "{table}" + ); + + fixture.write("src/app/icon.svg", ""); + fixture.write( + "src/app/company/page.tsx", + "export const metadata = { title: 'Company' };\nexport default function Page() { return
; }", + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let report = status_report(&plan, &evidence, None); + let states = array(&report["siteCompleteness"]) + .iter() + .map(|item| { + ( + text(&item["item"]).to_owned(), + text(&item["state"]).to_owned(), + ) + }) + .collect::>(); + assert_eq!(states["favicon"], "filled"); + assert_eq!(states["metadata /company"], "filled"); +} + +#[test] +fn a_missing_app_icon_is_a_warning() { + let fixture = Fixture::new(); + let finding = missing_app_icon(fixture.root()).expect("the fixture app has no icon"); + assert_eq!(finding.rule, "app-icon"); + fixture.write("src/app/favicon.ico", "ico"); + assert!(missing_app_icon(fixture.root()).is_none()); +} + +fn with_package(package: &str, lock: Option<&str>) -> Fixture { + let fixture = Fixture::new(); + fixture.write("package.json", package); + if let Some(lock) = lock { + fixture.write("package-lock.json", lock); + } + fixture +} + +async fn transition_default(fixture: &Fixture) -> Value { + let evidence = project_evidence(fixture.root()).await.unwrap(); + let plan = draft_plan( + &[screen("40:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + array(&plan["defaults"]) + .iter() + .find(|default| default["item"] == "view-transitions") + .cloned() + .expect("a view-transitions default") +} + +/// View transitions are always on: the dependency step brings every project +/// to the latest Next and React, so no version is checked. Only an explicit +/// opt-out, or a project that forbids the update while on an old React, +/// skips them; an existing page-motion library makes them a question. +#[tokio::test] +async fn view_transitions_are_always_on_unless_the_project_opts_out() { + for package in [ + r#"{"dependencies": {"next": "^16.1.0", "react": "19.3.0"}}"#, + r#"{"dependencies": {"next": "15.3.0", "react": "19.1.0"}}"#, + r#"{"dependencies": {"next": "^14.2.0", "react": "^18.3.0"}}"#, + r#"{"dependencies": {"next": "latest"}}"#, + ] { + let fixture = with_package(package, None); + let chosen = transition_default(&fixture).await; + assert_eq!( + chosen["mechanism"], "react-view-transition", + "{package}: {chosen}" + ); + assert!(text(&chosen["decision"]).contains("import { ViewTransition } from 'react'")); + assert!(text(&chosen["decision"]).contains("prefers-reduced-motion")); + assert!(text(&chosen["decision"]).contains("view-transition check")); + assert!(!array(&chosen["docs"]).is_empty()); + } + + let opted_out = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, + None, + ); + opted_out.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "viewTransitions": false}"#), + ); + let chosen = transition_default(&opted_out).await; + assert_eq!(chosen["mechanism"], "skipped"); + assert!(text(&chosen["evidence"]).contains("viewTransitions: false")); + + let frozen_old = with_package( + r#"{"dependencies": {"next": "14.2.0", "react": "18.3.1"}}"#, + None, + ); + frozen_old.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "updateDependencies": false}"#), + ); + assert_eq!( + transition_default(&frozen_old).await["mechanism"], + "skipped" + ); + let frozen_new = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, + None, + ); + frozen_new.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "updateDependencies": false}"#), + ); + assert_eq!( + transition_default(&frozen_new).await["mechanism"], + "react-view-transition" + ); + + let animated = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0", "framer-motion": "^11.0.0"}}"#, + None, + ); + let chosen = transition_default(&animated).await; + assert_eq!(chosen["mechanism"], "asked"); + assert_eq!(chosen["ifAdded"]["mechanism"], "react-view-transition"); + let evidence = project_evidence(animated.root()).await.unwrap(); + let plan = draft_plan( + &[screen("40:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + assert_eq!( + first_label(find(&plan, "site:view-transitions")), + "Keep framer-motion only (Recommended)" + ); +} + +#[test] +fn the_recipe_default_names_every_role_and_the_restraint_rules() { + let plan = draft_plan( + &[screen("41:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let recipes = array(&plan["defaults"]) + .iter() + .find(|default| default["item"] == "interaction-recipes") + .cloned() + .unwrap(); + let roles = array(&recipes["recipes"]) + .iter() + .map(|row| text(&row["role"])) + .collect::>(); + for role in [ + "primary button", + "card that links", + "nav item / tab", + "icon button", + "input / select / textarea", + ] { + assert!(roles.contains(&role), "{role} missing from {roles:?}"); + } + let restraint = text(&recipes["restraint"]); + for rule in [ + "at most two properties", + "150-250ms", + "0.97 and 1.05", + "_motionReduce", + "no hover", + ] { + assert!(restraint.contains(rule), "{rule} missing"); + } +} + +/// The agent's own questions go into the same up-front rounds, and their +/// answers come back from confirm. +#[tokio::test] +async fn extra_questions_are_asked_up_front_and_answered_at_confirm() { + let fixture = Fixture::new(); + let mut draft_input = input(&fixture, "draft"); + draft_input.extra_questions = vec![ + ExtraQuestionInput { + id: "contact-target".into(), + header: "Contact form target".into(), + question: "Where should the contact form send its messages?".into(), + options: vec![ + ExtraOptionInput { + label: "Email to the address in the footer (Recommended)".into(), + description: "A server action sending to the footer address.".into(), + }, + ExtraOptionInput { + label: "An existing API endpoint".into(), + description: "Named in openapi.json.".into(), + }, + ], + multiple: false, + }, + ExtraQuestionInput { + id: "legal".into(), + header: "Legal pages".into(), + question: "Which legal pages does the site need?".into(), + options: vec![ + ExtraOptionInput { + label: "Privacy policy".into(), + description: "/privacy".into(), + }, + ExtraOptionInput { + label: "Terms of service".into(), + description: "/terms".into(), + }, + ], + multiple: true, + }, + ]; + let drafted = draft( + &draft_input, + &[screen("42:1", "Pricing", 1440.0)], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let ids = array(&drafted["questions"]) + .iter() + .map(|q| text(&q["id"])) + .collect::>(); + assert!( + ids.contains(&"extra:contact-target") && ids.contains(&"extra:legal"), + "{ids:?}" + ); + let contact = array(&drafted["questions"]) + .iter() + .find(|q| q["id"] == "extra:contact-target") + .unwrap(); + assert_eq!( + first_label(contact), + "Email to the address in the footer (Recommended)" + ); + + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + confirm_input.answers = answers(&[ + ("route:42:1", "/pricing (Recommended)"), + ("extra:contact-target", "contact@prime.example"), + ]); + confirm_input.answers.insert( + "extra:legal".into(), + AnswerInput::Many(vec!["Privacy policy".into(), "Terms of service".into()]), + ); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!( + confirmed["extraAnswers"]["extra:contact-target"], + "contact@prime.example" + ); + assert_eq!( + confirmed["extraAnswers"]["extra:legal"], + json!(["Privacy policy", "Terms of service"]) + ); + + draft_input.extra_questions[0].header = "A header far longer than thirty characters".into(); + let error = draft( + &draft_input, + &[screen("42:1", "Pricing", 1440.0)], + json!({}), + &fixture.policy(), + ) + .await + .unwrap_err(); + assert!(error.message.contains("header"), "{}", error.message); +} + +fn template_fixture() -> Fixture { + let fixture = Fixture::new(); + for stale in ["package.json", "src"] { + let path = fixture.0.join(stale); + if path.is_dir() { + std::fs::remove_dir_all(&path).unwrap(); + } else { + std::fs::remove_file(&path).ok(); + } + } + crate::server::framework::tests::service_template(&fixture.0); + fixture +} + +fn default_item(plan: &Value, item: &str) -> Value { + array(&plan["defaults"]) + .iter() + .find(|default| default["item"] == item) + .cloned() + .unwrap_or_else(|| panic!("no {item} default: {}", plan["defaults"])) +} + +async fn plan_for(root: &str) -> (ProjectEvidence, Value) { + let evidence = project_evidence(root).await.unwrap(); + let plan = draft_plan( + &[screen("50:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + (evidence, plan) +} + +/// service-template: bun workspaces, vinext + React 19.3, no next +/// dependency. Its front app is an App Router app with view transitions +/// enabled, and the repository root, holding two apps, asks which one. +#[tokio::test] +async fn a_service_template_project_is_vinext_with_view_transitions_enabled() { + let fixture = template_fixture(); + let front = fixture.0.join("apps").join("front"); + let (evidence, plan) = plan_for(front.to_str().unwrap()).await; + assert_eq!(evidence.framework(), "vinext"); + let chosen = default_item(&plan, "view-transitions"); + assert_eq!(chosen["mechanism"], "react-view-transition", "{chosen}"); + assert!(text(&chosen["evidence"]).contains("vinext"), "{chosen}"); + assert!( + array(&chosen["docs"]) + .iter() + .any(|url| text(url).contains("vinext")) + ); + assert!(!question_ids(&plan).contains(&"site:framework".to_owned())); + + let (evidence, plan) = plan_for(fixture.root()).await; + assert_eq!(evidence.apps.len(), 2); + assert!(evidence.target_app.is_none()); + // "Home" is a public-site screen: apps/front, without asking. + assert_eq!(default_item(&plan, "target-app")["decision"], "apps/front"); + assert!(!question_ids(&plan).contains(&"site:app".to_owned())); + + let routes = super::super::project_context::run("routes", Some(fixture.root()), None) + .await + .unwrap(); + let apps = array(&routes["frameworks"]["apps"]) + .iter() + .map(|app| { + ( + text(&app["dir"]).to_owned(), + text(&app["framework"]).to_owned(), + ) + }) + .collect::>(); + assert_eq!( + apps, + [ + ("apps/admin".to_owned(), "next".to_owned()), + ("apps/front".to_owned(), "vinext".to_owned()) + ] + ); +} + +/// An existing project on the next CLI is asked once whether to migrate, +/// never migrated silently; the config can answer for it. +#[tokio::test] +async fn a_next_cli_project_is_asked_once_about_vinext() { + let fixture = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, + None, + ); + let (_, plan) = plan_for(fixture.root()).await; + let asked = find(&plan, "site:framework"); + assert_eq!(first_label(asked), "Migrate to vinext (Recommended)"); + assert!(text(&asked["question"]).contains("next 16.1.0")); + assert_eq!( + default_item(&plan, "view-transitions")["mechanism"], + "react-view-transition" + ); + + fixture.write( + CONFIG_FILE, + r#"{"framework": "next", "favicon": "app/icon.svg", "ogImage": "public/og.png", "siteName": "Prime", "siteUrl": "https://example.com", "motion": "none"}"#, + ); + let (_, plan) = plan_for(fixture.root()).await; + assert!(!question_ids(&plan).contains(&"site:framework".to_owned())); +} + +/// No app yet: start from the template without asking, ask only its name +/// and parts. +#[tokio::test] +async fn a_new_project_starts_from_the_template() { + let root = std::env::temp_dir().join(format!("screen-plan-new-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let (_, plan) = plan_for(root.to_str().unwrap()).await; + assert!( + text(&default_item(&plan, "project-start")["decision"]) + .contains("dev-five-git/service-template") + ); + assert_eq!( + default_item(&plan, "view-transitions")["mechanism"], + "react-view-transition" + ); + let ids = question_ids(&plan); + assert!( + ids.contains(&"site:app-name".to_owned()) && ids.contains(&"site:parts".to_owned()), + "{ids:?}" + ); + assert_eq!( + first_label(find(&plan, "site:parts")), + "Front only (Recommended)" + ); + std::fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn the_dependency_step_is_a_default_and_its_outcome_is_reported() { + let fixture = Fixture::new(); + let (_, plan) = plan_for(fixture.root()).await; + let step = default_item(&plan, "dependencies"); + assert!(text(&step["decision"]).contains("bunx @dependency-check-updates/cli -d -u --rm")); + assert!(text(&step["decision"]).contains("Cargo.lock and target/")); + + let mut confirm_input = drafted(&fixture, &[screen("51:1", "Pricing", 1440.0)]).await; + confirm_input.answers = answers(&[("route:51:1", "/pricing (Recommended)")]); + confirm(&confirm_input, &fixture.policy()).unwrap(); + let report = status_report( + &read_plan(fixture.root()).unwrap().unwrap(), + &evidence(), + None, + ); + let line = array(&report["siteCompleteness"]) + .iter() + .find(|item| item["item"] == "dependencies") + .cloned() + .unwrap(); + assert_eq!(line["state"], "missing"); + + let mut record = input(&fixture, "assume"); + record.dependency_update = Some(DependencyUpdateInput { + bumped: 14, + pins: vec![PinInput { + package: "oxlint".into(), + version: "1.86.0".into(), + reason: "eslint-plugin-devup does not load on 1.87 yet".into(), + }], + }); + let recorded = assume(&record, &fixture.policy()).unwrap(); + assert_eq!(recorded["assumptions"][0]["about"], "dependency oxlint"); + let report = status_report( + &read_plan(fixture.root()).unwrap().unwrap(), + &evidence(), + None, + ); + let line = array(&report["siteCompleteness"]) + .iter() + .find(|item| item["item"] == "dependencies") + .cloned() + .unwrap(); + assert_eq!(line["state"], "filled"); + assert!( + text(&line["detail"]).contains("14 packages bumped, pinned oxlint@1.86.0"), + "{line}" + ); + + fixture.write(CONFIG_FILE, r#"{"updateDependencies": false, "favicon": "app/icon.svg", "ogImage": "public/og.png", "siteName": "Prime", "siteUrl": "https://example.com", "motion": "none"}"#); + let (_, plan) = plan_for(fixture.root()).await; + assert_eq!(default_item(&plan, "dependencies")["mechanism"], "skipped"); +} + +#[test] +fn bun_lock_versions_are_read() { + let lock = r#"{ + "packages": { + "react": ["react@19.3.1", "", {}, "sha512-x"], + "vinext": ["vinext@1.0.2", "", {}, "sha512-y"], + } +}"#; + assert_eq!(bun_locked(lock, "react").as_deref(), Some("19.3.1")); + assert_eq!(bun_locked(lock, "vinext").as_deref(), Some("1.0.2")); + assert_eq!(bun_locked(lock, "next"), None); +} + +async fn plan_screens_at(root: &str, screens: &[PlanScreenInput]) -> Value { + let evidence = project_evidence(root).await.unwrap(); + draft_plan(screens, &BTreeMap::new(), &evidence, json!({}), None).unwrap() +} + +fn rows_app(plan: &Value) -> Vec<(String, String)> { + array(&plan["screens"]) + .iter() + .map(|row| (text(&row["id"]).to_owned(), text(&row["app"]).to_owned())) + .collect() +} + +/// apps/front and apps/admin: the app is picked by what the frames are, not +/// by name order. Marketing frames build the public site without a question. +#[tokio::test] +async fn marketing_frames_go_to_the_public_app_without_a_question() { + let fixture = template_fixture(); + let plan = plan_screens_at( + fixture.root(), + &[ + screen("60:1", "Main", 1440.0), + screen("60:2", "회사소개", 1440.0), + screen("60:3", "Contact", 1440.0), + ], + ) + .await; + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("site:app") || id.starts_with("app:")), + "{:?}", + question_ids(&plan) + ); + let chosen = default_item(&plan, "target-app"); + assert_eq!(chosen["decision"], "apps/front"); + assert_eq!(chosen["mechanism"], "decided"); + assert!( + chosen["evidence"] + .to_string() + .contains("named like the public site"), + "{chosen}" + ); + assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/front")); + assert_eq!(plan["appDir"], "apps/front/src/app"); +} + +#[tokio::test] +async fn admin_frames_go_to_the_admin_app() { + let fixture = template_fixture(); + let plan = plan_screens_at( + fixture.root(), + &[ + screen("61:1", "Admin - 회원관리", 1440.0), + screen("61:2", "Admin - 상품 목록", 1440.0), + ], + ) + .await; + assert_eq!(default_item(&plan, "target-app")["decision"], "apps/admin"); + assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/admin")); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("site:app")) + ); +} + +/// A file with both maps each screen to its side; a screen with no admin +/// evidence goes to the public app without a question, and each screen's +/// route options point into its own app. +#[tokio::test] +async fn a_mixed_file_maps_each_screen_and_sends_the_unclear_to_the_public_app() { + let fixture = template_fixture(); + let screens = [ + screen("62:1", "Home", 1440.0), + screen("62:2", "관리자 - 대시보드", 1440.0), + screen("62:3", "Settings", 1440.0), + ]; + let plan = plan_screens_at(fixture.root(), &screens).await; + let chosen = default_item(&plan, "target-app"); + assert_eq!(chosen["mechanism"], "per-screen"); + assert!( + text(&chosen["decision"]).contains("no admin evidence go to apps/front"), + "{chosen}" + ); + let apps = rows_app(&plan).into_iter().collect::>(); + assert_eq!(apps["62:1"], "apps/front"); + assert_eq!(apps["62:2"], "apps/admin"); + assert_eq!(apps["62:3"], "apps/front"); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("app:") || id == "site:app"), + "{:?}", + question_ids(&plan) + ); + let settings = find(&plan, "route:62:3"); + assert!( + text(&settings["options"][0]["description"]) + .contains("apps/front/src/app/settings/page.tsx"), + "{settings}" + ); + // Home is offered nothing from the admin app; the dashboard is offered + // the admin app's own pages. + for question in array(&plan["questions"]) { + let about_home = text(&question["id"]).ends_with("62:1"); + let mentions_admin = question["options"].to_string().contains("apps/admin/"); + assert!(!(about_home && mentions_admin), "{question}"); + } + assert!( + find(&plan, "route:62:2")["options"] + .to_string() + .contains("apps/admin/src/app/page.tsx") + ); +} + +/// Frames that say nothing either way go to the public app: with no admin +/// evidence that is the default, recorded rather than asked. +#[tokio::test] +async fn frames_that_say_nothing_go_to_the_public_app_without_a_question() { + let fixture = template_fixture(); + let screens = [ + screen("63:1", "Frame 12", 1440.0), + screen("63:2", "Frame 13", 1440.0), + ]; + let plan = plan_screens_at(fixture.root(), &screens).await; + assert!(!question_ids(&plan).iter().any(|id| id == "site:app")); + let chosen = default_item(&plan, "target-app"); + assert_eq!(chosen["mechanism"], "decided"); + assert_eq!(chosen["decision"], "apps/front"); + assert!( + chosen["evidence"].to_string().contains("no admin evidence"), + "{chosen}" + ); + assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/front")); +} + +/// Only when no app looks like the public site is the app asked - and the +/// answer still decides where each route is written. +#[tokio::test] +async fn the_app_is_asked_only_when_no_app_is_the_public_site() { + let fixture = template_fixture(); + for (from, to) in [("apps/front", "apps/alpha"), ("apps/admin", "apps/beta")] { + std::fs::rename(fixture.0.join(from), fixture.0.join(to)).unwrap(); + } + fixture.write( + "apps/alpha/package.json", + r#"{"name": "alpha", "dependencies": {"next": "16.1.0"}}"#, + ); + fixture.write( + "apps/beta/package.json", + r#"{"name": "beta", "dependencies": {"next": "16.1.0"}}"#, + ); + let screens = [ + screen("63:1", "Frame 12", 1440.0), + screen("63:2", "Frame 13", 1440.0), + ]; + let plan = plan_screens_at(fixture.root(), &screens).await; + let which = find(&plan, "site:app"); + assert_eq!(labels(which).len(), 2, "{which}"); + + let draft_input = ScreenPlanInput { + action: "draft".into(), + project_root: Some(fixture.root().into()), + ..ScreenPlanInput::default() + }; + let drafted = draft(&draft_input, &screens, json!({}), &fixture.policy()) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + let mut pairs = vec![("site:app", "apps/beta")]; + for question in array(&drafted["questions"]) { + let id = text(&question["id"]); + if id.starts_with("route:") { + pairs.push(( + id, + if id.ends_with("63:1") { + "/members" + } else { + "/orders" + }, + )); + } + } + let owned = pairs + .iter() + .map(|(id, answer)| (id.to_string(), answer.to_string())) + .collect::>(); + confirm_input.answers = answers( + &owned + .iter() + .map(|(id, answer)| (id.as_str(), answer.as_str())) + .collect::>(), + ); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let files = array(&confirmed["routes"]) + .iter() + .map(|row| (text(&row["app"]).to_owned(), text(&row["file"]).to_owned())) + .collect::>(); + assert_eq!( + files[0], + ( + "apps/beta".to_owned(), + "apps/beta/src/app/members/page.tsx".to_owned() + ), + "{files:?}" + ); +} + +/// auditPlan writes one runtime-audit plan per confirmed URL and the runner +/// once, records them, and reports the frames it has no export for instead +/// of skipping them. +#[tokio::test] +async fn audit_plan_writes_a_plan_per_url_and_reports_what_it_cannot_compare() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &confirmed_plan()); + let store = ArtifactStore::default(); + let mut request = input(&fixture, "auditPlan"); + request.artifacts = vec!["expired-artifact".into()]; + let result = audit_plan(&request, &fixture.policy(), &store) + .await + .unwrap(); + let plans = array(&result["plans"]); + assert_eq!( + plans + .iter() + .map(|plan| text(&plan["url"])) + .collect::>(), + ["/", "/about"] + ); + assert!( + fixture + .0 + .join(".devup/runtime-audit/home/plan.json") + .exists() + ); + assert!( + fixture + .0 + .join(".devup/runtime-audit/about/plan.json") + .exists() + ); + assert!( + fixture + .0 + .join(".devup/runtime-audit/runner/run.mjs") + .exists() + ); + assert_eq!(result["runner"]["state"], "written"); + // The plugin that strips the audit ids from production builds sits next + // to the app's vite.config, and the config is not wired to it yet. + let strip = &result["productionStrip"][0]; + assert!( + fixture + .0 + .join("vite-plugin-strip-figma-node-ids.mjs") + .exists() + ); + assert_eq!(strip["written"], true); + assert_eq!(strip["wired"], false); + assert!(text(&strip["verify"]).contains("grep -rl data-figma-node-id")); + assert_eq!( + result["expiredArtifacts"][0]["artifactId"], + "expired-artifact" + ); + assert_eq!(result["maxFixAttempts"], 3); + let frames = array(&result["missingReferences"]) + .iter() + .filter(|item| item["kind"] == "frame") + .map(|item| { + ( + text(&item["nodeId"]).to_owned(), + item["width"].as_u64().unwrap(), + ) + }) + .collect::>(); + assert_eq!( + frames, + [ + ("10:1".to_owned(), 1440), + ("10:2".to_owned(), 390), + ("10:3".to_owned(), 1440) + ] + ); + let home = &plans[0]; + assert_eq!(home["checks"]["view-transition"], 1); + assert_eq!(home["checks"]["page-health"], 1); + let written = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(array(&written["audit"]["plans"]).len(), 2); + assert!(text(&written["audit"]["plans"][0]["reportPath"]).ends_with("home/report/report.json")); + + // The runner is written once; a second run leaves it alone. + let again = audit_plan(&request, &fixture.policy(), &store) + .await + .unwrap(); + assert_eq!(again["runner"]["state"], "current"); +} + +/// A decision mid-run never stops the work: it is pending, its URL's other +/// work comes first, and assume takes it at the end - except a destructive +/// one, which stays unbuilt and is reported. +#[tokio::test] +async fn a_pending_decision_is_taken_at_the_end_and_a_destructive_one_is_left_unbuilt() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &confirmed_plan()); + let mut request = input(&fixture, "defer"); + request.decision = Some(PendingDecisionInput { + id: "about-form".into(), + about: "/about contact form".into(), + question: "Where does the contact form post?".into(), + options: vec!["mailto".into(), "an API route".into()], + recommended: "mailto".into(), + destructive: false, + }); + let deferred = defer(&request, &fixture.policy()).unwrap(); + assert_eq!(deferred["pendingDecisions"][0]["status"], "open"); + request.decision = Some(PendingDecisionInput { + id: "drop-old-page".into(), + about: "src/app/old/page.tsx".into(), + question: "Delete the old page?".into(), + options: Vec::new(), + recommended: "keep it".into(), + destructive: true, + }); + defer(&request, &fixture.policy()).unwrap(); + + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + let about = array(&report["workQueue"]) + .iter() + .find(|row| row["url"] == "/about") + .unwrap() + .clone(); + let order = array(&about["items"]) + .iter() + .map(|item| text(&item["do"]).to_owned()) + .collect::>(); + assert_eq!( + order.last().map(String::as_str), + Some("blocked"), + "{order:?}" + ); + + let mut take = input(&fixture, "assume"); + take.assumptions = vec![ + AssumptionInput { + about: "about-form".into(), + decision: "mailto".into(), + reason: Some("recommended".into()), + }, + AssumptionInput { + about: "drop-old-page".into(), + decision: "delete".into(), + reason: None, + }, + ]; + let taken = assume(&take, &fixture.policy()).unwrap(); + let decisions = array(&taken["pendingDecisions"]); + assert_eq!(decisions[0]["status"], "taken"); + assert_eq!(decisions[0]["taken"], "mailto"); + assert_eq!(decisions[1]["status"], "left-unbuilt"); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + let text_report = text(&report["finalReport"]); + assert!( + text_report + .contains("about-form: Where does the contact form post? -> mailto (taken at the end)"), + "{text_report}" + ); + assert!( + text_report + .contains("drop-old-page: Delete the old page? -> keep it (destructive: left unbuilt)") + ); + assert!( + text_report + .contains("| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction |") + ); + assert!(text_report.contains("Dependencies: the update step has not been recorded.")); +} diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs index 1f350aaa..23b30309 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -27,7 +27,8 @@ //! each one pins the commit it copied and `scripts/refresh-skills.mjs` is how //! that copy is moved forward. //! -//! `own` skills are written here, in this repository - devfive-frontend. There +//! `own` skills are written here, in this repository - devfive-frontend, +//! devup-implementation-plan and devup. There //! is no upstream to vendor from and nothing for the refresh script to compare //! against, so they pin no commit; this repository's own history is their //! provenance. They are otherwise installed exactly like an embedded skill, @@ -136,6 +137,17 @@ const EMBEDDED: &[(&str, Documents)] = &[ ), ], ), + ( + "devup-implementation-plan", + &[( + "SKILL.md", + include_str!("skills/devup-implementation-plan/SKILL.md"), + )], + ), + ( + "devup", + &[("SKILL.md", include_str!("skills/devup/SKILL.md"))], + ), ( "changepacks", &[("SKILL.md", include_str!("skills/changepacks/SKILL.md"))], @@ -1159,6 +1171,137 @@ impl Skill { } } +/// The explicit entry point: `/devup [figma url] [notes]`. +/// +/// An MCP server cannot start a skill or a slash command itself, so the user +/// gets one to type. Claude Code turns every project skill into a slash +/// command of the same name, so the `devup` skill installed with the others +/// already is `/devup` there. OpenCode keeps slash commands in their own +/// directory, so it gets a command file as well. Every other runtime is +/// reported unsupported rather than given a path nobody verified. +pub const COMMAND_NAME: &str = "devup"; +pub const COMMAND_INVOCATION: &str = "/devup [figma url] [notes]"; +/// Marks a command file devup-mcp wrote, so a later install may rewrite it +/// and leaves anyone else's alone. +const COMMAND_MARKER: &str = " +

First

Next
+ + + diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index 55cf5b1f..f78387d6 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -15,7 +15,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { release } from 'node:os'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { evaluateCheck, evaluateDimensions, summarize } from './checks.mjs'; +import { + ERROR_PAGE_TEXT, STATE_PROPERTIES, evaluateBounds, evaluateHidden, evaluateCheck, evaluateClippedText, evaluateDimensions, + evaluateInteraction, evaluatePageHealth, evaluateViewTransition, summarize, +} from './checks.mjs'; const PLAN_KIND = 'devup-runtime-audit-plan'; const REPORT_KIND = 'devup-runtime-audit-report'; @@ -126,6 +129,123 @@ async function measureBox(page, target, measure = 'box') { return { count, box: box ?? { x: 0, y: 0, width: 0, height: 0 }, visible }; } +// Counts the view transitions a navigation runs. A same-document one goes +// through document.startViewTransition, which React's calls; +// a cross-document one (the CSS @view-transition rule) arrives as a pagereveal +// event carrying a viewTransition. The count survives a document change in +// sessionStorage, which is per tab and origin. +const VIEW_TRANSITION_PROBE = () => { + const key = '__devupViewTransitions'; + const bump = () => sessionStorage.setItem(key, String(Number(sessionStorage.getItem(key) || 0) + 1)); + window.addEventListener('pagereveal', (event) => { + if (event.viewTransition) bump(); + }); + if (typeof document.startViewTransition === 'function' && !document.startViewTransition.__devup) { + const original = document.startViewTransition; + const counted = function startViewTransition(...args) { + bump(); + return original.apply(this, args); + }; + counted.__devup = true; + document.startViewTransition = counted; + } +}; + +async function observeViewTransition(page, check, timeout) { + const target = locate(page, check.subject); + const subjectCount = await target.count(); + if (subjectCount !== 1) return { subjectCount }; + const supported = await page.evaluate(() => typeof document.startViewTransition === 'function'); + const read = () => page.evaluate(() => Number(sessionStorage.getItem('__devupViewTransitions') || 0)); + const before = await read(); + const from = new URL(page.url()).pathname; + await target.click({ timeout }); + const navigated = await page + .waitForURL((url) => url.pathname !== from, { timeout }) + .then(() => true, () => false); + let calls = 0; + if (supported && navigated) { + calls = await page + .waitForFunction((start) => Number(sessionStorage.getItem('__devupViewTransitions') || 0) > start, before, { timeout: 2000 }) + .then(read, read) + .then((now) => now - before); + } + return { subjectCount, supported, navigated, calls, from, to: new URL(page.url()).pathname }; +} + +// Text an element cuts off itself: overflow hidden or clipped past its box, +// not an intended ellipsis or line clamp. +async function clippedText(page, subject) { + const scope = subject ? locate(page, subject) : page.locator('body'); + if ((await scope.count()) !== 1) return { clipped: [] }; + return scope.evaluate((root) => { + const clipped = []; + for (const element of [root, ...root.querySelectorAll('*')]) { + if (clipped.length >= 10) break; + const own = [...element.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent.trim()).join(' '); + if (!own) continue; + const style = getComputedStyle(element); + if (style.textOverflow === 'ellipsis' || (style.webkitLineClamp && style.webkitLineClamp !== 'none')) continue; + const clips = (value) => value === 'hidden' || value === 'clip'; + const wide = clips(style.overflowX) && element.scrollWidth > element.clientWidth + 1; + const tall = clips(style.overflowY) && element.scrollHeight > element.clientHeight + 1; + if (wide || tall) clipped.push({ text: own.slice(0, 60), tag: element.tagName.toLowerCase() }); + } + return { clipped }; + }); +} + +async function stateStyles(locator) { + return locator.evaluate((element, properties) => { + const style = getComputedStyle(element); + return Object.fromEntries(properties.map((property) => [property, style[property]])); + }, STATE_PROPERTIES); +} + +// Hover moves the mouse over the subject; focus sets keyboard modality first +// so :focus-visible applies, as it does for a keyboard user. Both wait out +// the shared transition before reading the styles. +async function enterState(page, target, state, timeout) { + const before = await stateStyles(target); + if (state === 'focus') { + await page.keyboard.press('Shift'); + await target.focus({ timeout }); + } else { + await target.hover({ timeout }); + } + await page.waitForTimeout(450); + const after = await stateStyles(target); + if (state === 'focus') await target.evaluate((element) => element.blur()); + await page.mouse.move(0, 0); + await page.waitForTimeout(state === 'focus' ? 0 : 300); + return { before, after }; +} + +async function observeInteraction(page, check, timeout) { + if (!check.subject) { + const all = page.locator('a[href], button, [role="button"]'); + const items = []; + const count = Math.min(await all.count(), 40); + for (let index = 0; index < count; index += 1) { + const target = all.nth(index); + if (!(await target.isVisible())) continue; + const box = await target.boundingBox(); + if (!box || box.width === 0 || box.height === 0) continue; + const described = await target.evaluate((element) => ({ + tag: element.tagName.toLowerCase(), + name: (element.getAttribute('aria-label') || element.textContent || '').trim().slice(0, 40), + })); + const states = await enterState(page, target, check.state, timeout).catch(() => null); + if (states) items.push({ ...described, ...states }); + } + return { items }; + } + const target = locate(page, check.subject); + const subjectCount = await target.count(); + if (subjectCount !== 1) return { subjectCount }; + return { subjectCount, ...(await enterState(page, target, check.state, timeout)) }; +} + async function pageMetrics(page) { return page.evaluate(() => { const viewportWidth = window.innerWidth; @@ -137,6 +257,19 @@ async function pageMetrics(page) { if (element.checkVisibility && !element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; const rect = element.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) continue; + // Content inside a horizontal scroll container (a carousel) scrolls + // there; it is not page overflow. The container itself still counts. + let scroller = element.parentElement; + let inScroller = false; + while (scroller && scroller !== document.body) { + const overflowX = getComputedStyle(scroller).overflowX; + if (overflowX === 'auto' || overflowX === 'scroll') { + inScroller = true; + break; + } + scroller = scroller.parentElement; + } + if (inScroller) continue; const right = rect.right + window.scrollX; if (right > maxRight) { maxRight = right; @@ -207,6 +340,7 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { blocked.push(url); return route.abort('blockedbyclient'); }); + await context.addInitScript(VIEW_TRANSITION_PROBE); const page = await context.newPage(); page.on('pageerror', (error) => environmentErrors.push({ kind: 'page-error', message: String(error?.message ?? error) })); page.on('console', (message) => { @@ -230,6 +364,7 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { for (const screen of plan.screens) { const record = { id: screen.id, status: 'completed', checks: [] }; run.screens.push(record); + const errorsAtStart = environmentErrors.length; const notMeasured = (status, reason) => { record.checks = screen.checks.map((check, index) => ({ index, kind: check.kind, label: check.label, status, reason, measured: {} })); }; @@ -278,11 +413,59 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { environmentErrors.push({ kind: 'font-missing', message: `${family} is not loaded` }); } for (const [index, check] of screen.checks.entries()) { + // A view-transition check navigates away, so it runs after every + // other check and the screenshot have seen this screen. + if (check.kind === 'view-transition') continue; + if (Array.isArray(check.widths) && check.widths.length && !check.widths.includes(viewport.width)) continue; + const item = { index, kind: check.kind, label: check.label }; + if (check.kind === 'page-health') { + const text = await page.evaluate(() => (document.body ? document.body.innerText : '')); + const errors = environmentErrors + .slice(errorsAtStart) + .filter((error) => ['page-error', 'console-error', 'navigation-failed'].includes(error.kind)); + record.checks.push({ ...item, ...evaluatePageHealth({ errorTexts: ERROR_PAGE_TEXT.filter((needle) => text.includes(needle)), errors }) }); + continue; + } + if (check.kind === 'hidden') { + const target = locate(page, check.subject); + const subjectCount = await target.count(); + const visible = subjectCount === 1 && await target.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 + && (!element.checkVisibility || element.checkVisibility({ opacityProperty: true, visibilityProperty: true })); + }); + record.checks.push({ ...item, ...evaluateHidden({ subjectCount, visible }) }); + continue; + } + if (check.kind === 'no-clipped-text') { + record.checks.push({ ...item, ...evaluateClippedText(await clippedText(page, check.subject)) }); + continue; + } + if (check.kind === 'interaction') { + const observed = await observeInteraction(page, check, stepTimeout).catch((error) => ({ subjectCount: 1, error: String(error?.message ?? error) })); + record.checks.push({ ...item, ...(observed.error + ? { status: 'inconclusive', reason: `the subject could not be ${check.state === 'focus' ? 'focused' : 'hovered'}: ${observed.error.split('\n')[0]}`, measured: {} } + : evaluateInteraction(check, observed)) }); + continue; + } + if (check.kind === 'capture') { + const target = locate(page, check.subject); + const count = await target.count(); + if (count !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: count === 0 ? 'subject matched no element' : `subject matched ${count} elements; a check needs exactly one`, measured: {} }); + continue; + } + const shot = `${viewport.width}x${viewport.height}@${viewport.deviceScaleFactor ?? 1}-${safeName(screen.id)}-c${index}.png`; + const bytes = await target.screenshot({ animations: 'disabled', caret: 'hide' }); + await writeFile(join(out, shot), bytes); + record.checks.push({ ...item, status: 'pass', reason: 'captured for the server to compare with its Figma render', measured: {}, screenshot: { path: shot, sha256: sha256(bytes), bytes: bytes.length } }); + continue; + } const subject = await measureBox(page, check.subject, check.measure); const container = await measureBox(page, check.container); const result = subject.error ? { status: 'inconclusive', reason: subject.error, measured: {} } - : (check.kind === 'dimensions' ? evaluateDimensions : evaluateCheck)(check, { + : (check.kind === 'dimensions' ? evaluateDimensions : check.kind === 'bounds' ? evaluateBounds : evaluateCheck)(check, { page: metrics, subject: subject.box, subjectCount: subject.count, @@ -301,6 +484,36 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { }); await writeFile(join(out, name), bytes); record.screenshot = { path: name, sha256: sha256(bytes), bytes: bytes.length }; + // Links on this page, so a dynamic route ([slug]) can be audited at the + // URLs the site itself links to. + record.links = await page.evaluate(() => { + const seen = new Set(); + for (const anchor of document.querySelectorAll('a[href]')) { + const url = new URL(anchor.getAttribute('href'), location.href); + if (url.origin === location.origin) seen.add(url.pathname); + if (seen.size >= 200) break; + } + return [...seen]; + }).catch(() => []); + for (const [index, check] of screen.checks.entries()) { + if (check.kind !== 'view-transition') continue; + if (Array.isArray(check.widths) && check.widths.length && !check.widths.includes(viewport.width)) continue; + const returnTo = page.url(); + const observed = await observeViewTransition(page, check, stepTimeout).catch((error) => ({ + subjectCount: 1, supported: true, navigated: false, calls: 0, error: String(error?.message ?? error), + })); + const result = observed.error + ? { status: 'inconclusive', reason: `the subject could not be followed: ${observed.error.split('\n')[0]}`, measured: {} } + : evaluateViewTransition(observed); + record.checks.push({ index, kind: check.kind, label: check.label, ...result }); + // Back to where the check started, so a following screen reached by + // steps starts from this screen whatever order the plan has. + if (page.url() !== returnTo) { + await page.goto(returnTo, { waitUntil: 'load', timeout: stepTimeout }).catch(() => {}); + await waitReady(page, plan, stepTimeout).catch(() => {}); + } + } + record.checks.sort((a, b) => a.index - b.index); } } finally { run.finishedAt = new Date().toISOString(); diff --git a/harness/runtime-audit/strip-figma-node-ids.mjs b/harness/runtime-audit/strip-figma-node-ids.mjs new file mode 100644 index 00000000..278cb6a1 --- /dev/null +++ b/harness/runtime-audit/strip-figma-node-ids.mjs @@ -0,0 +1,36 @@ +// A Vite plugin that removes data-figma-node-id and data-figma-node-ids from +// JSX in `vite build` (vinext build), so production HTML carries no audit +// attributes. They stay in `vite dev` and in an audit build: +// `DEVUP_AUDIT=1 bun run build`. +// +// It runs as a `pre` transform on .tsx/.jsx sources: Rolldown transforms +// TypeScript and JSX only after the transform hooks run, so the attributes +// are still plain JSX text here. devup-mcp writes them as double-quoted +// string literals, which is all this matches. +// +// import stripFigmaNodeIds from './vite-plugin-strip-figma-node-ids.mjs' +// export default { plugins: [stripFigmaNodeIds(), vinext()] } + +const ATTRIBUTE = /\s+data-figma-node-ids?="[^"]*"/g; + +/** The source with every data-figma-node-id(s) attribute removed. */ +export function stripFigmaNodeIds(code) { + return code.replace(ATTRIBUTE, ''); +} + +/** Whether a build keeps the attributes: an audit build, or a dev server. */ +export function keepsAttributes(command, env = process.env) { + return command !== 'build' || env.DEVUP_AUDIT === '1'; +} + +export default function stripFigmaNodeIdsPlugin(env = process.env) { + return { + name: 'devup-strip-figma-node-ids', + enforce: 'pre', + apply: (_config, { command }) => !keepsAttributes(command, env), + transform(code, id) { + if (!/\.[jt]sx$/.test(id.split('?')[0]) || !code.includes('data-figma-node-id')) return null; + return { code: stripFigmaNodeIds(code), map: null }; + }, + }; +} diff --git a/harness/runtime-audit/test/browser.test.mjs b/harness/runtime-audit/test/browser.test.mjs index 5c30eea3..d33140b4 100644 --- a/harness/runtime-audit/test/browser.test.mjs +++ b/harness/runtime-audit/test/browser.test.mjs @@ -219,3 +219,137 @@ test('dimensions across 1440/1100/858/390 retain CSS pixels at DPR 2 and filter await rm(out, { recursive: true, force: true }); } }); + +test('a view-transition check sees same-document and cross-document transitions, and their absence', async () => { + const server = await serve(); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const out = await mkdtemp(join(tmpdir(), 'devup-runtime-audit-vt-')); + try { + const planPath = join(out, 'plan.json'); + const check = { kind: 'view-transition', label: 'next', subject: { testId: 'go' } }; + await writeFile(planPath, JSON.stringify({ + schemaVersion: 1, + kind: 'devup-runtime-audit-plan', + planId: 'browser-vt', + plan: { + baseUrl, + allowedOrigins: [baseUrl], + viewports: [{ width: 1200, height: 800, deviceScaleFactor: 1 }], + screens: [ + { id: 'same', routePath: '/transition.html', checks: [{ kind: 'no-horizontal-overflow', label: 'page' }, check] }, + { id: 'cross', routePath: '/crossdoc.html', checks: [check] }, + { id: 'plain', routePath: '/plain.html', checks: [check] }, + ], + locale: null, + timezone: null, + readiness: null, + stepTimeoutMs: null, + }, + })); + const report = await runPlan({ plan: planPath, out, maxMinutes: 5 }); + const [same, cross, plain] = report.runs[0].screens; + assert.deepEqual(same.checks.map((item) => [item.index, item.status]), [[0, 'pass'], [1, 'pass']], JSON.stringify(same, null, 2)); + assert.equal(cross.checks[0].status, 'pass', JSON.stringify(cross, null, 2)); + assert.equal(plain.checks[0].status, 'fail', JSON.stringify(plain, null, 2)); + } finally { + server.close(); + await rm(out, { recursive: true, force: true }); + } +}); + +test('page health, bounds, clipped text, interaction states and captures in a real browser', async () => { + const server = await serve(); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const out = await mkdtemp(join(tmpdir(), 'devup-runtime-audit-kinds-')); + try { + const planPath = join(out, 'plan.json'); + const node = (id) => ({ css: `[data-figma-node-id="${id}"]` }); + await writeFile(planPath, JSON.stringify({ + schemaVersion: 1, + kind: 'devup-runtime-audit-plan', + planId: 'browser-kinds', + plan: { + baseUrl, + allowedOrigins: [baseUrl], + viewports: [{ width: 1200, height: 800, deviceScaleFactor: 1 }, { width: 800, height: 800, deviceScaleFactor: 1 }], + screens: [ + { id: 'layout', routePath: '/layout.html', checks: [ + { kind: 'page-health', label: 'health' }, + { kind: 'bounds', label: 'card 1', subject: node('1:3'), container: node('1:2'), expected: { x: 20, y: 20, width: 160, height: 100 } }, + { kind: 'bounds', label: 'card 2', subject: node('1:4'), container: node('1:2'), expected: { x: 200, y: 20, width: 160, height: 100 } }, + { kind: 'no-clipped-text', label: 'text', subject: node('1:6') }, + { kind: 'interaction', label: 'cta hover', state: 'hover', subject: { role: 'button', name: 'Send' } }, + { kind: 'interaction', label: 'cta focus', state: 'focus', subject: { role: 'button', name: 'Send' } }, + { kind: 'interaction', label: 'flat hover', state: 'hover', subject: { role: 'link', name: 'Plain link' } }, + { kind: 'capture', label: 'icon', subject: node('1:5'), widths: [1200] }, + { kind: 'interaction', label: 'sweep', state: 'hover' }, + ] }, + { id: 'broken', routePath: '/broken.html', checks: [{ kind: 'page-health', label: 'health' }] }, + ], + locale: null, + timezone: null, + readiness: null, + stepTimeoutMs: null, + }, + })); + const report = await runPlan({ plan: planPath, out, maxMinutes: 5 }); + const [wide, narrow] = report.runs; + const statuses = (screen) => Object.fromEntries(screen.checks.map((item) => [item.label, item.status])); + assert.deepEqual(statuses(wide.screens[0]), { + health: 'pass', 'card 1': 'pass', 'card 2': 'fail', text: 'fail', + 'cta hover': 'pass', 'cta focus': 'pass', 'flat hover': 'fail', icon: 'pass', sweep: 'fail', + }, JSON.stringify(wide.screens[0].checks, null, 2)); + const card = wide.screens[0].checks.find((item) => item.label === 'card 2'); + assert.match(card.reason, /^position: /); + const sweep = wide.screens[0].checks.find((item) => item.label === 'sweep'); + assert.match(sweep.reason, /nothing on 1 of 2: "Plain link"/); + const icon = wide.screens[0].checks.find((item) => item.label === 'icon'); + assert.ok(icon.screenshot.path.endsWith('-c7.png')); + assert.ok(!narrow.screens[0].checks.some((item) => item.label === 'icon'), 'a capture runs only at its widths'); + assert.equal(statuses(wide.screens[1]).health, 'fail'); + assert.match(wide.screens[1].checks[0].reason, /couldn't load/); + } finally { + server.close(); + await rm(out, { recursive: true, force: true }); + } +}); + +test('a page merged from two width frames is found by its per-width node ids', async () => { + const server = await serve(); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const out = await mkdtemp(join(tmpdir(), 'devup-runtime-audit-merged-')); + try { + const planPath = join(out, 'plan.json'); + const at = (width, id) => ({ css: `[data-figma-node-id="${id}"], [data-figma-node-ids~="${width}:${id}"]` }); + await writeFile(planPath, JSON.stringify({ + schemaVersion: 1, + kind: 'devup-runtime-audit-plan', + planId: 'browser-merged', + plan: { + baseUrl, + allowedOrigins: [baseUrl], + viewports: [{ width: 1440, height: 800, deviceScaleFactor: 1 }, { width: 390, height: 800, deviceScaleFactor: 1 }], + screens: [{ id: 'merged', routePath: '/merged.html', checks: [ + { kind: 'bounds', label: 'hero 1440', subject: at(1440, 'd:2'), container: at(1440, 'd:1'), expected: { x: 0, y: 0, width: 1440, height: 400 }, widths: [1440] }, + { kind: 'bounds', label: 'hero 390', subject: at(390, 'm:2'), container: at(390, 'm:1'), expected: { x: 0, y: 0, width: 390, height: 300 }, widths: [390] }, + { kind: 'visible', label: 'promo 1440', subject: at(1440, 'd:3'), widths: [1440] }, + { kind: 'hidden', label: 'promo 390', subject: { css: '[data-figma-node-id="d:3"]' }, widths: [390] }, + ] }], + locale: null, + timezone: null, + readiness: null, + stepTimeoutMs: null, + }, + })); + const report = await runPlan({ plan: planPath, out, maxMinutes: 5 }); + const results = report.runs.flatMap((run) => run.screens[0].checks.map((item) => [item.label, item.status, item.reason])); + assert.deepEqual(results.map(([label, status]) => [label, status]), [ + ['hero 1440', 'pass'], ['promo 1440', 'pass'], ['hero 390', 'pass'], ['promo 390', 'pass'], + ], JSON.stringify(results)); + assert.ok(!results.some(([, , reason]) => /matched no element/.test(reason)), 'no missing nodes'); + assert.ok(report.runs[0].screens[0].links.includes('/plain.html'), JSON.stringify(report.runs[0].screens[0].links)); + } finally { + server.close(); + await rm(out, { recursive: true, force: true }); + } +}); diff --git a/harness/runtime-audit/test/checks.test.mjs b/harness/runtime-audit/test/checks.test.mjs index d03396cc..fcbf872d 100644 --- a/harness/runtime-audit/test/checks.test.mjs +++ b/harness/runtime-audit/test/checks.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { evaluateCheck, evaluateDimensions, summarize } from '../checks.mjs'; +import { + GEOMETRY_ROUNDING_PX, STATE_PROPERTIES, evaluateBounds, evaluateHidden, evaluateCheck, evaluateClippedText, evaluateDimensions, evaluateInteraction, + evaluatePageHealth, evaluateViewTransition, summarize, +} from '../checks.mjs'; import { validateDimensionsPlan } from '../run.mjs'; const page = { viewportWidth: 360, scrollWidth: 360, maxRight: 340, overflowHidden: false }; @@ -78,3 +81,82 @@ test('dimension runner refuses inferred values, duplicate mappings and invalid t { ...check, figmaDimensions: [{ ...mapping, viewportWidth: 858 }] }, ]) assert.throws(() => validateDimensionsPlan(plan(entry))); }); + +test('a view-transition check passes only when following the link ran one', () => { + const observed = { subjectCount: 1, supported: true, navigated: true, calls: 1, from: '/a', to: '/b' }; + assert.equal(evaluateViewTransition(observed).status, 'pass'); + assert.equal(evaluateViewTransition({ ...observed, calls: 0 }).status, 'fail'); + assert.match(evaluateViewTransition({ ...observed, calls: 0 }).reason, /ran no view transition/); + assert.equal(evaluateViewTransition({ ...observed, navigated: false }).status, 'inconclusive'); + assert.equal(evaluateViewTransition({ ...observed, supported: false }).status, 'inconclusive'); + assert.equal(evaluateViewTransition({ subjectCount: 0 }).status, 'inconclusive'); +}); + +test('page health fails on an error page, a page error or a console error', () => { + assert.equal(evaluatePageHealth({ errorTexts: [], errors: [] }).status, 'pass'); + const broken = evaluatePageHealth({ errorTexts: ["This page couldn't load"], errors: [{ kind: 'page-error', message: 'boom\nstack' }] }); + assert.equal(broken.status, 'fail'); + assert.match(broken.reason, /couldn't load.*page-error: boom$/); +}); + +test('bounds judge position and size relative to the container', () => { + const container = box(100, 50, 600, 400); + const check = { kind: 'bounds', expected: { x: 20, y: 20, width: 160, height: 100 } }; + const at = (x, y, width, height) => ({ subjectCount: 1, containerCount: 1, subject: box(100 + x, 50 + y, width, height), container }); + assert.equal(evaluateBounds(check, at(20.4, 19.6, 160.4, 100)).status, 'pass', 'rounding is not a mismatch'); + const onePixel = evaluateBounds(check, at(21, 20, 160, 100)); + assert.equal(onePixel.status, 'fail', 'layout is exact: 1px off at a drawn width fails'); + assert.match(onePixel.reason, /^position: x off by 1px/); + assert.equal(evaluateBounds({ ...check, tolerancePx: 50 }, at(21, 20, 160, 100)).status, 'fail', 'no tolerance can be configured'); + assert.equal(GEOMETRY_ROUNDING_PX, 0.5); + const moved = evaluateBounds(check, at(20, 80, 160, 100)); + assert.equal(moved.status, 'fail'); + assert.match(moved.reason, /^position: y off by 60px/); + assert.match(evaluateBounds(check, at(20, 20, 300, 100)).reason, /^size: width off by 140px/); + assert.equal(evaluateBounds(check, { ...at(0, 0, 1, 1), containerCount: 0 }).status, 'inconclusive'); +}); + +test('clipped text fails; nothing clipped passes', () => { + assert.equal(evaluateClippedText({ clipped: [] }).status, 'pass'); + assert.equal(evaluateClippedText({ clipped: [{ text: 'Long', tag: 'p' }] }).status, 'fail'); +}); + +test('an interaction state passes only when something visible changes', () => { + const before = Object.fromEntries(STATE_PROPERTIES.map((property) => [property, 'a'])); + assert.equal(evaluateInteraction({ state: 'hover' }, { subjectCount: 1, before, after: { ...before, color: 'b' } }).status, 'pass'); + assert.equal(evaluateInteraction({ state: 'hover' }, { subjectCount: 1, before, after: before }).status, 'fail'); +}); + +test('an interaction sweep fails on the links and buttons that change nothing', () => { + const before = Object.fromEntries(STATE_PROPERTIES.map((property) => [property, 'a'])); + const items = [ + { tag: 'a', name: 'Home', before, after: { ...before, color: 'b' } }, + { tag: 'button', name: 'Send', before, after: before }, + ]; + const result = evaluateInteraction({ state: 'hover' }, { items }); + assert.equal(result.status, 'fail'); + assert.match(result.reason, /1 of 2: