diff --git a/docs/blog/2026-02-01-figma-to-code-one-command.md b/docs/blog/2026-02-01-figma-to-code-one-command.md index dd91d128..793d2fa0 100644 --- a/docs/blog/2026-02-01-figma-to-code-one-command.md +++ b/docs/blog/2026-02-01-figma-to-code-one-command.md @@ -22,7 +22,7 @@ $ ralph-starter run --from figma \ --project "https://figma.com/file/ABC123/Dashboard" \ --figma-mode components \ --figma-framework react \ - --loops 5 --test --commit + --max-iterations 5 --test --commit πŸ”„ Loop 1/5 β†’ Fetching from Figma API... 12 frames, 34 components found @@ -83,6 +83,10 @@ Setup is dead simple, just a personal access token from Figma: ralph-starter config set figma.token figd_xxxxx ``` +:::tip Figma Plan Matters +The free/starter plan limits you to **6 API requests per month** -- that is barely one fetch. For real development, you need a **Professional plan with a Dev seat** ($12/month), which gives you 10+ requests per minute. Responses are cached locally so repeated runs are free. +::: + The reason this works at all is [the loop](/blog/my-first-ralph-loop). The agent does not just generate code and stop. It generates, runs tests, sees what broke, fixes it, runs again. By loop 3 or 4 you have components that actually render and pass lint. Same [Ralph Wiggum technique](/blog/ralph-wiggum-technique) I use for everything else -- just pointed at a design file instead of a GitHub issue. I did not even plan it this way. It just... worked. Want to try it with your own Figma file? @@ -90,7 +94,17 @@ Want to try it with your own Figma file? ```bash npx ralph-starter init ralph-starter config set figma.token figd_your_token_here -ralph-starter run --from figma --project "your-figma-url" --figma-mode components --figma-framework react --loops 5 +ralph-starter run --from figma --project "your-figma-url" --figma-mode components --figma-framework react --max-iterations 5 +``` + +You can also pick your model with `--model`. Sonnet is fast and cheap for UI work, Opus is better for complex state logic: + +```bash +# Fast + cheap (recommended for most Figma workflows) +ralph-starter run --from figma --project "your-figma-url" --figma-mode components --model claude-sonnet-4-5-20250929 --max-iterations 5 + +# Maximum quality +ralph-starter run --from figma --project "your-figma-url" --figma-mode components --model claude-opus-4-6 --max-iterations 3 ``` ## References diff --git a/docs/docs/sources/figma.md b/docs/docs/sources/figma.md index a27f250e..972b0d31 100644 --- a/docs/docs/sources/figma.md +++ b/docs/docs/sources/figma.md @@ -199,6 +199,7 @@ Create a custom mapping file to control how Figma content maps to your component | `--figma-target` | Target directory (content mode) | Path (e.g., `src/pages`) | | `--figma-preview` | Preview without applying (content mode) | Flag | | `--figma-mapping` | Custom mapping file (content mode) | File path (e.g., `mapping.json`) | +| `--model` | AI model for the coding agent | Model ID (e.g., `claude-sonnet-4-5-20250929`) | ## Figma URL Formats @@ -247,6 +248,30 @@ ralph-starter integrations fetch figma "ABC123" --figma-mode assets # Run the generated curl commands to download ``` +### Choose Your Model + +Use `--model` to pick which AI model implements the design. Sonnet is fast and cost-effective for most UI work; Opus produces more nuanced implementations for complex layouts: + +```bash +# Fast iteration with Sonnet (recommended for most Figma workflows) +ralph-starter run --from figma \ + --project "https://figma.com/file/ABC123/Dashboard" \ + --figma-mode components \ + --model claude-sonnet-4-5-20250929 \ + --max-iterations 5 + +# Maximum quality with Opus +ralph-starter run --from figma \ + --project "https://figma.com/file/ABC123/Dashboard" \ + --figma-mode components \ + --model claude-opus-4-6 \ + --max-iterations 3 +``` + +:::tip Model Selection for Figma +**Sonnet** is the sweet spot for Figma-to-code. It handles component structure, layout, and styling accurately at ~5x lower cost and faster iteration speed. Use **Opus** when you need complex state logic or intricate responsive behavior alongside the UI. +::: + ## Test Connection Verify your authentication: @@ -255,8 +280,72 @@ Verify your authentication: ralph-starter integrations test figma ``` +## Rate Limits & Caching + +### Figma API Rate Limits + +Figma enforces rate limits based on your **plan tier** and **seat type**. This matters because it determines how many API requests you can make per minute (or per month). + +| Seat Type | Starter | Professional | Enterprise | +|-----------|---------|-------------|------------| +| Collab/Viewer (low) | 6/month | 5/min | 10/min | +| Dev/Full (high) | 10/min | 15-50/min | 20-100/min | + +:::warning Free & Starter Plans +On the **Starter plan with a Collab/Viewer seat** (`limit-type=low`), you get only **6 requests per month**. Each `ralph-starter run --from figma` uses 2-4 API calls, so you can exhaust your budget in 1-2 runs. Upgrade to a **Professional plan with a Dev seat** ($12/month) for 10+ requests per minute. +::: + +### Community Files + +When you access a **community file** (duplicated from the Figma Community), the **file owner's plan** determines your rate limits -- not your own plan. If the original author is on a free/starter plan, you'll be limited to 6 requests per month regardless of your plan. + +**Fix:** Duplicate the file to your own workspace. This makes you the owner and applies your plan's limits. + +### Response Caching + +ralph-starter automatically caches Figma API responses in `~/.ralph/figma-cache/` with a 1-hour TTL. This means: + +- **First run** fetches from the API and populates the cache +- **Subsequent runs** (within 1 hour) use the cache with zero API calls +- **Rate limited (429)?** Falls back to stale cache if available + +This is especially useful for iterative development -- fetch once, then run the coding loop as many times as you want without touching the API. + +To clear the cache and force a fresh fetch: + +```bash +rm -rf ~/.ralph/figma-cache/ +``` + +### Debugging API Issues + +Use the `RALPH_DEBUG` environment variable to see every API request, response status, and rate limit headers: + +```bash +RALPH_DEBUG=1 ralph-starter run --from figma --project "your-figma-url" +``` + +This shows: +- Each API endpoint being called +- HTTP status codes and retry-after values +- Plan tier (`x-figma-plan-tier`) and limit type (`x-figma-rate-limit-type`) +- Cache hits and stale cache fallbacks + ## Troubleshooting +### "Figma API blocked for ~N day(s)" + +This means CloudFront (Figma's CDN) has blocked your IP after too many rate-limited requests. The `retry-after` header shows days, not minutes. + +**Solutions (pick one):** +1. **Upgrade your Figma plan** to Professional with a Dev seat ($12/month) -- gives you 10+ req/min instead of 6/month +2. **Use a VPN** to get a fresh IP, fetch once to populate the cache, then disconnect +3. **Wait** for the block to expire (shown in the error message) + +### "Figma API rate limit hit" + +A transient rate limit (not a CDN block). ralph-starter will automatically retry once after respecting the `retry-after` header. If it fails again, wait 1-2 minutes and try again. + ### "Invalid Figma token" Your token may have expired or been revoked. Create a new token in Figma settings. @@ -278,3 +367,4 @@ Assets are detected by name patterns. Rename your icon frames to include "icon", - **Variables API** requires Figma Enterprise plan (falls back to styles) - **Image export URLs** expire after 30 days - **Large files** may be slow; use `--figma-nodes` to target specific frames +- **Starter plan (Collab seat)** limited to 6 API requests/month -- use caching or upgrade to Professional diff --git a/package.json b/package.json index 33c5221d..fcb5bde2 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,9 @@ "yaml": "^2.7.0", "zod": "^4.3.6" }, + "optionalDependencies": { + "sharp": "^0.33.0" + }, "devDependencies": { "@biomejs/biome": "^2.3.13", "@commitlint/cli": "^20.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 937e0888..2e6aa44d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,10 @@ importers: vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2) + optionalDependencies: + sharp: + specifier: ^0.33.0 + version: 0.33.5 packages: @@ -250,6 +254,9 @@ packages: resolution: {integrity: sha512-aO5l99BQJ0X34ft8b0h7QFkQlqxC6e7ZPVmBKz13xM9O8obDaM1Cld4sQlJDXXU/VFuUzQ30mVtHjVz74TuStw==} engines: {node: '>=v18'} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -412,6 +419,111 @@ packages: peerDependencies: hono: ^4 + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/ansi@2.0.3': resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -1030,6 +1142,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -1152,6 +1271,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -1504,6 +1627,9 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -2030,6 +2156,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2067,6 +2197,9 @@ packages: simple-git@3.32.2: resolution: {integrity: sha512-n/jhNmvYh8dwyfR6idSfpXrFazuyd57jwNMzgjGnKZV/1lTh0HKvPq20v4AQ62rP+l19bWjjXPTCdGHMt0AdrQ==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -2569,6 +2702,11 @@ snapshots: conventional-commits-parser: 6.2.1 picocolors: 1.1.1 + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -2651,6 +2789,81 @@ snapshots: dependencies: hono: 4.11.7 + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + '@inquirer/ansi@2.0.3': {} '@inquirer/checkbox@5.1.0(@types/node@25.3.0)': @@ -3204,6 +3417,18 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + colorette@2.0.20: {} commander@14.0.3: {} @@ -3325,6 +3550,9 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@2.1.2: + optional: true + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -3731,6 +3959,9 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.4: + optional: true + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -4246,6 +4477,33 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4294,6 +4552,11 @@ snapshots: transitivePeerDependencies: - supports-color + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + optional: true + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 diff --git a/src/cli.ts b/src/cli.ts index cdeb0bc9..22692751 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -63,6 +63,7 @@ program .option('--prd ', 'Read tasks from a PRD markdown file') .option('--max-iterations ', 'Maximum loop iterations (auto-calculated if not specified)') .option('--agent ', 'Specify agent (claude-code, cursor, codex, opencode, openclaw)') + .option('--model ', 'Model to use (e.g., claude-sonnet-4-5-20250929, claude-opus-4-6)') .option('--from ', 'Fetch spec from source (file, url, github, todoist, linear, notion)') .option('--project ', 'Project/repo name for --from integrations') .option('--label ', 'Label filter for --from integrations') @@ -93,6 +94,7 @@ program 'Max input tokens per iteration for smart context trimming (0 = unlimited)' ) .option('--max-cost ', 'Maximum cost in USD before stopping (0 = unlimited)', parseFloat) + .option('--plan ', 'API plan for budget tracking (max, pro, team, api)') // Figma integration options .option('--figma-mode ', 'Figma mode: spec, tokens, components, assets, content') .option( @@ -105,6 +107,10 @@ program .option('--figma-target ', 'Target directory for content mode') .option('--figma-preview', 'Show content changes without applying (content mode)') .option('--figma-mapping ', 'Custom content mapping file (content mode)') + .option( + '--design-image ', + 'Design reference image (screenshot of the target design for pixel-perfect matching)' + ) .action(runCommand); // ralph-starter fix - Fix build errors and code quality issues @@ -117,6 +123,10 @@ program .option('--max-iterations ', 'Max fix iterations (default: 3)') .option('--output-dir ', 'Project directory (default: cwd)') .option('--design', 'Visual-first design fix: screenshot, analyze, plan, and fix design issues') + .option( + '--design-image ', + 'Design reference image to match (screenshot of the target design)' + ) .action(fixCommand); // ralph-starter init - Initialize Ralph in a project diff --git a/src/commands/fix.ts b/src/commands/fix.ts index 65bb0b00..35444aba 100644 --- a/src/commands/fix.ts +++ b/src/commands/fix.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import chalk from 'chalk'; import ora from 'ora'; import { type Agent, detectAvailableAgents, detectBestAgent } from '../loop/agents.js'; @@ -20,6 +20,7 @@ interface FixOptions { outputDir?: string; scan?: boolean; design?: boolean; + designImage?: string; } /** @@ -188,6 +189,24 @@ export async function fixCommand(customTask: string | undefined, options: FixOpt fixTask = `${fixTask}\n\n## Original Design Specification\n\nIMPORTANT: Use the following specification as the source of truth for what the design should look like. Match the described colors, spacing, layout, and styling exactly.\n${specContext}`; } + // Copy design reference image if provided (must happen before prompt construction) + let designImagePath: string | undefined; + if (options.designImage) { + const srcPath = resolve(options.designImage); + if (!existsSync(srcPath)) { + console.log(chalk.red(`Design image not found: ${srcPath}`)); + return; + } + const destSpecsDir = join(cwd, 'specs'); + if (!existsSync(destSpecsDir)) { + mkdirSync(destSpecsDir, { recursive: true }); + } + const destPath = join(destSpecsDir, 'design-reference.png'); + copyFileSync(srcPath, destPath); + designImagePath = 'specs/design-reference.png'; + console.log(chalk.cyan(`Design reference image: ${designImagePath}`)); + } + // For design/visual tasks, add instructions to visually verify with screenshots const DESIGN_KEYWORDS = [ 'css', @@ -221,13 +240,19 @@ export async function fixCommand(customTask: string | undefined, options: FixOpt // --design flag: structured visual-first fix flow if (options.design) { fixTask = `You are fixing design and visual issues in this project. Ignore IMPLEMENTATION_PLAN.md β€” this is a visual fix pass, not a feature build. - -IMPORTANT: Your VERY FIRST action must be to start the dev server and take screenshots. Do NOT read files or explore the codebase first β€” start visually. +${ + designImagePath + ? ` +CRITICAL β€” DESIGN REFERENCE IMAGE: A screenshot of the EXACT target design is at \`${designImagePath}\`. Your VERY FIRST action must be to use the Read tool to open this image and study it carefully. This is what the final result MUST look like. Every comparison you make should be against THIS image, not just the text spec. +` + : '' +} +IMPORTANT: Your ${designImagePath ? 'SECOND' : 'VERY FIRST'} action must be to start the dev server and take screenshots. Do NOT read code files or explore the codebase first β€” start visually. ## Phase 1: Visual Audit (DO THIS FIRST) -1. Start the dev server (e.g. \`npm run dev\` or \`npx vite\`) β€” this OVERRIDES the "no dev server" rule +${designImagePath ? `0. Use the Read tool to open \`${designImagePath}\` β€” study it carefully, this is your target\n` : ''}1. Start the dev server (e.g. \`npm run dev\` or \`npx vite\`) β€” this OVERRIDES the "no dev server" rule 2. Take full-page screenshots at 3 viewports: desktop (1440px), tablet (768px), mobile (375px) -3. Analyze each screenshot carefully against the spec below +3. Analyze each screenshot carefully against the ${designImagePath ? `design reference image (\`${designImagePath}\`)` : 'spec below'} ## Phase 2: Issue Identification (be SPECIFIC, not generic) Look at the screenshots and identify CONCRETE issues you can actually see. Do NOT list generic improvements β€” only list problems visible in the screenshots. @@ -273,10 +298,35 @@ This is a visual/design task. After making your CSS and styling changes, you MUS 5. CRITICAL: Stop the dev server (kill the process) when done β€” do NOT leave it running`; } + // Detect source type from project artifacts so skill filtering works correctly. + // e.g., if we see public/images/screenshots/ or figma specs, we know this is a + // figma project and can filter out android/ios/game skills. + let sourceType: string | undefined; + if (existsSync(join(cwd, 'public', 'images', 'screenshots'))) { + sourceType = 'figma'; + } else if (existsSync(specsDir)) { + try { + const specFiles = readdirSync(specsDir).filter((f) => f.endsWith('.md')); + for (const file of specFiles) { + const head = readFileSync(join(specsDir, file), 'utf-8').slice(0, 200); + if (head.includes('Design Specification:') || head.includes('figma')) { + sourceType = 'figma'; + break; + } + } + } catch { + // Unreadable β€” skip detection + } + } + // Install relevant skills so the agent has design/quality context // Use the user's custom task (not the full generated prompt) to avoid keyword-spam // that triggers excessive skill searches from the design prompt boilerplate - await autoInstallSkillsFromTask(customTask || (options.design ? 'design fix' : 'fix'), cwd); + await autoInstallSkillsFromTask( + customTask || (options.design ? 'design fix' : 'fix'), + cwd, + sourceType + ); const defaultIter = options.design ? 7 : isDesignTask ? 4 : 3; const maxIter = options.maxIterations ? Number.parseInt(options.maxIterations, 10) : defaultIter; @@ -295,6 +345,8 @@ This is a visual/design task. After making your CSS and styling changes, you MUS maxSkills: options.design ? 4 : undefined, skipPlanInstructions: options.design, fixMode: options.design ? 'design' : customTask ? 'custom' : 'scan', + designImagePath, + sourceType, // Design mode: require explicit DESIGN_VERIFIED token after visual verification ...(options.design && { completionPromise: 'DESIGN_VERIFIED', diff --git a/src/commands/run.ts b/src/commands/run.ts index 4bd58f64..54d69d36 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import chalk from 'chalk'; @@ -6,6 +6,7 @@ import { execa } from 'execa'; import inquirer from 'inquirer'; import ora from 'ora'; import { type IssueRef, initGitRepo, isGitRepo } from '../automation/git.js'; +import type { SectionSummary } from '../integrations/figma/parsers/plan-generator.js'; import { type Agent, detectAvailableAgents, @@ -22,6 +23,12 @@ import { getSourceDefaults } from '../sources/config.js'; import { fetchFromSource } from '../sources/index.js'; import type { SourceOptions } from '../sources/types.js'; import { detectPackageManager, formatRunCommand, getRunCommand } from '../utils/package-manager.js'; +import { + isValidFigmaCdnUrl, + isValidPngBuffer, + sanitizeAssetFilename, + sanitizeSvgContent, +} from '../utils/sanitize.js'; import { showWelcome } from '../wizard/ui.js'; /** Default fallback repo for GitHub issues when no project is specified */ @@ -75,6 +82,54 @@ function detectRunCommand( return null; } +/** + * Detect the project's tech stack from package.json dependencies. + * Returns a concise description like "React + TypeScript + Tailwind CSS v4". + */ +function detectProjectStack(cwd: string): string | null { + const packageJsonPath = join(cwd, 'package.json'); + if (!existsSync(packageJsonPath)) return null; + + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + } as Record; + + const parts: string[] = []; + + // Framework detection (pick one) + if (allDeps.next) parts.push('Next.js'); + else if (allDeps.nuxt) parts.push('Nuxt'); + else if (allDeps['@remix-run/react'] || allDeps.remix) parts.push('Remix'); + else if (allDeps.astro) parts.push('Astro'); + else if (allDeps.svelte || allDeps['@sveltejs/kit']) parts.push('Svelte'); + else if (allDeps.vue) parts.push('Vue'); + else if (allDeps.react) parts.push('React'); + + // Language + if (allDeps.typescript || existsSync(join(cwd, 'tsconfig.json'))) { + parts.push('TypeScript'); + } + + // CSS framework + if (allDeps.tailwindcss) { + const version = allDeps.tailwindcss?.replace(/[\^~>=<]/g, ''); + const major = version ? Number.parseInt(version.split('.')[0], 10) : null; + parts.push(major && major >= 4 ? 'Tailwind CSS v4' : 'Tailwind CSS'); + } else if (allDeps['styled-components']) { + parts.push('styled-components'); + } else if (allDeps['@emotion/react']) { + parts.push('Emotion'); + } + + return parts.length > 0 ? parts.join(' + ') : null; + } catch { + return null; + } +} + /** * Extract tasks from spec content and format as implementation plan * Handles "### Task N:" headers with subtasks underneath @@ -203,6 +258,7 @@ export interface RunCommandOptions { prd?: string; maxIterations?: number; agent?: string; + model?: string; // Source options from?: string; project?: string; @@ -223,6 +279,7 @@ export interface RunCommandOptions { contextBudget?: number; validationWarmup?: number; maxCost?: number; + plan?: string; // Figma options figmaMode?: 'spec' | 'tokens' | 'components' | 'assets' | 'content'; figmaFramework?: 'react' | 'vue' | 'svelte' | 'astro' | 'nextjs' | 'nuxt' | 'html'; @@ -232,6 +289,8 @@ export interface RunCommandOptions { figmaTarget?: string; figmaPreview?: boolean; figmaMapping?: string; + // Design reference + designImage?: string; } export async function runCommand( @@ -245,9 +304,7 @@ export async function runCommand( if (options.outputDir) { const expandedPath = options.outputDir.replace(/^~/, homedir()); cwd = resolve(expandedPath); - if (!existsSync(cwd)) { - mkdirSync(cwd, { recursive: true }); - } + mkdirSync(cwd, { recursive: true }); } showWelcome(); @@ -281,6 +338,12 @@ export async function runCommand( let sourceSpec: string | null = null; let sourceTitle: string | undefined; let sourceIssueRef: IssueRef | undefined; + let figmaImagesDownloaded: boolean | undefined; + let figmaFontSubstitutions: Array<{ original: string; substitute: string }> | undefined; + let figmaSectionSummaries: SectionSummary[] | undefined; + let figmaHasDesignTokens: boolean | undefined; + let figmaIconFilenames: string[] | undefined; + let figmaFontNames: string[] | undefined; if (options.from) { spinner.start('Fetching spec from source...'); try { @@ -313,23 +376,289 @@ export async function runCommand( sourceSpec = result.content; sourceTitle = result.title; - // Auto-inject design tokens when fetching Figma spec (default mode) + // Auto-inject design tokens and content structure from pre-extracted metadata + // (no additional API calls β€” extracted during the spec fetch) if ( options.from.toLowerCase() === 'figma' && (!options.figmaMode || options.figmaMode === 'spec') ) { - try { - const tokensResult = await fetchFromSource(options.from, projectId, { - ...fetchOptions, - figmaMode: 'tokens', - figmaFormat: options.figmaFormat || 'css', - }); - if (tokensResult.content) { - sourceSpec = `## Design Tokens\n\n${tokensResult.content}\n\n---\n\n${sourceSpec}`; - console.log(chalk.dim(' Auto-injected design tokens into spec')); + const tokensContent = result.metadata?.tokensContent as string | undefined; + if (tokensContent) { + sourceSpec = `## Design Tokens\n\n${tokensContent}\n\n---\n\n${sourceSpec}`; + console.log(chalk.dim(' Auto-injected design tokens into spec')); + } + + const contentStructure = result.metadata?.contentStructure as string | undefined; + if (contentStructure) { + const contentSection = `## Content Structure\n\n${contentStructure}`; + sourceSpec = `${contentSection}\n\n---\n\n${sourceSpec}`; + console.log(chalk.dim(' Auto-injected content structure into spec')); + } + + // Font substitution warnings + const fontChecks = result.metadata?.fontChecks as + | Array<{ fontFamily: string; isGoogleFont: boolean; suggestedAlternative?: string }> + | undefined; + if (fontChecks) { + const nonGoogleFonts = fontChecks.filter((f) => !f.isGoogleFont); + if (nonGoogleFonts.length > 0) { + for (const font of nonGoogleFonts) { + if (font.suggestedAlternative) { + console.log( + chalk.yellow( + ` Font "${font.fontFamily}" \u2192 Google Fonts alternative "${font.suggestedAlternative}" \u2014 verify design fidelity` + ) + ); + } else { + console.log( + chalk.yellow( + ` Font "${font.fontFamily}" is not available on Google Fonts \u2014 agent will choose a similar font` + ) + ); + } + } + // Inject font substitution table into spec + const { buildFontSubstitutionMarkdown } = await import( + '../integrations/figma/parsers/font-checker.js' + ); + const fontSection = buildFontSubstitutionMarkdown(nonGoogleFonts); + if (fontSection) { + sourceSpec = `${fontSection}\n---\n\n${sourceSpec}`; + } + // Build substitutions array for loop options + figmaFontSubstitutions = nonGoogleFonts + .filter((f) => f.suggestedAlternative) + .map((f) => ({ + original: f.fontFamily, + substitute: f.suggestedAlternative!, + })); + } + } + + // Auto-download images from Figma + const imageFillUrls = result.metadata?.imageFillUrls as Record | undefined; + if (imageFillUrls && Object.keys(imageFillUrls).length > 0) { + try { + const imagesDir = join(cwd, 'public', 'images'); + mkdirSync(imagesDir, { recursive: true }); + + const downloads = Object.entries(imageFillUrls).filter( + ([, url]) => url != null && url !== '' + ); + if (downloads.length > 0) { + const BATCH_SIZE = 5; + let downloaded = 0; + for (let idx = 0; idx < downloads.length; idx += BATCH_SIZE) { + const batch = downloads.slice(idx, idx + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(async ([ref, url]) => { + if (!isValidFigmaCdnUrl(url)) return false; + const response = await fetch(url); + if (!response.ok) return false; + const buffer = Buffer.from(await response.arrayBuffer()); + if (!isValidPngBuffer(buffer)) return false; + writeFileSync(join(imagesDir, `${sanitizeAssetFilename(ref)}.png`), buffer); + return true; + }) + ); + downloaded += results.filter((r) => r.status === 'fulfilled' && r.value).length; + } + + if (downloaded > 0) { + console.log(chalk.dim(` Downloaded ${downloaded} image(s) to public/images/`)); + figmaImagesDownloaded = true; + } + } + } catch { + console.log(chalk.dim(' Image download skipped \u2014 will use placehold.co')); + } + } + + // Download frame screenshots for multimodal visual reference (with 30s timeout) + const frameScreenshots = result.metadata?.frameScreenshots as + | Record + | undefined; + if (frameScreenshots) { + const validScreenshots = Object.entries(frameScreenshots).filter( + ([, url]) => url != null && url !== '' + ); + if (validScreenshots.length > 0) { + try { + const screenshotsDir = join(cwd, 'public', 'images', 'screenshots'); + mkdirSync(screenshotsDir, { recursive: true }); + let ssDownloaded = 0; + const ssTimeout = AbortSignal.timeout(30_000); + const ssResults = await Promise.allSettled( + validScreenshots.map(async ([nodeId, url]) => { + if (!isValidFigmaCdnUrl(url!)) return false; + const response = await fetch(url!, { signal: ssTimeout }); + if (!response.ok) return false; + const buffer = Buffer.from(await response.arrayBuffer()); + if (!isValidPngBuffer(buffer)) return false; + const filename = `frame-${sanitizeAssetFilename(nodeId.replace(/:/g, '-'))}.png`; + writeFileSync(join(screenshotsDir, filename), buffer); + return true; + }) + ); + ssDownloaded = ssResults.filter((r) => r.status === 'fulfilled' && r.value).length; + if (ssDownloaded > 0) { + console.log( + chalk.dim( + ` Downloaded ${ssDownloaded} frame screenshot(s) to public/images/screenshots/` + ) + ); + } + } catch { + // Screenshot download failed β€” non-critical + } + } + } + + // Download icon SVGs from Figma + const iconSvgUrls = result.metadata?.iconSvgUrls as Record | undefined; + if (iconSvgUrls && Object.keys(iconSvgUrls).length > 0) { + try { + const iconNodes = result.metadata?.iconNodes as + | Array<{ nodeId: string; filename: string }> + | undefined; + if (iconNodes) { + const iconsDir = join(cwd, 'public', 'images', 'icons'); + mkdirSync(iconsDir, { recursive: true }); + let iconDownloaded = 0; + const iconTimeout = AbortSignal.timeout(30_000); + const iconResults = await Promise.allSettled( + iconNodes.map(async (icon) => { + const url = iconSvgUrls[icon.nodeId]; + if (!url || !isValidFigmaCdnUrl(url)) return false; + const response = await fetch(url, { signal: iconTimeout }); + if (!response.ok) return false; + const svg = sanitizeSvgContent(await response.text()); + writeFileSync(join(iconsDir, sanitizeAssetFilename(icon.filename)), svg); + return true; + }) + ); + iconDownloaded = iconResults.filter( + (r) => r.status === 'fulfilled' && r.value + ).length; + if (iconDownloaded > 0) { + console.log( + chalk.dim(` Downloaded ${iconDownloaded} icon(s) as SVG to public/images/icons/`) + ); + } + } + } catch { + // Icon download failed β€” non-critical + } + } + + // Download composite visual group renders (overlapping layers combined into one image) + const compositeRenderUrls = result.metadata?.compositeRenderUrls as + | Record + | undefined; + const compositeNodes = result.metadata?.compositeNodes as + | Array<{ nodeId: string; name: string }> + | undefined; + if (compositeRenderUrls && compositeNodes) { + try { + const imagesDir = join(cwd, 'public', 'images'); + mkdirSync(imagesDir, { recursive: true }); + let compositeDownloaded = 0; + const compTimeout = AbortSignal.timeout(30_000); + const compResults = await Promise.allSettled( + compositeNodes.map(async (comp) => { + const url = compositeRenderUrls[comp.nodeId]; + if (!url || !isValidFigmaCdnUrl(url)) return false; + const response = await fetch(url, { signal: compTimeout }); + if (!response.ok) return false; + const buffer = Buffer.from(await response.arrayBuffer()); + if (!isValidPngBuffer(buffer)) return false; + const safeName = sanitizeAssetFilename(comp.name); + writeFileSync(join(imagesDir, `composite-${safeName}.png`), buffer); + return true; + }) + ); + compositeDownloaded = compResults.filter( + (r) => r.status === 'fulfilled' && r.value + ).length; + if (compositeDownloaded > 0) { + console.log( + chalk.dim( + ` Downloaded ${compositeDownloaded} composite background(s) to public/images/` + ) + ); + } + } catch { + // Composite download failed β€” non-critical, falls back to individual layers + } + } + // Optimize downloaded images (compress/resize to max 1MB) + if (figmaImagesDownloaded) { + try { + const { readdirSync } = await import('node:fs'); + const { optimizeImage } = await import('../utils/image-optimizer.js'); + const allImageFiles: string[] = []; + + // Collect image files from public/images/ (not subdirectories) + const imagesDir = join(cwd, 'public', 'images'); + if (existsSync(imagesDir)) { + for (const file of readdirSync(imagesDir)) { + if (file.endsWith('.png')) { + allImageFiles.push(join(imagesDir, file)); + } + } + } + // Collect screenshot files + const screenshotsDir = join(cwd, 'public', 'images', 'screenshots'); + if (existsSync(screenshotsDir)) { + for (const file of readdirSync(screenshotsDir)) { + if (file.endsWith('.png')) { + allImageFiles.push(join(screenshotsDir, file)); + } + } + } + + if (allImageFiles.length > 0) { + let totalSaved = 0; + let optimizedCount = 0; + for (const filePath of allImageFiles) { + const result = await optimizeImage(filePath); + if (result.optimized) { + totalSaved += result.originalSize - result.newSize; + optimizedCount++; + } + } + if (totalSaved > 0) { + console.log( + chalk.dim( + ` Optimized ${optimizedCount} image(s): saved ${(totalSaved / 1024 / 1024).toFixed(1)}MB` + ) + ); + } + } + } catch { + // Image optimization failed β€” non-critical, keep originals } - } catch { - // Tokens fetch failed β€” proceed with spec only + } + + // Capture metadata for plan generation (used after try/catch scope) + figmaSectionSummaries = result.metadata?.sectionSummaries as SectionSummary[] | undefined; + figmaHasDesignTokens = !!result.metadata?.tokensContent; + const iconNodesForPlan = result.metadata?.iconNodes as + | Array<{ nodeId: string; filename: string }> + | undefined; + figmaIconFilenames = iconNodesForPlan?.map((n) => n.filename); + // Collect unique font names from font checks + const allFontChecks = result.metadata?.fontChecks as + | Array<{ fontFamily: string; isGoogleFont: boolean; suggestedAlternative?: string }> + | undefined; + if (allFontChecks) { + figmaFontNames = [ + ...new Set( + allFontChecks.map((f) => + f.isGoogleFont ? f.fontFamily : (f.suggestedAlternative ?? f.fontFamily) + ) + ), + ]; } } @@ -349,9 +678,7 @@ export async function runCommand( // Write to specs directory const specsDir = join(cwd, 'specs'); - if (!existsSync(specsDir)) { - mkdirSync(specsDir, { recursive: true }); - } + mkdirSync(specsDir, { recursive: true }); const specFilename = result.title ? `${result.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.md` @@ -361,7 +688,8 @@ export async function runCommand( console.log(chalk.dim(` Written to: ${specPath}`)); // Cap spec size for agent context (full spec remains on disk in specs/) - const MAX_SPEC_SIZE = 15_000; + // Figma specs include richer visual data (fills, gradients, strokes) β€” allow more room + const MAX_SPEC_SIZE = options.from?.toLowerCase() === 'figma' ? 25_000 : 15_000; if (sourceSpec.length > MAX_SPEC_SIZE) { console.log( chalk.yellow( @@ -378,7 +706,7 @@ export async function runCommand( // Prompt for project location when fetching from integration sources // Skip if --auto or --output-dir was provided - const integrationSources = ['github', 'linear', 'notion', 'todoist']; + const integrationSources = ['github', 'linear', 'notion', 'figma']; const isIntegrationSource = integrationSources.includes(options.from?.toLowerCase() || ''); if (isIntegrationSource && !options.auto && !options.outputDir) { @@ -437,9 +765,7 @@ export async function runCommand( ]); const newCwd = join(process.cwd(), folderName); - if (!existsSync(newCwd)) { - mkdirSync(newCwd, { recursive: true }); - } + mkdirSync(newCwd, { recursive: true }); cwd = newCwd; console.log(chalk.dim(` Created: ${cwd}`)); } else if (projectLocation === 'custom') { @@ -454,10 +780,7 @@ export async function runCommand( // Expand ~ to home directory const expandedPath = customPath.replace(/^~/, homedir()); cwd = resolve(expandedPath); - - if (!existsSync(cwd)) { - mkdirSync(cwd, { recursive: true }); - } + mkdirSync(cwd, { recursive: true }); console.log(chalk.dim(` Using: ${cwd}`)); } // 'current' - no change needed @@ -515,18 +838,83 @@ export async function runCommand( // Get task if not provided let finalTask = task; - // If we fetched from a source, use that as the task - if (sourceSpec && !finalTask) { + // If we fetched from a source, combine spec with task + if (sourceSpec) { + // Detect project tech stack for richer task context + const projectStack = detectProjectStack(cwd); + // Extract tasks from spec and create implementation plan - const extractedPlan = extractTasksFromSpec(sourceSpec); + // Figma sources get a detailed plan with concrete design values per section; + // other sources use generic task extraction from spec headers/checkboxes. + let extractedPlan: string | null = null; + + if ( + options.from?.toLowerCase() === 'figma' && + (!options.figmaMode || options.figmaMode === 'spec') && + figmaSectionSummaries && + figmaSectionSummaries.length > 0 + ) { + const { extractFigmaPlan } = await import('../integrations/figma/parsers/plan-generator.js'); + extractedPlan = extractFigmaPlan(figmaSectionSummaries, { + fileName: sourceTitle || 'Figma Design', + projectStack, + imagesDownloaded: figmaImagesDownloaded, + hasDesignTokens: figmaHasDesignTokens, + iconFilenames: figmaIconFilenames, + fontNames: figmaFontNames, + }); + } + + if (!extractedPlan) { + extractedPlan = extractTasksFromSpec(sourceSpec); + } + if (extractedPlan) { writeFileSync(implementationPlanPath, extractedPlan); console.log(chalk.cyan('Created IMPLEMENTATION_PLAN.md from spec')); + } + const stackSection = projectStack + ? `\n## Project Stack\n\nUse the existing project stack: **${projectStack}**. Follow existing patterns and conventions in the codebase.\n` + : ''; + if (projectStack) { + console.log(chalk.dim(` Stack: ${projectStack}`)); + } - finalTask = `Study the following specification carefully: + // Build a concise task headline from source title and stack + const taskHeadline = sourceTitle + ? `Implement the "${sourceTitle}" design${projectStack ? ` with ${projectStack}` : ''}.` + : `Implement this design${projectStack ? ` with ${projectStack}` : ''}.`; + + if (finalTask) { + // User provided both a task and a source spec β€” combine them + finalTask = `${taskHeadline} + +Study the following specification carefully: ${sourceSpec} +${stackSection} +## User Instructions + +${finalTask} + +## Implementation Tracking + +${ + extractedPlan + ? `An IMPLEMENTATION_PLAN.md file has been created with tasks extracted from this spec. +As you complete each task, mark it done by changing [ ] to [x] in IMPLEMENTATION_PLAN.md.` + : `Create an IMPLEMENTATION_PLAN.md file with tasks broken down from the spec above. +As you complete each task, mark it done by changing [ ] to [x] in IMPLEMENTATION_PLAN.md.` +} +Focus on ONE task at a time. Don't assume functionality is not already implemented β€” search the codebase first. +Implement completely β€” no placeholders or stubs.`; + } else if (extractedPlan) { + finalTask = `${taskHeadline} + +Study the following specification carefully: +${sourceSpec} +${stackSection} ## Implementation Tracking An IMPLEMENTATION_PLAN.md file has been created with tasks extracted from this spec. @@ -534,10 +922,12 @@ As you complete each task, mark it done by changing [ ] to [x] in IMPLEMENTATION Focus on ONE task at a time. Don't assume functionality is not already implemented β€” search the codebase first. Implement completely β€” no placeholders or stubs.`; } else { - finalTask = `Study the following specification carefully: + finalTask = `${taskHeadline} -${sourceSpec} +Study the following specification carefully: +${sourceSpec} +${stackSection} ## Getting Started IMPORTANT: Before writing any code, you MUST first: @@ -634,7 +1024,23 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN } // Auto-install relevant skills from skills.sh (enabled by default) - await autoInstallSkillsFromTask(finalTask, cwd); + await autoInstallSkillsFromTask(finalTask, cwd, options.from?.toLowerCase()); + + // Copy design reference image to specs/ so the agent can read it + let designImagePath: string | undefined; + if (options.designImage) { + const srcPath = resolve(options.designImage); + if (!existsSync(srcPath)) { + console.log(chalk.red(`Design image not found: ${srcPath}`)); + process.exit(1); + } + const specsDir = join(cwd, 'specs'); + mkdirSync(specsDir, { recursive: true }); + const destPath = join(specsDir, 'design-reference.png'); + copyFileSync(srcPath, destPath); + designImagePath = 'specs/design-reference.png'; + console.log(chalk.cyan(`Design reference image: ${designImagePath}`)); + } // Apply preset if specified let preset: PresetConfig | undefined; @@ -698,10 +1104,13 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN rateLimit: options.rateLimit ?? preset?.rateLimit, trackProgress: options.trackProgress ?? true, // Default to true trackCost: options.trackCost ?? true, // Default to true - model: agent.type === 'claude-code' ? 'claude-3-sonnet' : 'default', + model: options.model, // Pass through to agent CLI (e.g., --model claude-sonnet-4-5-20250929) checkFileCompletion: true, // Always check for file-based completion contextBudget: options.contextBudget ? Number(options.contextBudget) : undefined, maxCost: options.maxCost, + planBudget: options.plan + ? (await import('../loop/cost-tracker.js')).KNOWN_PLANS[options.plan.toLowerCase()] + : undefined, circuitBreaker: preset?.circuitBreaker ? { maxConsecutiveFailures: @@ -715,6 +1124,9 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN maxSameErrorCount: options.circuitBreakerErrors ?? 5, } : undefined, + figmaImagesDownloaded: figmaImagesDownloaded ?? undefined, + figmaFontSubstitutions: figmaFontSubstitutions ?? undefined, + designImagePath, }; const result = await runLoop(loopOptions); diff --git a/src/integrations/figma/data/google-fonts.ts b/src/integrations/figma/data/google-fonts.ts new file mode 100644 index 00000000..6cf15e01 --- /dev/null +++ b/src/integrations/figma/data/google-fonts.ts @@ -0,0 +1,275 @@ +/** + * Google Fonts Data + * + * Curated list of popular Google Fonts and common proprietary β†’ Google Font substitutions. + */ + +/** + * Popular Google Fonts names (~200 entries). + * Used to check if a Figma font is available on Google Fonts. + */ +export const GOOGLE_FONTS: ReadonlySet = new Set([ + // Sans-serif + 'ABeeZee', + 'Albert Sans', + 'Alegreya Sans', + 'Archivo', + 'Archivo Narrow', + 'Arimo', + 'Asap', + 'Assistant', + 'Barlow', + 'Barlow Condensed', + 'Barlow Semi Condensed', + 'Be Vietnam Pro', + 'Cabin', + 'Cairo', + 'Cantarell', + 'Catamaran', + 'Chivo', + 'Commissioner', + 'DM Sans', + 'Dosis', + 'Encode Sans', + 'Exo 2', + 'Figtree', + 'Fira Sans', + 'Fira Sans Condensed', + 'Fredoka', + 'Geist', + 'Hanken Grotesk', + 'Heebo', + 'Hind', + 'Hind Siliguri', + 'IBM Plex Sans', + 'Inter', + 'Inter Tight', + 'Jost', + 'Josefin Sans', + 'Kanit', + 'Karla', + 'Lato', + 'Lexend', + 'Libre Franklin', + 'M PLUS Rounded 1c', + 'Manrope', + 'Maven Pro', + 'Montserrat', + 'Mukta', + 'Mulish', + 'Noto Sans', + 'Noto Sans JP', + 'Noto Sans KR', + 'Noto Sans SC', + 'Noto Sans TC', + 'Nunito', + 'Nunito Sans', + 'Open Sans', + 'Oswald', + 'Outfit', + 'Overpass', + 'Oxygen', + 'PT Sans', + 'PT Sans Narrow', + 'Poppins', + 'Plus Jakarta Sans', + 'Public Sans', + 'Quicksand', + 'Rajdhani', + 'Raleway', + 'Red Hat Display', + 'Red Hat Text', + 'Roboto', + 'Roboto Condensed', + 'Roboto Flex', + 'Rubik', + 'Saira', + 'Schibsted Grotesk', + 'Signika', + 'Source Sans 3', + 'Space Grotesk', + 'Syne', + 'Teko', + 'Titillium Web', + 'Ubuntu', + 'Urbanist', + 'Varela Round', + 'Work Sans', + 'Yantramanav', + 'Zen Kaku Gothic New', + + // Serif + 'Abril Fatface', + 'Alegreya', + 'Bitter', + 'Bree Serif', + 'Brygada 1918', + 'Cormorant', + 'Cormorant Garamond', + 'Crimson Pro', + 'Crimson Text', + 'DM Serif Display', + 'DM Serif Text', + 'Domine', + 'EB Garamond', + 'Fraunces', + 'Gelasio', + 'IBM Plex Serif', + 'Instrument Serif', + 'Libre Baskerville', + 'Libre Caslon Text', + 'Lora', + 'Merriweather', + 'Noto Serif', + 'Noto Serif JP', + 'Old Standard TT', + 'PT Serif', + 'Petrona', + 'Playfair Display', + 'Playfair Display SC', + 'Roboto Serif', + 'Roboto Slab', + 'Rokkitt', + 'Source Serif 4', + 'Spectral', + 'Vollkorn', + 'Young Serif', + 'Zilla Slab', + + // Display + 'Alfa Slab One', + 'Anton', + 'Archivo Black', + 'Bangers', + 'Bebas Neue', + 'Black Ops One', + 'Bungee', + 'Chakra Petch', + 'Comfortaa', + 'Concert One', + 'Crete Round', + 'Dela Gothic One', + 'Fugaz One', + 'Gruppo', + 'Lilita One', + 'Lobster', + 'Lobster Two', + 'Orbitron', + 'Pacifico', + 'Permanent Marker', + 'Passion One', + 'Playball', + 'Righteous', + 'Russo One', + 'Satisfy', + 'Secular One', + 'Shadows Into Light', + 'Shrikhand', + 'Staatliches', + 'Ultra', + + // Monospace + 'Cousine', + 'Fira Code', + 'Fira Mono', + 'IBM Plex Mono', + 'Inconsolata', + 'JetBrains Mono', + 'Noto Sans Mono', + 'Overpass Mono', + 'PT Mono', + 'Red Hat Mono', + 'Roboto Mono', + 'Source Code Pro', + 'Space Mono', + 'Ubuntu Mono', + + // Handwriting + 'Caveat', + 'Dancing Script', + 'Great Vibes', + 'Indie Flower', + 'Kalam', + 'Patrick Hand', +]); + +/** + * Common proprietary/commercial font β†’ Google Fonts alternative mappings. + * Used when a Figma design uses a font not available on Google Fonts. + */ +export const FONT_SUBSTITUTIONS: ReadonlyMap = new Map([ + // System / Apple fonts + ['SF Pro', 'Inter'], + ['SF Pro Display', 'Inter'], + ['SF Pro Text', 'Inter'], + ['SF Pro Rounded', 'Nunito'], + ['SF Compact', 'Inter'], + ['SF Mono', 'JetBrains Mono'], + ['New York', 'Libre Baskerville'], + ['.SF NS', 'Inter'], + + // System / Microsoft fonts + ['Segoe UI', 'Open Sans'], + ['Segoe UI Variable', 'Open Sans'], + ['Consolas', 'Fira Code'], + ['Calibri', 'Lato'], + + // System / Generic + ['Helvetica', 'Inter'], + ['Helvetica Neue', 'Inter'], + ['Arial', 'Inter'], + ['Times New Roman', 'Merriweather'], + ['Georgia', 'Libre Baskerville'], + ['Verdana', 'Open Sans'], + ['Trebuchet MS', 'Fira Sans'], + ['Courier New', 'Source Code Pro'], + + // Popular commercial fonts + ['Proxima Nova', 'Montserrat'], + ['Proxima Nova Soft', 'Nunito'], + ['Futura', 'Jost'], + ['Futura PT', 'Jost'], + ['Gotham', 'Montserrat'], + ['Gotham Rounded', 'Nunito'], + ['Circular', 'DM Sans'], + ['Circular Std', 'DM Sans'], + ['Graphik', 'DM Sans'], + ['Gilroy', 'Manrope'], + ['Avenir', 'Nunito Sans'], + ['Avenir Next', 'Nunito Sans'], + ['Euclid Circular', 'DM Sans'], + ['Euclid Circular A', 'DM Sans'], + ['Euclid Circular B', 'DM Sans'], + ['AkzidenzGrotesk', 'Work Sans'], + ['Akzidenz-Grotesk', 'Work Sans'], + ['Brandon Grotesque', 'Raleway'], + ['Myriad Pro', 'Source Sans 3'], + ['Aktiv Grotesk', 'Inter'], + ['Cereal', 'Plus Jakarta Sans'], + ['Airbnb Cereal', 'Plus Jakarta Sans'], + ['Product Sans', 'Rubik'], + ['Google Sans', 'Rubik'], + + // Serif / Display commercial fonts + ['Chronicle Display', 'DM Serif Display'], + ['Chronicle Text', 'DM Serif Text'], + ['Tiempos', 'Lora'], + ['Tiempos Headline', 'Playfair Display'], + ['Canela', 'Fraunces'], + ['Recoleta', 'Young Serif'], + ['Sentinel', 'Bitter'], + ['Styrene', 'Space Grotesk'], + ['Neue Haas Grotesk', 'Inter'], + ['Neue Haas Unica', 'Inter'], + ['GT Walsheim', 'Plus Jakarta Sans'], + ['GT America', 'Barlow'], + ['Dia', 'Outfit'], + ['Aeonik', 'Manrope'], + ['SΓΆhne', 'Inter'], + ['Sohne', 'Inter'], + ['General Sans', 'DM Sans'], + ['Satoshi', 'Figtree'], + ['Cabinet Grotesk', 'Space Grotesk'], + ['Clash Display', 'Syne'], + ['Supreme', 'Outfit'], +]); diff --git a/src/integrations/figma/parsers/design-spec.ts b/src/integrations/figma/parsers/design-spec.ts index 3988baa1..e6e27922 100644 --- a/src/integrations/figma/parsers/design-spec.ts +++ b/src/integrations/figma/parsers/design-spec.ts @@ -5,12 +5,256 @@ * for use as AI coding loop task input. */ -import type { FigmaNode, TypeStyle } from '../types.js'; +import type { + FigmaNode, + GradientStop, + ImageFilters, + Paint, + RGBA, + Transform, + TypeStyle, +} from '../types.js'; + +export interface SpecOptions { + /** Font substitution map (original β†’ Google Fonts alternative) */ + fontSubstitutions?: Map; + /** Image fill download URLs (imageRef β†’ URL) */ + imageFillUrls?: Record; + /** Icon node IDs that were exported as SVG (nodeId β†’ filename) */ + exportedIcons?: Map; + /** Composite visual group node IDs that were rendered as single images (nodeId β†’ image path) */ + compositeImages?: Map; + /** Composite node IDs that have text overlays (visual-dominant composites) */ + compositeTextOverlays?: Set; +} + +/** + * Detect composite visual groups β€” overlapping visual layers that should + * be rendered as a single image rather than extracted individually. + * + * Examples: hero backgrounds made of stacked mountain/gradient layers, + * photo collages, layered illustrations. + * + * IMPORTANT: Composites must be PURE visual groups (shapes, images, gradients). + * The Figma /images API renders everything visible including text, so if a + * composite contains text or content frames, the rendered PNG will have + * baked-in text that conflicts with the text extracted in the spec. + * + * Detection criteria: + * 1. No auto-layout (children overlap intentionally) + * 2. Multiple children (β‰₯2) + * 3. ALL children are purely visual β€” zero TEXT nodes, zero FRAME/GROUP/COMPONENT + * children that contain text (these are content containers, not visual layers) + * 4. Container is large enough to be a background element (β‰₯200px both dimensions) + * 5. Children actually overlap (bounding box intersection >30%) + * 6. Not a direct child of CANVAS (those are page sections, not backgrounds) + */ +export interface CompositeNodeResult { + nodeId: string; + name: string; + width: number; + height: number; + /** Node IDs of visual-only children to render (excludes text overlays). Present only for visual-dominant composites. */ + visualChildIds?: string[]; + /** Whether this composite has text overlays that should be positioned on top */ + hasTextOverlays?: boolean; +} + +export function collectCompositeNodes(nodes: FigmaNode[]): CompositeNodeResult[] { + const results: CompositeNodeResult[] = []; + + // Collect IDs of top-level frames (direct children of CANVAS) to exclude them + const topLevelFrameIds = new Set(); + for (const node of nodes) { + if (node.type === 'CANVAS' && node.children) { + for (const child of node.children) { + topLevelFrameIds.add(child.id); + } + } + } + + function walk(node: FigmaNode) { + if (node.visible === false) return; + if (!node.children || node.children.length < 2) { + // Recurse anyway to check deeper nodes + if (node.children) { + for (const child of node.children) walk(child); + } + return; + } + + // Never treat top-level frames (page sections) as composites β€” + // they contain layout structure the agent needs + if (topLevelFrameIds.has(node.id)) { + for (const child of node.children) walk(child); + return; + } + + const bbox = node.absoluteBoundingBox; + // Must be large enough to be a background element (at least 200px in both dimensions) + if (!bbox || bbox.width < 200 || bbox.height < 200) { + for (const child of node.children) walk(child); + return; + } + + // Must NOT have auto-layout (overlapping is intentional) + if (node.layoutMode && node.layoutMode !== 'NONE') { + for (const child of node.children) walk(child); + return; + } + + const visibleChildren = node.children.filter((c) => c.visible !== false); + if (visibleChildren.length < 2) { + for (const child of node.children) walk(child); + return; + } + + // Partition children into visual layers vs text/content layers. + // Visual layers: image fills, gradients, shapes with NO text descendants. + // Text layers: TEXT nodes or frames/groups that contain text. + const visualLayers: FigmaNode[] = []; + const textLayers: FigmaNode[] = []; + + for (const child of visibleChildren) { + if (child.type === 'TEXT' || containsText(child)) { + textLayers.push(child); + } else if (hasVisualContent(child)) { + visualLayers.push(child); + } + } + + // Need at least 2 visual layers to form a composite + if (visualLayers.length < 2) { + for (const child of node.children) walk(child); + return; + } + + // Check if visual children overlap + const visualBboxes = visualLayers + .map((c) => c.absoluteBoundingBox) + .filter((b): b is NonNullable => b != null); + + if (visualBboxes.length >= 2 && hasSignificantOverlap(visualBboxes)) { + if (textLayers.length === 0) { + // PURE composite: all visual, no text β€” render entire node as single image + results.push({ + nodeId: node.id, + name: node.name, + width: Math.round(bbox.width), + height: Math.round(bbox.height), + }); + // Don't recurse β€” children are handled as one unit + return; + } + // VISUAL-DOMINANT composite: overlapping visuals WITH text overlays. + // The text layers must NOT be rendered into the composite image + // (Figma API bakes text into PNGs, causing duplication). + // We still record the composite but include visualChildIds so the + // rendering can target just the visual layers. + results.push({ + nodeId: node.id, + name: node.name, + width: Math.round(bbox.width), + height: Math.round(bbox.height), + visualChildIds: visualLayers.map((c) => c.id), + hasTextOverlays: true, + }); + // Recurse into text layers so they appear in the spec as normal + for (const child of textLayers) walk(child); + return; + } + + // Recurse into children + for (const child of node.children) walk(child); + } + + for (const node of nodes) walk(node); + return results; +} + +/** Check if a node or any of its descendants contain text */ +function containsText(node: FigmaNode): boolean { + if (node.type === 'TEXT') return true; + if (node.children) { + for (const child of node.children) { + if (child.visible !== false && containsText(child)) return true; + } + } + return false; +} + +/** Check if a node has visual content (images, fills, or vector shapes) */ +function hasVisualContent(node: FigmaNode): boolean { + // Has image fill + if (node.fills?.some((f) => f.type === 'IMAGE' && f.visible !== false)) return true; + // Has meaningful color fill + if (node.fills?.some((f) => f.visible !== false && f.type === 'SOLID')) return true; + // Has gradient + if (node.fills?.some((f) => f.visible !== false && f.type?.startsWith('GRADIENT'))) return true; + // Is a vector/shape + if (['VECTOR', 'BOOLEAN_OPERATION', 'RECTANGLE', 'ELLIPSE'].includes(node.type)) return true; + // Is a group/frame with visual children + if (node.children?.some((c) => c.visible !== false && hasVisualContent(c))) return true; + return false; +} + +/** Check if bounding boxes have significant overlap (>30% of the smallest box) */ +function hasSignificantOverlap( + boxes: Array<{ x: number; y: number; width: number; height: number }> +): boolean { + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i]; + const b = boxes[j]; + const overlapX = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)); + const overlapY = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)); + const overlapArea = overlapX * overlapY; + const smallerArea = Math.min(a.width * a.height, b.width * b.height); + if (smallerArea > 0 && overlapArea / smallerArea > 0.3) return true; + } + } + return false; +} + +/** + * Select the primary frame from a list of canvas children. + * + * When a Figma page has multiple top-level frames (e.g., "Desktop", "Mobile", + * component sheets, copies), we only want the main design frame in the spec. + * Otherwise the agent gets duplicate content and implements everything twice. + * + * Heuristic: pick the visible frame with the largest area (width Γ— height). + * This is almost always the primary design artboard. + */ +export function selectPrimaryFrames(children: FigmaNode[]): FigmaNode[] { + const visibleFrames = children.filter( + (c) => c.visible !== false && c.type === 'FRAME' && c.absoluteBoundingBox + ); + + if (visibleFrames.length <= 1) { + // 0 or 1 frames β€” return all visible children (may include non-frame nodes) + return children.filter((c) => c.visible !== false); + } + + // Pick the frame with the largest area + let largest = visibleFrames[0]; + let largestArea = 0; + for (const frame of visibleFrames) { + const bbox = frame.absoluteBoundingBox!; + const area = bbox.width * bbox.height; + if (area > largestArea) { + largestArea = area; + largest = frame; + } + } + + return [largest]; +} /** * Convert Figma nodes to a markdown design specification */ -export function nodesToSpec(nodes: FigmaNode[], fileName: string): string { +export function nodesToSpec(nodes: FigmaNode[], fileName: string, options?: SpecOptions): string { const sections: string[] = [`# Design Specification: ${fileName}\n`]; for (const node of nodes) { @@ -21,12 +265,14 @@ export function nodesToSpec(nodes: FigmaNode[], fileName: string): string { if (node.type === 'CANVAS') { sections.push(`## Page: ${node.name}\n`); if (node.children) { - for (const child of node.children) { - sections.push(nodeToMarkdown(child, 2)); + // Select only the primary frame to avoid spec duplication + const primaryChildren = selectPrimaryFrames(node.children); + for (const child of primaryChildren) { + sections.push(nodeToMarkdown(child, 2, options)); } } } else { - sections.push(nodeToMarkdown(node, 1)); + sections.push(nodeToMarkdown(node, 1, options)); } } @@ -36,7 +282,7 @@ export function nodesToSpec(nodes: FigmaNode[], fileName: string): string { /** * Convert a single Figma node to markdown */ -function nodeToMarkdown(node: FigmaNode, depth: number): string { +function nodeToMarkdown(node: FigmaNode, depth: number, options?: SpecOptions): string { // Skip invisible nodes if (node.visible === false) return ''; @@ -49,10 +295,12 @@ function nodeToMarkdown(node: FigmaNode, depth: number): string { // Add type badge lines.push(`\n*Type: ${formatNodeType(node.type)}*`); - // Add dimensions if available + // Add dimensions and position if available if (node.absoluteBoundingBox) { - const { width, height } = node.absoluteBoundingBox; - lines.push(`*Dimensions: ${Math.round(width)} x ${Math.round(height)} px*`); + const { x, y, width, height } = node.absoluteBoundingBox; + lines.push( + `*Dimensions: ${Math.round(width)} x ${Math.round(height)} px β€” Position: (${Math.round(x)}, ${Math.round(y)})*` + ); } // Add description if available (frame descriptions are great for specs) @@ -63,9 +311,21 @@ function nodeToMarkdown(node: FigmaNode, depth: number): string { // Add layout info for auto-layout frames if (node.layoutMode && node.layoutMode !== 'NONE') { lines.push(`\n**Layout:** ${node.layoutMode.toLowerCase()}`); + // Container sizing + const hSize = formatLayoutSizing(node.layoutSizingHorizontal, 'width'); + const vSize = formatLayoutSizing(node.layoutSizingVertical, 'height'); + if (hSize) lines.push(`- Width sizing: ${hSize}`); + if (vSize) lines.push(`- Height sizing: ${vSize}`); + // Flex wrap + if (node.layoutWrap === 'WRAP') { + lines.push(`- Wrap: flex-wrap: wrap`); + } if (node.itemSpacing) { lines.push(`- Gap: ${node.itemSpacing}px`); } + if (node.counterAxisSpacing) { + lines.push(`- Row gap: ${node.counterAxisSpacing}px`); + } if (node.paddingTop || node.paddingRight || node.paddingBottom || node.paddingLeft) { lines.push( `- Padding: ${node.paddingTop || 0}px ${node.paddingRight || 0}px ${node.paddingBottom || 0}px ${node.paddingLeft || 0}px` @@ -79,14 +339,154 @@ function nodeToMarkdown(node: FigmaNode, depth: number): string { } } + // Infer layout from positions when no auto-layout is present + if ( + (!node.layoutMode || node.layoutMode === 'NONE') && + node.children && + node.children.length >= 2 + ) { + const meaningfulKids = node.children.filter( + (c) => c.visible !== false && c.absoluteBoundingBox + ); + const inferred = inferLayout(node, meaningfulKids); + if (inferred && inferred.type !== 'absolute') { + lines.push( + `\n**Inferred Layout** (no auto-layout in Figma β€” derived from element positions):` + ); + lines.push( + `- CSS: \`display: flex; flex-direction: ${inferred.type === 'flex-row' ? 'row' : 'column'}\`` + ); + if (inferred.gap) lines.push(`- Gap: ${inferred.gap}px`); + if (inferred.padding) { + const { top, right, bottom, left } = inferred.padding; + lines.push(`- Padding: ${top}px ${right}px ${bottom}px ${left}px`); + } + if (inferred.justify) lines.push(`- Justify: ${inferred.justify}`); + if (inferred.align) lines.push(`- Align: ${inferred.align}`); + } + } + + // Add flex grow + if (node.layoutGrow && node.layoutGrow > 0) { + lines.push(`- Flex grow: ${node.layoutGrow}`); + } + + // Child sizing within parent's auto-layout (only when this is NOT a layout container itself) + if (!node.layoutMode || node.layoutMode === 'NONE') { + const hChildSize = formatLayoutSizing(node.layoutSizingHorizontal, 'width'); + const vChildSize = formatLayoutSizing(node.layoutSizingVertical, 'height'); + if (hChildSize) lines.push(`- Width: ${hChildSize}`); + if (vChildSize) lines.push(`- Height: ${vChildSize}`); + } + + // Self-alignment override in parent's cross-axis + if (node.layoutAlign && node.layoutAlign !== 'INHERIT') { + const selfAlignMap: Record = { + STRETCH: 'stretch (fill cross-axis)', + MIN: 'align-self: flex-start', + CENTER: 'align-self: center', + MAX: 'align-self: flex-end', + }; + lines.push(`- Self alignment: ${selfAlignMap[node.layoutAlign] || node.layoutAlign}`); + } + + // Absolutely-positioned child inside auto-layout parent + if (node.layoutPositioning === 'ABSOLUTE') { + lines.push( + `\n**Positioning:** absolute (not in flow β€” use \`position: absolute\` with coordinates)` + ); + } + + // Overflow clipping + if (node.clipsContent) { + lines.push(`\n**Overflow:** hidden`); + } + + // Scroll/sticky behavior + if (node.scrollBehavior && node.scrollBehavior !== 'SCROLLS') { + const behaviorMap: Record = { + FIXED: '`position: fixed`', + STICKY_SCROLLS: '`position: sticky; top: 0`', + }; + lines.push(`\n**Scroll behavior:** ${behaviorMap[node.scrollBehavior] || node.scrollBehavior}`); + } + + // Scrollable container + if (node.overflowDirection && node.overflowDirection !== 'NONE') { + const dirMap: Record = { + HORIZONTAL_SCROLLING: '`overflow-x: auto` (horizontal scroll)', + VERTICAL_SCROLLING: '`overflow-y: auto` (vertical scroll)', + HORIZONTAL_AND_VERTICAL_SCROLLING: '`overflow: auto` (both axes)', + }; + lines.push(`\n**Scrollable:** ${dirMap[node.overflowDirection] || node.overflowDirection}`); + } + + // Responsive size constraints + const sizeConstraints: string[] = []; + if (node.minWidth) sizeConstraints.push(`min-width: ${node.minWidth}px`); + if (node.maxWidth) sizeConstraints.push(`max-width: ${node.maxWidth}px`); + if (node.minHeight) sizeConstraints.push(`min-height: ${node.minHeight}px`); + if (node.maxHeight) sizeConstraints.push(`max-height: ${node.maxHeight}px`); + if (sizeConstraints.length > 0) { + lines.push(`\n**Size constraints:** ${sizeConstraints.join(', ')}`); + } + + // Add constraints (only for non-auto-layout contexts β€” redundant when layoutSizing is present) + if (node.constraints && (!node.layoutSizingHorizontal || node.layoutPositioning === 'ABSOLUTE')) { + const hHint = formatConstraintHint(node.constraints.horizontal, 'horizontal'); + const vHint = formatConstraintHint(node.constraints.vertical, 'vertical'); + if (hHint || vHint) { + lines.push(`\n**Constraints:**`); + if (hHint) lines.push(`- Horizontal: ${hHint}`); + if (vHint) lines.push(`- Vertical: ${vHint}`); + } + } + + // Add fill colors and gradients (skip IMAGE fills β€” handled below) + if (node.fills) { + const colorFills = node.fills.filter( + (f) => f.visible !== false && f.type !== 'IMAGE' && f.type !== 'EMOJI' && f.type !== 'VIDEO' + ); + if (colorFills.length > 0) { + const fillStr = formatFills(colorFills); + if (fillStr) lines.push(fillStr); + } + } + + // Add strokes (borders) + if (node.strokes && node.strokes.length > 0) { + const strokeStr = formatStrokes(node); + if (strokeStr) lines.push(strokeStr); + } + + // Add opacity + if (node.opacity !== undefined && node.opacity < 1) { + lines.push(`\n**Opacity:** ${node.opacity}`); + } + + // Add rotation + if (node.rotation && Math.abs(node.rotation) > 0.1) { + lines.push(`\n**Rotation:** \`transform: rotate(${Math.round(node.rotation)}deg)\``); + } + + // Mask indicator + if (node.isMask) { + lines.push(`\n**Mask layer** (clips following siblings to this shape)`); + } + // Add text content if (node.type === 'TEXT' && node.characters) { lines.push(`\n**Text content:**`); lines.push(`> ${node.characters.replace(/\n/g, '\n> ')}`); + // Hyperlink + if (node.style?.hyperlink?.type === 'URL' && node.style.hyperlink.url) { + lines.push(`- Link: \`${node.style.hyperlink.url}\``); + } + if (node.style) { lines.push(`\n**Typography:**`); - lines.push(formatTypography(node.style)); + lines.push(formatTypography(node.style, options?.fontSubstitutions)); } } @@ -127,33 +527,717 @@ function nodeToMarkdown(node: FigmaNode, depth: number): string { } } + // Add image fill info with CSS implementation hints + if (node.fills) { + const imageFills = node.fills.filter( + (f) => f.type === 'IMAGE' && f.visible !== false && f.imageRef + ); + if (imageFills.length > 0) { + // Determine if this is a background image (frame/rectangle with image fill + children) + const isBackgroundImage = node.children && node.children.length > 0; + // Detect hero-like sections: large frames (β‰₯ 400px tall) with bg image + child content + const bbox = node.absoluteBoundingBox; + const isHeroSection = isBackgroundImage && bbox && bbox.height >= 400 && depth <= 3; + const imageLabel = isHeroSection + ? 'Image (Hero Background)' + : isBackgroundImage + ? 'Image (Background)' + : 'Image'; + lines.push(`\n**${imageLabel}:**`); + for (const fill of imageFills) { + const dims = node.absoluteBoundingBox; + const dimStr = dims ? ` (${Math.round(dims.width)}x${Math.round(dims.height)})` : ''; + const hasDownload = options?.imageFillUrls?.[fill.imageRef!]; + const path = hasDownload + ? `/images/${fill.imageRef}.png` + : `placehold.co/${dims ? `${Math.round(dims.width)}x${Math.round(dims.height)}` : '400x300'}`; + lines.push(`- Source: \`${path}\`${dimStr}`); + lines.push(`- Element: "${node.name}"`); + if (fill.scaleMode) { + const cssHint = scaleModeToCSS(fill.scaleMode, isBackgroundImage); + lines.push(`- Scale mode: ${fill.scaleMode} β†’ CSS: ${cssHint}`); + } + // Decode imageTransform to CSS object-position / background-position + const posHint = fill.imageTransform + ? imageTransformToObjectPosition(fill.imageTransform) + : null; + if (posHint) { + if (isBackgroundImage) { + lines.push(`- Crop position: \`background-position: ${posHint}\``); + } else { + lines.push(`- Crop position: \`object-position: ${posHint}\``); + } + } + // Image filters β†’ CSS filter() + if (fill.filters) { + const cssFilters = imageFiltersToCss(fill.filters); + if (cssFilters) { + lines.push(`- Filters: \`filter: ${cssFilters}\``); + } + } + if (isBackgroundImage) { + const bgPos = posHint || 'center'; + if (isHeroSection) { + lines.push( + `- Implementation (HERO): This image MUST fill the entire section. Use \`position: relative\` on the section container with \`min-height: ${dims ? `${Math.round(dims.height)}px` : '100vh'}\`. Apply the image as either:` + ); + lines.push( + ` * CSS background: \`background-image: url(${path}); background-size: cover; background-position: ${bgPos}\`` + ); + lines.push( + ` * Or absolute \`\`: \`position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: ${bgPos}; z-index: 0\`` + ); + lines.push( + ` * All child content must use \`position: relative; z-index: 1\` to appear above the image` + ); + } else { + lines.push( + `- Implementation: Use CSS \`background-image: url(${path})\` with \`background-size: cover; background-position: ${bgPos}\` on container div, or \`\` with \`object-fit: cover; object-position: ${bgPos}\` as absolute-positioned child behind content` + ); + } + } + // Classify image semantic importance (people, products, key visuals) + const sectionBbox = node.absoluteBoundingBox; + const importance = classifyImageImportance(node.name, dims ?? null, sectionBbox ?? null); + if (importance) { + lines.push( + `- **Responsive Priority: ${importance.priority.toUpperCase()}** β€” ${importance.hint}` + ); + } + } + } + } + + // Add exported icon reference (SVG file) + const iconFile = options?.exportedIcons?.get(node.id); + if (iconFile) { + lines.push(`\n**Icon (SVG):**`); + lines.push(`- Source: \`/images/icons/${iconFile}\``); + lines.push(`- Element: "${node.name}"`); + lines.push( + `- Implementation: Use \`${node.name}\` or inline SVG` + ); + } + lines.push(''); - // Process children (but limit depth for readability) - if (node.children && depth < 4) { - // Filter to meaningful children + // Check if this node is a composite visual group (rendered as single image) + const compositeImagePath = options?.compositeImages?.get(node.id); + const compositeHasTextOverlays = options?.compositeTextOverlays?.has(node.id); + if (compositeImagePath) { + const bbox = node.absoluteBoundingBox; + const dimStr = bbox ? ` (${Math.round(bbox.width)}x${Math.round(bbox.height)})` : ''; + + if (compositeHasTextOverlays) { + // Visual-dominant composite: visual layers rendered as image, text extracted separately + lines.push( + `\n**Composite Background (visual layers only β€” text NOT included in this image):**` + ); + lines.push(`- Source: \`${compositeImagePath}\`${dimStr}`); + lines.push(`- Element: "${node.name}"`); + lines.push( + `- This image contains ONLY the visual layers (mountains, gradients, images). Text content appears BELOW as separate elements β€” overlay them on top.` + ); + if (bbox && bbox.height >= 400) { + lines.push(`- Implementation (HERO PARALLAX):`); + lines.push( + ` * Container: \`position: relative; overflow: hidden; min-height: ${Math.round(bbox.height)}px\`` + ); + lines.push( + ` * Background image: \`position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0\`` + ); + lines.push( + ` * CSS: \`background-image: url(${compositeImagePath}); background-size: cover; background-position: center; min-height: ${Math.round(bbox.height)}px\`` + ); + lines.push( + ` * ALL text/content below must use \`position: relative; z-index: 1\` to layer OVER the background` + ); + } else { + lines.push( + `- Implementation: Use as full-bleed \`background-image\` with \`background-size: cover\`, all content with \`position: relative; z-index: 1\`` + ); + } + + // Recurse into text/content children (they were NOT rendered into the composite image) + if (node.children) { + const textChildren = node.children.filter((child) => { + if (child.visible === false) return false; + return child.type === 'TEXT' || containsText(child); + }); + for (let i = 0; i < textChildren.length; i++) { + lines.push(nodeToMarkdown(textChildren[i], depth + 1, options)); + } + } + } else { + // Pure composite: all visual, no text children + lines.push(`\n**Composite Background (rendered as single image):**`); + lines.push(`- Source: \`${compositeImagePath}\`${dimStr}`); + lines.push(`- Element: "${node.name}"`); + lines.push( + `- This image combines multiple overlapping visual layers from the Figma design into a single background.` + ); + if (bbox && bbox.height >= 400) { + lines.push(`- Implementation (HERO): This image MUST fill the entire section.`); + lines.push( + ` * CSS: \`background-image: url(${compositeImagePath}); background-size: cover; background-position: center; min-height: ${Math.round(bbox.height)}px\`` + ); + lines.push( + ` * Or: \`\`` + ); + lines.push(` * All text/content on top must use \`position: relative; z-index: 1\``); + } else { + lines.push( + `- Implementation: Use as \`background-image\` with \`background-size: cover\` or as an \`\` with \`object-fit: cover\`` + ); + } + } + } else if (node.children && depth < 6) { + // Process children (limit depth for readability β€” deeper levels are more selective) + const isDeep = depth >= 4; const meaningfulChildren = node.children.filter((child) => { if (child.visible === false) return false; + // At deep levels, only include nodes with actual content (text, images, large elements) + if (isDeep) { + if (child.type === 'TEXT') return true; + if (child.fills?.some((f) => f.visible !== false && f.type === 'IMAGE')) return true; + const bbox = child.absoluteBoundingBox; + return bbox ? bbox.width >= 50 && bbox.height >= 50 : false; + } // Include frames, components, groups, text - return [ - 'FRAME', - 'COMPONENT', - 'COMPONENT_SET', - 'INSTANCE', - 'GROUP', - 'TEXT', - 'SECTION', - ].includes(child.type); + if ( + ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE', 'GROUP', 'TEXT', 'SECTION'].includes( + child.type + ) + ) { + return true; + } + // Include shapes with fills (colors, gradients, images) that are large enough to be meaningful + if (['RECTANGLE', 'ELLIPSE'].includes(child.type)) { + const hasFill = child.fills?.some((f) => f.visible !== false) ?? false; + if (!hasFill) return false; + // Skip tiny spacer elements + const bbox = child.absoluteBoundingBox; + return bbox ? bbox.width >= 20 && bbox.height >= 20 : false; + } + // Include VECTOR/BOOLEAN_OPERATION nodes that were exported as icons + if (['VECTOR', 'BOOLEAN_OPERATION'].includes(child.type)) { + const bbox = child.absoluteBoundingBox; + return bbox ? bbox.width >= 8 && bbox.height >= 8 : false; + } + return false; }); - for (const child of meaningfulChildren) { - lines.push(nodeToMarkdown(child, depth + 1)); + // Detect overlapping siblings (non-auto-layout) to add z-index hints + const hasOverlap = !node.layoutMode || node.layoutMode === 'NONE'; + + // Detect sequential/numbered patterns in siblings (e.g., "01", "02", "03" or "Step 1", "Step 2") + const sequentialPattern = detectSequentialPattern(meaningfulChildren); + if (sequentialPattern) { + lines.push( + `\n**Sequential Pattern Detected (${sequentialPattern.type}):** ${sequentialPattern.description}` + ); + lines.push( + `- IMPORTANT: These elements follow an ordered sequence. Preserve the exact layout order and spacing from the design.` + ); + lines.push(`- Items: ${sequentialPattern.labels.join(' β†’ ')}`); + } + + for (let i = 0; i < meaningfulChildren.length; i++) { + const child = meaningfulChildren[i]; + // In overlapping contexts, text/content ALWAYS gets higher z-index than visual layers. + // This is a universal rule: text must be readable above backgrounds, images, and decorations. + if (hasOverlap && meaningfulChildren.length > 1) { + const isTextContent = child.type === 'TEXT' || containsText(child); + if (isTextContent) { + lines.push( + `` + ); + } else { + const isImage = child.fills?.some((f) => f.visible !== false && f.type === 'IMAGE'); + lines.push( + `` + ); + } + } + lines.push(nodeToMarkdown(child, depth + 1, options)); } } return lines.join('\n'); } +// ─── Sequential Pattern Detection ─────────────────────────── + +interface SequentialPatternResult { + type: string; + description: string; + labels: string[]; +} + +/** + * Detect sequential/numbered patterns among sibling elements. + * + * Designs often use numbered steps (01, 02, 03), ordered lists, or + * sequential labels. The agent must preserve the exact order and + * spacing from the design, not reflow them arbitrarily. + */ +function detectSequentialPattern(children: FigmaNode[]): SequentialPatternResult | null { + if (children.length < 2) return null; + + // Strategy 1: Check node names for numeric sequences ("01", "02" or "Step 1", "Step 2") + const nameNums: { index: number; num: number; label: string }[] = []; + for (let i = 0; i < children.length; i++) { + const match = children[i].name.match(/(\d+)/); + if (match) { + nameNums.push({ index: i, num: parseInt(match[1], 10), label: children[i].name }); + } + } + + if (nameNums.length >= 2) { + const sorted = [...nameNums].sort((a, b) => a.num - b.num); + const isSequential = sorted.every((item, i) => i === 0 || item.num === sorted[i - 1].num + 1); + if (isSequential) { + return { + type: 'numbered-steps', + description: `${sorted.length} ordered items (${sorted[0].num}–${sorted[sorted.length - 1].num})`, + labels: sorted.map((s) => s.label), + }; + } + } + + // Strategy 2: Check first TEXT child for leading numbers ("01 ...", "1. ...") + const textNums: { index: number; num: number; label: string }[] = []; + for (let i = 0; i < children.length; i++) { + const firstText = findFirstTextContent(children[i]); + if (firstText) { + const match = firstText.match(/^[0\s]*(\d+)/); + if (match) { + const preview = firstText.slice(0, 40).replace(/\n/g, ' '); + textNums.push({ index: i, num: parseInt(match[1], 10), label: preview }); + } + } + } + + if (textNums.length >= 2) { + const sorted = [...textNums].sort((a, b) => a.num - b.num); + const isSequential = sorted.every((item, i) => i === 0 || item.num === sorted[i - 1].num + 1); + if (isSequential) { + return { + type: 'numbered-content', + description: `${sorted.length} sequentially numbered content blocks`, + labels: sorted.map((s) => s.label), + }; + } + } + + return null; +} + +/** Find the first text content string in a node tree (shallow search, max depth 3) */ +function findFirstTextContent(node: FigmaNode, depth = 0): string | null { + if (depth > 3) return null; + if (node.type === 'TEXT' && node.characters) return node.characters; + if (node.children) { + for (const child of node.children) { + if (child.visible === false) continue; + const text = findFirstTextContent(child, depth + 1); + if (text) return text; + } + } + return null; +} + +// ─── Image Semantic Importance ────────────────────────────── + +interface ImageImportance { + priority: 'critical' | 'high' | 'normal'; + hint: string; +} + +/** + * Classify image semantic importance from node name and context. + * + * Universal heuristic: images of people are almost always the most + * important visual element on a page (hero portraits, team photos, + * testimonials). They must never be hidden or cropped away at any + * breakpoint. Product/feature images are also high-priority. + */ +function classifyImageImportance( + nodeName: string, + bbox: { width: number; height: number } | null, + sectionBbox: { width: number; height: number } | null +): ImageImportance | null { + const lower = nodeName.toLowerCase(); + + // Person/people images β€” highest priority + if ( + /\b(person|people|portrait|photo|avatar|team|founder|headshot|profile|model|woman|man|face|selfie|human|client|testimonial)\b/.test( + lower + ) || + /\b(hero.*(image|photo|pic|img))\b/.test(lower) + ) { + return { + priority: 'critical', + hint: 'Contains a person/people β€” this is the KEY visual element. MUST remain visible at ALL viewport sizes. Use `object-position: top center` to keep the face/upper body visible when cropping. NEVER use `display: none` or `visibility: hidden` on this image at any breakpoint.', + }; + } + + // Large images relative to section β€” likely the primary visual + if (bbox && sectionBbox) { + const areaRatio = (bbox.width * bbox.height) / (sectionBbox.width * sectionBbox.height); + if (areaRatio > 0.3) { + return { + priority: 'high', + hint: 'Large primary image β€” keep visible at all breakpoints. On smaller screens, scale proportionally rather than hiding.', + }; + } + } + + // Product/key content images + if ( + /\b(product|feature|showcase|main|key|primary|highlight|mockup|screenshot|demo)\b/.test(lower) + ) { + return { + priority: 'high', + hint: 'Key content image β€” must remain visible and prominent at all breakpoints.', + }; + } + + return null; +} + +// ─── Layout Inference ─────────────────────────────────────── + +interface InferredLayout { + type: 'flex-row' | 'flex-column' | 'absolute'; + gap?: number; + padding?: { top: number; right: number; bottom: number; left: number }; + justify?: string; + align?: string; +} + +/** + * Infer CSS layout from absolute positions of sibling elements. + * Used when Figma designs lack auto-layout (e.g., community files). + */ +function inferLayout(parent: FigmaNode, children: FigmaNode[]): InferredLayout | null { + if (children.length < 2) return null; + if (parent.layoutMode && parent.layoutMode !== 'NONE') return null; + + const parentBbox = parent.absoluteBoundingBox; + if (!parentBbox) return null; + + const childBoxes = children + .filter((c) => c.absoluteBoundingBox) + .map((c) => { + const bb = c.absoluteBoundingBox as NonNullable; + return { + node: c, + bbox: bb, + relX: bb.x - parentBbox.x, + relY: bb.y - parentBbox.y, + }; + }); + + if (childBoxes.length < 2) return null; + + // Check for horizontal row: all children share similar Y within tolerance + const yTolerance = Math.min(20, parentBbox.height * 0.05); + const yValues = childBoxes.map((c) => c.relY); + const yRange = Math.max(...yValues) - Math.min(...yValues); + const isHorizontalRow = yRange < yTolerance; + + // Check for vertical column: all children share similar X within tolerance + const xTolerance = Math.min(20, parentBbox.width * 0.05); + const xValues = childBoxes.map((c) => c.relX); + const xRange = Math.max(...xValues) - Math.min(...xValues); + const isVerticalColumn = xRange < xTolerance; + + if (isHorizontalRow && childBoxes.length >= 2) { + const sorted = [...childBoxes].sort((a, b) => a.relX - b.relX); + const gaps: number[] = []; + for (let i = 1; i < sorted.length; i++) { + const prevRight = sorted[i - 1].relX + sorted[i - 1].bbox.width; + gaps.push(Math.round(sorted[i].relX - prevRight)); + } + const avgGap = gaps.length > 0 ? Math.round(gaps.reduce((s, g) => s + g, 0) / gaps.length) : 0; + + const firstLeft = sorted[0].relX; + const lastRight = sorted[sorted.length - 1].relX + sorted[sorted.length - 1].bbox.width; + const topPad = Math.round(Math.max(0, Math.min(...yValues))); + const leftPad = Math.round(Math.max(0, firstLeft)); + const rightPad = Math.round(Math.max(0, parentBbox.width - lastRight)); + + return { + type: 'flex-row', + gap: avgGap > 0 ? avgGap : undefined, + padding: + topPad > 0 || leftPad > 0 || rightPad > 0 + ? { top: topPad, right: rightPad, bottom: 0, left: leftPad } + : undefined, + justify: detectJustification( + sorted.map((s) => s.relX), + sorted.map((s) => s.bbox.width), + parentBbox.width + ), + }; + } + + if (isVerticalColumn && childBoxes.length >= 2) { + const sorted = [...childBoxes].sort((a, b) => a.relY - b.relY); + const gaps: number[] = []; + for (let i = 1; i < sorted.length; i++) { + const prevBottom = sorted[i - 1].relY + sorted[i - 1].bbox.height; + gaps.push(Math.round(sorted[i].relY - prevBottom)); + } + const avgGap = gaps.length > 0 ? Math.round(gaps.reduce((s, g) => s + g, 0) / gaps.length) : 0; + + const topPad = Math.round(Math.max(0, sorted[0].relY)); + const leftPad = Math.round(Math.max(0, Math.min(...xValues))); + + return { + type: 'flex-column', + gap: avgGap > 0 ? avgGap : undefined, + padding: + topPad > 0 || leftPad > 0 ? { top: topPad, right: 0, bottom: 0, left: leftPad } : undefined, + }; + } + + // Neither row nor column β€” elements may overlap or have complex positions + return { type: 'absolute' }; +} + +/** + * Detect justification (start, center, space-between, end) from child X positions. + */ +function detectJustification( + xs: number[], + widths: number[], + parentWidth: number +): string | undefined { + if (xs.length < 2) return undefined; + + const firstLeft = xs[0]; + const lastRight = xs[xs.length - 1] + widths[widths.length - 1]; + const leftSpace = firstLeft; + const rightSpace = parentWidth - lastRight; + + // Nearly equal left/right space β†’ center + if (Math.abs(leftSpace - rightSpace) < 20 && leftSpace > 20) { + return 'center'; + } + + // Very little left space, substantial right space β†’ flex-start + if (leftSpace < 20 && rightSpace > 40) { + return 'flex-start'; + } + + // Very little right space, substantial left space β†’ flex-end + if (rightSpace < 20 && leftSpace > 40) { + return 'flex-end'; + } + + // Equal gaps between items β†’ space-between (if outer edges have minimal spacing) + if (xs.length >= 3) { + const gaps: number[] = []; + for (let i = 1; i < xs.length; i++) { + gaps.push(xs[i] - (xs[i - 1] + widths[i - 1])); + } + const gapVariance = Math.max(...gaps) - Math.min(...gaps); + if (gapVariance < 10 && leftSpace < gaps[0] * 0.5) { + return 'space-between'; + } + } + + return undefined; +} + +// ─── Helper functions ─────────────────────────────────────── + +/** + * Convert Figma scaleMode to CSS implementation hint + */ +function scaleModeToCSS(scaleMode: string, isBackground?: boolean): string { + if (isBackground) { + switch (scaleMode) { + case 'FILL': + return '`background-size: cover; background-position: center`'; + case 'FIT': + return '`background-size: contain; background-repeat: no-repeat; background-position: center`'; + case 'TILE': + return '`background-repeat: repeat; background-size: auto`'; + case 'STRETCH': + return '`background-size: 100% 100%`'; + default: + return `\`background-size: cover\``; + } + } + switch (scaleMode) { + case 'FILL': + return '`object-fit: cover`'; + case 'FIT': + return '`object-fit: contain`'; + case 'STRETCH': + return '`object-fit: fill`'; + case 'TILE': + return '`background-repeat: repeat` (use as CSS background)'; + default: + return '`object-fit: cover`'; + } +} + +/** + * Decode Figma imageTransform (2Γ—3 affine matrix) into CSS object-position. + * + * Figma's imageTransform [[a, b, tx], [c, d, ty]] encodes the crop region: + * - tx, ty = offset of the visible region (0-1 range, relative to image) + * - a, d = scale (portion of image visible: 1 = full, 0.5 = 50%) + * + * For CSS object-position with object-fit:cover, the percentage tells the + * browser which part of the image to anchor. We convert the Figma offset + * and scale into a percentage: position = tx / (1 - a) when a < 1. + */ +function imageTransformToObjectPosition(transform: Transform): string | null { + const [[a, , tx], [, d, ty]] = transform; + + const EPSILON = 1e-4; // Threshold for "no meaningful crop" + const isFullScaleX = Math.abs(1 - a) < EPSILON; + const isFullScaleY = Math.abs(1 - d) < EPSILON; + + // If both dimensions show full image (no crop), position is irrelevant + if (isFullScaleX && isFullScaleY) return null; + + // For each axis: if cropped, compute position; if full-scale, center (50%) + const xPct = !isFullScaleX ? Math.round((tx / (1 - a)) * 100) : 50; + const yPct = !isFullScaleY ? Math.round((ty / (1 - d)) * 100) : 50; + + // Default is 50% 50% β€” skip if that's what we computed + if (xPct === 50 && yPct === 50) return null; + return `${clampPct(xPct)}% ${clampPct(yPct)}%`; +} + +function clampPct(v: number): number { + return Math.max(0, Math.min(100, v)); +} + +/** + * Convert Figma RGBA (0-1 range) to hex string + */ +function rgbaToHex(rgba: RGBA): string { + const r = Math.round(rgba.r * 255); + const g = Math.round(rgba.g * 255); + const b = Math.round(rgba.b * 255); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + +/** + * Convert Figma RGBA to CSS rgba() or hex + */ +export function rgbaToCss(rgba: RGBA, paintOpacity?: number): string { + const hex = rgbaToHex(rgba); + const alpha = (rgba.a ?? 1) * (paintOpacity ?? 1); + if (alpha < 1) { + const r = Math.round(rgba.r * 255); + const g = Math.round(rgba.g * 255); + const b = Math.round(rgba.b * 255); + return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(2)})`; + } + return hex; +} + +/** + * Format fills (solid colors and gradients) for display + */ +function formatFills(fills: Paint[]): string { + const parts: string[] = []; + + for (const fill of fills) { + if (fill.type === 'SOLID' && fill.color) { + parts.push(`- Background: ${rgbaToCss(fill.color, fill.opacity)}`); + } else if (fill.type.startsWith('GRADIENT_') && fill.gradientStops) { + parts.push(`- Background: ${formatGradient(fill)}`); + } + } + + if (parts.length === 0) return ''; + return `\n**Fills:**\n${parts.join('\n')}`; +} + +/** + * Format gradient as CSS gradient string + */ +export function formatGradient(paint: Paint): string { + const stops = paint.gradientStops || []; + const stopsStr = stops + .map((s: GradientStop) => `${rgbaToCss(s.color)} ${Math.round(s.position * 100)}%`) + .join(', '); + + switch (paint.type) { + case 'GRADIENT_LINEAR': + return `linear-gradient(${stopsStr})`; + case 'GRADIENT_RADIAL': + return `radial-gradient(${stopsStr})`; + case 'GRADIENT_ANGULAR': + return `conic-gradient(${stopsStr})`; + case 'GRADIENT_DIAMOND': + return `radial-gradient(${stopsStr})`; // CSS has no diamond gradient; approximate as radial + default: + return `gradient(${stopsStr})`; + } +} + +/** + * Format strokes (borders) for display + */ +function formatStrokes(node: FigmaNode): string { + if (!node.strokes || node.strokes.length === 0) return ''; + + const parts: string[] = []; + for (const stroke of node.strokes) { + if (stroke.visible === false) continue; + if (stroke.type === 'SOLID' && stroke.color) { + const color = rgbaToCss(stroke.color, stroke.opacity); + const align = node.strokeAlign ? ` (${node.strokeAlign.toLowerCase()})` : ''; + const style = node.strokeDashes?.length ? 'dashed' : 'solid'; + + if (node.individualStrokeWeights) { + const { top, right, bottom, left } = node.individualStrokeWeights; + if (top > 0) parts.push(`- border-top: ${top}px ${style} ${color}${align}`); + if (right > 0) parts.push(`- border-right: ${right}px ${style} ${color}${align}`); + if (bottom > 0) parts.push(`- border-bottom: ${bottom}px ${style} ${color}${align}`); + if (left > 0) parts.push(`- border-left: ${left}px ${style} ${color}${align}`); + } else { + const weight = node.strokeWeight || 1; + parts.push(`- Border: ${weight}px ${style} ${color}${align}`); + } + } + } + + if (parts.length === 0) return ''; + return `\n**Strokes:**\n${parts.join('\n')}`; +} + +/** + * Format constraint as CSS hint + */ +function formatConstraintHint(constraint: string, axis: 'horizontal' | 'vertical'): string | null { + const hintMap: Record = { + LEFT: 'fixed left', + RIGHT: 'fixed right', + TOP: 'fixed top', + BOTTOM: 'fixed bottom', + CENTER: 'centered', + LEFT_RIGHT: 'fill container width', + TOP_BOTTOM: 'fill container height', + SCALE: 'scale with parent', + }; + const hint = hintMap[constraint]; + // Skip trivial defaults (LEFT for horizontal, TOP for vertical) + if (axis === 'horizontal' && constraint === 'LEFT') return null; + if (axis === 'vertical' && constraint === 'TOP') return null; + return hint || null; +} + /** * Format node type for display */ @@ -190,14 +1274,57 @@ function formatAlignment(alignment: string): string { return alignMap[alignment] || alignment.toLowerCase(); } +/** + * Format layout sizing mode as CSS hint + */ +function formatLayoutSizing(sizing: string | undefined, axis: 'width' | 'height'): string | null { + if (!sizing) return null; + switch (sizing) { + case 'FIXED': + return null; // Implied by the px dimensions already shown + case 'HUG': + return 'fit-content (hug contents)'; + case 'FILL': + return axis === 'width' ? '100% (fill container)' : 'flex: 1 (fill container)'; + default: + return null; + } +} + +/** + * Convert Figma image filters to CSS filter() string + */ +function imageFiltersToCss(filters: ImageFilters): string | null { + const parts: string[] = []; + if (filters.exposure && filters.exposure !== 0) { + parts.push(`brightness(${(1 + filters.exposure / 100).toFixed(2)})`); + } + if (filters.contrast && filters.contrast !== 0) { + parts.push(`contrast(${(1 + filters.contrast / 100).toFixed(2)})`); + } + if (filters.saturation && filters.saturation !== 0) { + parts.push(`saturate(${(1 + filters.saturation / 100).toFixed(2)})`); + } + if (filters.temperature && filters.temperature !== 0) { + // Temperature has no direct CSS equivalent; approximate via hue-rotate + parts.push(`hue-rotate(${Math.round(filters.temperature * 0.3)}deg)`); + } + return parts.length > 0 ? parts.join(' ') : null; +} + /** * Format typography info */ -function formatTypography(style: TypeStyle): string { +function formatTypography(style: TypeStyle, fontSubstitutions?: Map): string { const parts: string[] = []; if (style.fontFamily) { - parts.push(`- Font: ${style.fontFamily}`); + const substitute = fontSubstitutions?.get(style.fontFamily); + if (substitute) { + parts.push(`- Font: ${substitute} (original: ${style.fontFamily})`); + } else { + parts.push(`- Font: ${style.fontFamily}`); + } } if (style.fontSize) { parts.push(`- Size: ${style.fontSize}px`); @@ -205,6 +1332,9 @@ function formatTypography(style: TypeStyle): string { if (style.fontWeight) { parts.push(`- Weight: ${style.fontWeight}`); } + if (style.italic || style.fontStyle === 'italic') { + parts.push(`- Style: italic`); + } if (style.lineHeightPx) { parts.push(`- Line height: ${Math.round(style.lineHeightPx)}px`); } @@ -214,6 +1344,42 @@ function formatTypography(style: TypeStyle): string { if (style.textAlignHorizontal) { parts.push(`- Align: ${style.textAlignHorizontal.toLowerCase()}`); } + // Text transform + if (style.textCase && style.textCase !== 'ORIGINAL') { + const caseMap: Record = { + UPPER: 'uppercase', + LOWER: 'lowercase', + TITLE: 'capitalize', + SMALL_CAPS: 'small-caps', + SMALL_CAPS_FORCED: 'small-caps', + }; + parts.push(`- Text transform: ${caseMap[style.textCase] || style.textCase.toLowerCase()}`); + } + // Text decoration + if (style.textDecoration && style.textDecoration !== 'NONE') { + parts.push(`- Text decoration: ${style.textDecoration.toLowerCase()}`); + } + // Text auto-resize (sizing behavior) + if (style.textAutoResize && style.textAutoResize !== 'NONE') { + const resizeMap: Record = { + HEIGHT: 'fixed width, auto height', + WIDTH_AND_HEIGHT: 'auto width and height (hug contents)', + TRUNCATE: 'fixed size, truncate overflow', + }; + parts.push(`- Text sizing: ${resizeMap[style.textAutoResize] || style.textAutoResize}`); + } + // Text truncation + if (style.textTruncation === 'ENDING') { + if (style.maxLines && style.maxLines > 1) { + parts.push( + `- Truncation: ellipsis after ${style.maxLines} lines β†’ \`display: -webkit-box; -webkit-line-clamp: ${style.maxLines}; -webkit-box-orient: vertical; overflow: hidden\`` + ); + } else { + parts.push( + `- Truncation: ellipsis β†’ \`text-overflow: ellipsis; overflow: hidden; white-space: nowrap\`` + ); + } + } return parts.join('\n'); } @@ -224,17 +1390,23 @@ function formatTypography(style: TypeStyle): string { function formatEffect(effect: { type: string; radius: number; + color?: RGBA; offset?: { x: number; y: number }; + spread?: number; + blurType?: string; }): string { + const colorStr = effect.color ? ` ${rgbaToCss(effect.color)}` : ''; switch (effect.type) { case 'DROP_SHADOW': - return `Drop shadow (blur: ${effect.radius}px${effect.offset ? `, offset: ${effect.offset.x}x${effect.offset.y}` : ''})`; + return `Drop shadow: ${effect.offset?.x || 0}px ${effect.offset?.y || 0}px ${effect.radius}px${effect.spread ? ` spread ${effect.spread}px` : ''}${colorStr}`; case 'INNER_SHADOW': - return `Inner shadow (blur: ${effect.radius}px)`; + return `Inner shadow: ${effect.offset?.x || 0}px ${effect.offset?.y || 0}px ${effect.radius}px${colorStr}`; case 'LAYER_BLUR': - return `Blur (${effect.radius}px)`; + return effect.blurType === 'PROGRESSIVE' + ? `Progressive blur: ${effect.radius}px (approximate with gradient mask + filter: blur())` + : `Blur: ${effect.radius}px`; case 'BACKGROUND_BLUR': - return `Background blur (${effect.radius}px)`; + return `Background blur: ${effect.radius}px β†’ \`backdrop-filter: blur(${effect.radius}px)\``; default: return effect.type; } diff --git a/src/integrations/figma/parsers/font-checker.ts b/src/integrations/figma/parsers/font-checker.ts new file mode 100644 index 00000000..6da459a8 --- /dev/null +++ b/src/integrations/figma/parsers/font-checker.ts @@ -0,0 +1,103 @@ +/** + * Font Checker + * + * Detects fonts used in Figma designs and checks availability on Google Fonts. + * Suggests substitutions for commercial/proprietary fonts. + */ + +import { FONT_SUBSTITUTIONS, GOOGLE_FONTS } from '../data/google-fonts.js'; +import type { FigmaNode } from '../types.js'; + +export interface FontCheckResult { + fontFamily: string; + isGoogleFont: boolean; + suggestedAlternative?: string; +} + +/** + * Collect all unique font families from a Figma node tree + */ +export function collectFontFamilies(nodes: FigmaNode[]): Set { + const fonts = new Set(); + + function walk(node: FigmaNode) { + if (node.type === 'TEXT' && node.style?.fontFamily) { + fonts.add(node.style.fontFamily); + } + // Check style override table for mixed-style text nodes + if (node.styleOverrideTable) { + for (const override of Object.values(node.styleOverrideTable)) { + if (override.fontFamily) fonts.add(override.fontFamily); + } + } + if (node.children) { + for (const child of node.children) walk(child); + } + } + + for (const node of nodes) walk(node); + return fonts; +} + +/** + * Check each font against Google Fonts and find substitutions + */ +export function checkFonts(fontFamilies: Set): FontCheckResult[] { + const results: FontCheckResult[] = []; + for (const font of fontFamilies) { + const isGoogle = GOOGLE_FONTS.has(font); + const result: FontCheckResult = { fontFamily: font, isGoogleFont: isGoogle }; + if (!isGoogle) { + result.suggestedAlternative = findSubstitution(font) ?? undefined; + } + results.push(result); + } + return results; +} + +/** + * Find a Google Fonts substitute for a proprietary font + */ +function findSubstitution(fontFamily: string): string | null { + // Direct match + if (FONT_SUBSTITUTIONS.has(fontFamily)) { + return FONT_SUBSTITUTIONS.get(fontFamily)!; + } + + // Strip weight/style suffixes and try again + const normalized = fontFamily + .replace( + /\s*(Regular|Bold|Light|Medium|Thin|Black|Heavy|Book|Demi|Semi|SemiBold|ExtraBold|ExtraLight|UltraLight|Italic)\s*/gi, + ' ' + ) + .trim(); + if (normalized !== fontFamily && FONT_SUBSTITUTIONS.has(normalized)) { + return FONT_SUBSTITUTIONS.get(normalized)!; + } + + return null; +} + +/** + * Build markdown section describing font substitutions for the spec + */ +export function buildFontSubstitutionMarkdown(fontChecks: FontCheckResult[]): string { + const nonGoogle = fontChecks.filter((f) => !f.isGoogleFont); + if (nonGoogle.length === 0) return ''; + + const lines: string[] = ['## Font Substitutions\n']; + lines.push( + 'The following fonts from the Figma design are not available on Google Fonts and have been substituted:\n' + ); + lines.push('| Original Font | Substitute (Google Fonts) |'); + lines.push('|--------------|--------------------------|'); + for (const font of nonGoogle) { + lines.push( + `| ${font.fontFamily} | ${font.suggestedAlternative || '*(pick a similar Google Font)*'} |` + ); + } + lines.push( + '\nUse the substitute fonts in your implementation. Import them via Google Fonts `` tag.\n' + ); + return lines.join('\n'); +} diff --git a/src/integrations/figma/parsers/icon-collector.ts b/src/integrations/figma/parsers/icon-collector.ts new file mode 100644 index 00000000..fe8f4cdb --- /dev/null +++ b/src/integrations/figma/parsers/icon-collector.ts @@ -0,0 +1,123 @@ +/** + * Icon Collector + * + * Detects icon-like nodes in a Figma node tree β€” small VECTOR, INSTANCE, or FRAME + * nodes that are likely icons (typically ≀ 48px). These get exported as SVG via the + * Figma Images API so the coding agent can use them directly. + */ + +import type { FigmaNode } from '../types.js'; + +export interface IconInfo { + nodeId: string; + nodeName: string; + width: number; + height: number; + /** Sanitized filename for saving the SVG */ + filename: string; +} + +/** Max dimension (px) to consider a node an icon */ +const MAX_ICON_SIZE = 64; +/** Min dimension (px) to avoid collecting spacers or separators */ +const MIN_ICON_SIZE = 8; + +/** + * Collect icon-like nodes from the tree. + * Icons are small VECTOR/BOOLEAN_OPERATION/INSTANCE/FRAME nodes (≀ 64px). + * Deduplicates by name (same icon used multiple times β†’ export once). + */ +export function collectIconNodes(nodes: FigmaNode[], limit = 30): IconInfo[] { + const results: IconInfo[] = []; + const seenNames = new Set(); + + function walk(node: FigmaNode) { + if (node.visible === false) return; + + const bbox = node.absoluteBoundingBox; + if (bbox && isIconNode(node, bbox.width, bbox.height)) { + const name = sanitizeIconName(node.name); + if (!seenNames.has(name)) { + seenNames.add(name); + results.push({ + nodeId: node.id, + nodeName: node.name, + width: Math.round(bbox.width), + height: Math.round(bbox.height), + filename: `${name}.svg`, + }); + if (results.length >= limit) return; + } + } + + if (node.children) { + for (const child of node.children) { + if (results.length >= limit) return; + walk(child); + } + } + } + + for (const node of nodes) { + if (results.length >= limit) break; + walk(node); + } + return results; +} + +/** + * Determine if a node looks like an icon based on type, size, and name heuristics. + */ +function isIconNode(node: FigmaNode, width: number, height: number): boolean { + // Must be within icon size range + if (width < MIN_ICON_SIZE || height < MIN_ICON_SIZE) return false; + if (width > MAX_ICON_SIZE || height > MAX_ICON_SIZE) return false; + + // VECTOR and BOOLEAN_OPERATION are almost always icons or icon parts + if (node.type === 'VECTOR' || node.type === 'BOOLEAN_OPERATION') return true; + + // Small INSTANCE nodes are typically icon component instances + if (node.type === 'INSTANCE') return true; + + // Small FRAME/GROUP nodes named with icon-like patterns + if (node.type === 'FRAME' || node.type === 'GROUP') { + const nameLower = node.name.toLowerCase(); + if ( + nameLower.includes('icon') || + nameLower.includes('logo') || + nameLower.includes('arrow') || + nameLower.includes('chevron') || + nameLower.includes('close') || + nameLower.includes('menu') || + nameLower.includes('search') || + nameLower.includes('cart') || + nameLower.includes('account') || + nameLower.includes('user') || + nameLower.includes('check') || + nameLower.includes('star') || + nameLower.includes('heart') || + nameLower.includes('social') + ) { + return true; + } + // Small frames with vector children are likely icons + if (node.children?.some((c) => c.type === 'VECTOR' || c.type === 'BOOLEAN_OPERATION')) { + return true; + } + } + + return false; +} + +/** + * Sanitize a Figma node name into a valid filename. + */ +function sanitizeIconName(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'icon' + ); +} diff --git a/src/integrations/figma/parsers/image-collector.ts b/src/integrations/figma/parsers/image-collector.ts new file mode 100644 index 00000000..51b789e5 --- /dev/null +++ b/src/integrations/figma/parsers/image-collector.ts @@ -0,0 +1,53 @@ +/** + * Image Collector + * + * Detects nodes with IMAGE fills in a Figma node tree and collects + * their imageRef values for downloading. + */ + +import type { FigmaNode } from '../types.js'; + +export interface ImageRefInfo { + imageRef: string; + nodeId: string; + nodeName: string; + width: number; + height: number; +} + +/** + * Collect all image references from nodes with IMAGE paint fills + */ +export function collectImageRefs(nodes: FigmaNode[]): ImageRefInfo[] { + const results: ImageRefInfo[] = []; + const seen = new Set(); + + function walk(node: FigmaNode) { + if (node.fills) { + for (const fill of node.fills) { + if ( + fill.type === 'IMAGE' && + fill.visible !== false && + fill.imageRef && + !seen.has(fill.imageRef) + ) { + seen.add(fill.imageRef); + const bbox = node.absoluteBoundingBox; + results.push({ + imageRef: fill.imageRef, + nodeId: node.id, + nodeName: node.name, + width: bbox ? Math.round(bbox.width) : 0, + height: bbox ? Math.round(bbox.height) : 0, + }); + } + } + } + if (node.children) { + for (const child of node.children) walk(child); + } + } + + for (const node of nodes) walk(node); + return results; +} diff --git a/src/integrations/figma/parsers/plan-generator.ts b/src/integrations/figma/parsers/plan-generator.ts new file mode 100644 index 00000000..baa8b10d --- /dev/null +++ b/src/integrations/figma/parsers/plan-generator.ts @@ -0,0 +1,679 @@ +/** + * Figma Plan Generator + * + * Extracts structured section summaries from Figma nodes and generates + * a detailed IMPLEMENTATION_PLAN.md with concrete design values + * (dimensions, layout, colors, typography, images) per section. + */ + +import type { FigmaNode, Paint } from '../types.js'; +import { formatGradient, rgbaToCss, selectPrimaryFrames } from './design-spec.js'; + +// ─── Types ────────────────────────────────────────────────── + +export interface SectionSummary { + name: string; + dimensions: { width: number; height: number } | null; + layout: { + direction: 'horizontal' | 'vertical' | null; + gap: number | null; + rowGap: number | null; + padding: string | null; + mainAlign: string | null; + crossAlign: string | null; + wrap: boolean; + } | null; + background: string | null; + images: Array<{ + path: string; + scaleMode: string; + isHero: boolean; + dimensions: string; + }>; + compositeImage: { path: string; dimensions: string; hasTextOverlays?: boolean } | null; + icons: string[]; + typography: Array<{ + font: string; + size: number; + weight: number; + lineHeight: number | null; + color: string | null; + usage: string; + }>; + borderRadius: string | null; + overflow: string | null; + scrollBehavior: string | null; + effects: string[]; + border: string | null; + childCount: number; +} + +export interface SectionSummaryOptions { + imageFillUrls?: Record; + compositeImages?: Map; + /** Node IDs of composites that have text overlays (visual-dominant composites) */ + compositeTextOverlays?: Set; + exportedIcons?: Map; + fontSubstitutions?: Map; +} + +export interface FigmaPlanOptions { + fileName: string; + projectStack?: string | null; + imagesDownloaded?: boolean; + hasDesignTokens?: boolean; + iconFilenames?: string[]; + fontNames?: string[]; +} + +// ─── Alignment mapping ────────────────────────────────────── + +const ALIGN_MAP: Record = { + MIN: 'start', + CENTER: 'center', + MAX: 'end', + SPACE_BETWEEN: 'space-between', + BASELINE: 'baseline', +}; + +// ─── Section Summary Extraction ───────────────────────────── + +/** + * Extract structured summaries from top-level Figma sections. + * + * Walks direct FRAME children of CANVAS nodes (or specific requested nodes) + * and extracts layout, colors, typography, images, etc. from each section. + */ +export function extractSectionSummaries( + nodes: FigmaNode[], + options?: SectionSummaryOptions +): SectionSummary[] { + const sections: SectionSummary[] = []; + + for (const node of nodes) { + if (node.visible === false) continue; + + if (node.type === 'CANVAS' && node.children) { + // Page node β€” select only the primary frame to avoid duplication + const primaryChildren = selectPrimaryFrames(node.children); + for (const child of primaryChildren) { + if (child.type === 'FRAME' || child.type === 'COMPONENT' || child.type === 'SECTION') { + sections.push(extractOneSection(child, options)); + } + } + } else if (node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'SECTION') { + // Directly requested node β€” treat as a section + sections.push(extractOneSection(node, options)); + } + } + + return sections; +} + +function extractOneSection(node: FigmaNode, options?: SectionSummaryOptions): SectionSummary { + const bbox = node.absoluteBoundingBox; + + return { + name: node.name, + dimensions: bbox ? { width: Math.round(bbox.width), height: Math.round(bbox.height) } : null, + layout: extractLayout(node), + background: extractBackground(node), + images: extractImages(node, options), + compositeImage: extractCompositeImage(node, options), + icons: extractIcons(node, options), + typography: extractTypography(node, options), + borderRadius: extractBorderRadius(node), + overflow: node.clipsContent ? 'hidden' : null, + scrollBehavior: extractScrollBehavior(node), + effects: extractEffects(node), + border: extractBorder(node), + childCount: node.children?.filter((c) => c.visible !== false).length ?? 0, + }; +} + +// ─── Property Extractors ──────────────────────────────────── + +function extractLayout(node: FigmaNode): SectionSummary['layout'] { + if (!node.layoutMode || node.layoutMode === 'NONE') return null; + + const pt = node.paddingTop || 0; + const pr = node.paddingRight || 0; + const pb = node.paddingBottom || 0; + const pl = node.paddingLeft || 0; + const hasPadding = pt > 0 || pr > 0 || pb > 0 || pl > 0; + + return { + direction: node.layoutMode === 'HORIZONTAL' ? 'horizontal' : 'vertical', + gap: node.itemSpacing ?? null, + rowGap: node.counterAxisSpacing ?? null, + padding: hasPadding ? `${pt}px ${pr}px ${pb}px ${pl}px` : null, + mainAlign: node.primaryAxisAlignItems ? (ALIGN_MAP[node.primaryAxisAlignItems] ?? null) : null, + crossAlign: node.counterAxisAlignItems ? (ALIGN_MAP[node.counterAxisAlignItems] ?? null) : null, + wrap: node.layoutWrap === 'WRAP', + }; +} + +function extractBackground(node: FigmaNode): string | null { + if (!node.fills) return null; + + for (const fill of node.fills) { + if (fill.visible === false) continue; + if (fill.type === 'SOLID' && fill.color) { + return rgbaToCss(fill.color, fill.opacity); + } + if (fill.type.startsWith('GRADIENT_') && fill.gradientStops) { + return formatGradient(fill); + } + } + return null; +} + +function extractImages(node: FigmaNode, options?: SectionSummaryOptions): SectionSummary['images'] { + const images: SectionSummary['images'] = []; + walkForImages(node, images, options, 0); + return images; +} + +function walkForImages( + node: FigmaNode, + images: SectionSummary['images'], + options: SectionSummaryOptions | undefined, + depth: number +): void { + if (node.visible === false || depth > 6) return; + + if (node.fills) { + const imageFills = node.fills.filter( + (f) => f.type === 'IMAGE' && f.visible !== false && f.imageRef + ); + for (const fill of imageFills) { + const bbox = node.absoluteBoundingBox; + const ref = fill.imageRef ?? ''; + const hasDownload = ref && options?.imageFillUrls?.[ref]; + const path = hasDownload + ? `/images/${ref}.png` + : bbox + ? `placehold.co/${Math.round(bbox.width)}x${Math.round(bbox.height)}` + : 'placehold.co/400x300'; + const isHero = !!node.children?.length && !!bbox && bbox.height >= 400 && depth <= 1; + images.push({ + path, + scaleMode: fill.scaleMode || 'FILL', + isHero, + dimensions: bbox ? `${Math.round(bbox.width)}x${Math.round(bbox.height)}` : '', + }); + } + } + + if (node.children) { + for (const child of node.children) { + walkForImages(child, images, options, depth + 1); + } + } +} + +function extractCompositeImage( + node: FigmaNode, + options?: SectionSummaryOptions +): SectionSummary['compositeImage'] { + if (!options?.compositeImages) return null; + + // Check if this section itself is a composite + const path = options.compositeImages.get(node.id); + if (path) { + const bbox = node.absoluteBoundingBox; + return { + path, + dimensions: bbox ? `${Math.round(bbox.width)}x${Math.round(bbox.height)}` : '', + hasTextOverlays: options.compositeTextOverlays?.has(node.id), + }; + } + + // Check direct children for composites + if (node.children) { + for (const child of node.children) { + if (child.visible === false) continue; + const childPath = options.compositeImages.get(child.id); + if (childPath) { + const bbox = child.absoluteBoundingBox; + return { + path: childPath, + dimensions: bbox ? `${Math.round(bbox.width)}x${Math.round(bbox.height)}` : '', + hasTextOverlays: options.compositeTextOverlays?.has(child.id), + }; + } + } + } + return null; +} + +function extractIcons(node: FigmaNode, options?: SectionSummaryOptions): string[] { + if (!options?.exportedIcons) return []; + const icons: string[] = []; + walkForIcons(node, icons, options.exportedIcons, 0); + return icons; +} + +function walkForIcons( + node: FigmaNode, + icons: string[], + exportedIcons: Map, + depth: number +): void { + if (node.visible === false || depth > 6) return; + + const iconFile = exportedIcons.get(node.id); + if (iconFile) { + icons.push(`/images/icons/${iconFile}`); + } + + if (node.children) { + for (const child of node.children) { + walkForIcons(child, icons, exportedIcons, depth + 1); + } + } +} + +function extractTypography( + node: FigmaNode, + options?: SectionSummaryOptions +): SectionSummary['typography'] { + const styles: SectionSummary['typography'] = []; + walkForTypography(node, styles, options, 0); + return deduplicateTypography(styles); +} + +function walkForTypography( + node: FigmaNode, + styles: SectionSummary['typography'], + options: SectionSummaryOptions | undefined, + depth: number +): void { + if (node.visible === false || depth > 6) return; + + if (node.type === 'TEXT' && node.style) { + const s = node.style; + const fontName = options?.fontSubstitutions?.get(s.fontFamily) ?? s.fontFamily; + + // Extract text color from style fills + let color: string | null = null; + if (s.fills) { + const solidFill = s.fills.find( + (f: Paint) => f.visible !== false && f.type === 'SOLID' && f.color + ); + if (solidFill?.color) { + color = rgbaToCss(solidFill.color, solidFill.opacity); + } + } + + styles.push({ + font: fontName, + size: s.fontSize, + weight: s.fontWeight, + lineHeight: s.lineHeightPx ? Math.round(s.lineHeightPx) : null, + color, + usage: node.name || 'text', + }); + } + + if (node.children) { + for (const child of node.children) { + walkForTypography(child, styles, options, depth + 1); + } + } +} + +/** + * Deduplicate typography by font+size+weight, keeping the most descriptive usage label. + * Limit to 4 unique styles per section. + */ +function deduplicateTypography(styles: SectionSummary['typography']): SectionSummary['typography'] { + const seen = new Map(); + + for (const style of styles) { + const key = `${style.font}-${style.size}-${style.weight}`; + if (!seen.has(key)) { + seen.set(key, style); + } + } + + // Sort by size descending (largest = heading, smallest = detail) + const unique = [...seen.values()].sort((a, b) => b.size - a.size); + return unique.slice(0, 4); +} + +function extractBorderRadius(node: FigmaNode): string | null { + if (node.cornerRadius && node.cornerRadius > 0) { + return `${node.cornerRadius}px`; + } + if (node.rectangleCornerRadii) { + const [tl, tr, br, bl] = node.rectangleCornerRadii; + if (tl > 0 || tr > 0 || br > 0 || bl > 0) { + return `${tl}px ${tr}px ${br}px ${bl}px`; + } + } + return null; +} + +function extractScrollBehavior(node: FigmaNode): string | null { + if (!node.scrollBehavior || node.scrollBehavior === 'SCROLLS') return null; + if (node.scrollBehavior === 'FIXED') return 'fixed'; + if (node.scrollBehavior === 'STICKY_SCROLLS') return 'sticky'; + return null; +} + +function extractEffects(node: FigmaNode): string[] { + if (!node.effects) return []; + const results: string[] = []; + for (const effect of node.effects) { + if (effect.visible === false) continue; + if (effect.type === 'DROP_SHADOW' && effect.color && effect.offset) { + const color = rgbaToCss(effect.color); + results.push( + `box-shadow: ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px${effect.spread ? ` ${effect.spread}px` : ''} ${color}` + ); + } else if (effect.type === 'BACKGROUND_BLUR') { + results.push(`backdrop-filter: blur(${effect.radius}px)`); + } + } + return results; +} + +function extractBorder(node: FigmaNode): string | null { + if (!node.strokes || node.strokes.length === 0) return null; + + for (const stroke of node.strokes) { + if (stroke.visible === false) continue; + if (stroke.type === 'SOLID' && stroke.color) { + const color = rgbaToCss(stroke.color, stroke.opacity); + const weight = node.strokeWeight || 1; + const style = node.strokeDashes?.length ? 'dashed' : 'solid'; + + if (node.individualStrokeWeights) { + const { top, right, bottom, left } = node.individualStrokeWeights; + const sides: string[] = []; + if (top > 0) sides.push(`border-top: ${top}px ${style} ${color}`); + if (right > 0) sides.push(`border-right: ${right}px ${style} ${color}`); + if (bottom > 0) sides.push(`border-bottom: ${bottom}px ${style} ${color}`); + if (left > 0) sides.push(`border-left: ${left}px ${style} ${color}`); + return sides.join(', '); + } + return `${weight}px ${style} ${color}`; + } + } + return null; +} + +// ─── Plan Generation ──────────────────────────────────────── + +/** + * Generate a detailed IMPLEMENTATION_PLAN.md from section summaries. + */ +export function extractFigmaPlan(sections: SectionSummary[], options: FigmaPlanOptions): string { + const lines: string[] = []; + let taskNum = 0; + + lines.push(`# Implementation Plan`); + lines.push(``); + lines.push(`*Auto-generated from Figma design: "${options.fileName}"*`); + lines.push(``); + + // Universal design-to-code rules β€” these apply to EVERY design, not just this one + lines.push(`## Universal Stacking & Layout Rules`); + lines.push(``); + lines.push( + `> These rules apply to ALL sections. Follow them even when not explicitly repeated per task.` + ); + lines.push(``); + lines.push(`**Z-Index Stacking (mandatory):**`); + lines.push(`- Text and content elements ALWAYS get a higher z-index than visual/image layers`); + lines.push(`- Background images/decorations: z-index 0`); + lines.push(`- Content containers with text: position: relative; z-index: 1 (minimum)`); + lines.push(`- Never let a background or decorative image cover text at any viewport size`); + lines.push(``); + lines.push(`**Image Responsive Priority:**`); + lines.push( + `- Images of people (portraits, photos, team members) are the KEY visual element β€” NEVER hide them at any breakpoint` + ); + lines.push( + `- For person images: use \`object-position: top center\` to keep the face visible when the container crops` + ); + lines.push( + `- Large hero/feature images must scale proportionally, never \`display: none\` on smaller screens` + ); + lines.push(`- Decorative/background images may be hidden on mobile if needed for readability`); + lines.push(``); + lines.push(`**Sequential/Numbered Elements:**`); + lines.push( + `- When the design shows numbered steps (01, 02, 03) or ordered items (Step 1, Step 2, Step 3), preserve the EXACT layout order and spacing from the design` + ); + lines.push( + `- Do not reflow or reorder numbered elements β€” their visual position must match the design` + ); + lines.push(`- Maintain consistent spacing between sequential items as specified in the spec`); + lines.push(``); + lines.push(`## Tasks`); + + // Task 1: Setup + taskNum++; + lines.push(``); + lines.push(`### Task ${taskNum}: Project setup and design tokens`); + if (options.projectStack) { + lines.push(`- [ ] Verify ${options.projectStack} project structure`); + } else { + lines.push(`- [ ] Initialize project structure`); + } + if (options.hasDesignTokens) { + lines.push(`- [ ] Configure design tokens in @theme (colors, fonts, spacing from spec)`); + } else { + lines.push(`- [ ] Extract colors, fonts, and spacing from spec into CSS variables/@theme`); + } + if (options.fontNames && options.fontNames.length > 0) { + lines.push(`- [ ] Import Google Fonts: ${options.fontNames.join(', ')}`); + } else { + lines.push(`- [ ] Import fonts referenced in the spec via Google Fonts`); + } + if (options.imagesDownloaded) { + const totalIcons = options.iconFilenames?.length ?? 0; + const totalImages = sections.reduce((sum, s) => sum + s.images.length, 0); + const assetParts: string[] = []; + if (totalImages > 0) assetParts.push(`${totalImages} image(s) in public/images/`); + if (totalIcons > 0) assetParts.push(`${totalIcons} icon(s) in public/images/icons/`); + if (assetParts.length > 0) { + lines.push(`- [ ] Verify downloaded assets: ${assetParts.join(', ')}`); + } + } + + // Per-section tasks + for (const section of sections) { + taskNum++; + const dimStr = section.dimensions + ? ` (${section.dimensions.width} x ${section.dimensions.height}px)` + : ''; + lines.push(``); + lines.push(`### Task ${taskNum}: Implement ${section.name}${dimStr}`); + + // Layout subtask + if (section.layout) { + const parts: string[] = []; + parts.push(`flex ${section.layout.direction || 'column'}`); + if (section.layout.gap != null) parts.push(`gap ${section.layout.gap}px`); + if (section.layout.rowGap != null) parts.push(`row-gap ${section.layout.rowGap}px`); + if (section.layout.padding) parts.push(`padding ${section.layout.padding}`); + if (section.layout.wrap) parts.push('flex-wrap'); + if (section.layout.mainAlign) parts.push(`main-axis ${section.layout.mainAlign}`); + if (section.layout.crossAlign) parts.push(`cross-axis ${section.layout.crossAlign}`); + lines.push(`- [ ] Build layout: ${parts.join(', ')}`); + } else { + lines.push(`- [ ] Build ${section.name} layout structure`); + } + + // Background + if (section.background) { + lines.push(`- [ ] Background: ${section.background}`); + } + + // Composite image + if (section.compositeImage) { + const isHero = section.dimensions && section.dimensions.height >= 400; + if (section.compositeImage.hasTextOverlays) { + lines.push( + `- [ ] Add parallax hero background: ${section.compositeImage.path} (${section.compositeImage.dimensions}), full-bleed cover${isHero ? `, min-height ${section.dimensions?.height}px` : ''}` + ); + lines.push( + `- [ ] Layer ALL text content over hero background with z-index stacking (text is NOT in the composite image)` + ); + if (isHero) { + lines.push(`- [ ] Ensure visual layers create depth effect with text overlaid on top`); + } + } else { + lines.push( + `- [ ] Add composite background: ${section.compositeImage.path} (${section.compositeImage.dimensions}), cover${isHero ? `, min-height ${section.dimensions?.height}px` : ''}` + ); + lines.push(`- [ ] Position content over background with z-index layering`); + } + } else if (section.images.length > 0) { + // Detect overlapping full-width images that should create depth/parallax + const fullWidthImages = section.dimensions + ? section.images.filter((img) => { + const [w] = img.dimensions.split('x').map(Number); + return w >= (section.dimensions?.width ?? 0) * 0.8; + }) + : []; + + if (fullWidthImages.length > 1) { + lines.push( + `- [ ] Layer ${fullWidthImages.length} overlapping images to create depth/parallax effect` + ); + lines.push(` * Stack images using absolute positioning with ascending z-index`); + lines.push(` * Later images in the list are on top (higher z-index)`); + for (const img of fullWidthImages) { + const [w, h] = img.dimensions.split('x').map(Number); + const aspectRatio = w && h ? ` (aspect-ratio: ${(w / h).toFixed(2)})` : ''; + lines.push( + ` * ${img.path} (${img.dimensions})${aspectRatio}, ${img.scaleMode.toLowerCase()}` + ); + } + } + + for (const img of section.images) { + if (fullWidthImages.includes(img)) continue; // already listed above + if (img.isHero) { + lines.push( + `- [ ] Add hero background: ${img.path} (${img.dimensions}), cover, min-height from spec` + ); + lines.push(`- [ ] Position content over background with z-index layering`); + } else { + const [w, h] = img.dimensions.split('x').map(Number); + const aspectRatio = w && h ? `, aspect-ratio: ${(w / h).toFixed(2)}` : ''; + lines.push( + `- [ ] Add image: ${img.path} (${img.dimensions}${aspectRatio}), ${img.scaleMode.toLowerCase()}` + ); + } + // Flag high-priority images that must never be hidden + const imgName = img.path.toLowerCase(); + if ( + /person|people|portrait|photo|avatar|team|founder|headshot|model|woman|man|face/.test( + imgName + ) + ) { + lines.push( + ` * **CRITICAL**: Person image β€” must remain visible at ALL breakpoints (see Universal Rules above)` + ); + } + } + } + + // Icons + if (section.icons.length > 0) { + if (section.icons.length <= 3) { + lines.push(`- [ ] Add icons: ${section.icons.join(', ')}`); + } else { + lines.push(`- [ ] Add ${section.icons.length} icons from public/images/icons/`); + } + } + + // Typography + if (section.typography.length > 0) { + for (const t of section.typography) { + const parts: string[] = []; + parts.push(`${t.font} ${t.size}px`); + if (t.lineHeight) parts.push(`/${t.lineHeight}px`); + parts.push(`weight ${t.weight}`); + if (t.color) parts.push(`color ${t.color}`); + lines.push(`- [ ] ${t.usage}: ${parts.join(' ')}`); + } + } else { + lines.push(`- [ ] Apply typography and text content from spec`); + } + + // Border + if (section.border) { + lines.push(`- [ ] Border: ${section.border}`); + } + + // Border radius + if (section.borderRadius) { + lines.push(`- [ ] Border radius: ${section.borderRadius}`); + } + + // Effects + for (const effect of section.effects) { + lines.push(`- [ ] Effect: ${effect}`); + } + + // Scroll behavior + if (section.scrollBehavior) { + lines.push( + `- [ ] Scroll behavior: ${section.scrollBehavior} (position: ${section.scrollBehavior}${section.scrollBehavior === 'sticky' ? '; top: 0' : ''})` + ); + } + + // Responsive β€” include image-specific guidance when section has images + const hasPersonImage = section.images.some((img) => + /person|people|portrait|photo|avatar|team|founder|headshot|model|woman|man|face/.test( + img.path.toLowerCase() + ) + ); + if (hasPersonImage) { + lines.push( + `- [ ] Add responsive breakpoints β€” ensure person images remain visible at all sizes (never display:none; use object-position: top center for cropping)` + ); + } else { + lines.push(`- [ ] Add responsive breakpoints`); + } + } + + // Detect alternating image placement across content sections + if (sections.length >= 3) { + const sectionsWithImages = sections.filter((s) => s.images.length > 0 && !s.compositeImage); + if (sectionsWithImages.length >= 2) { + lines.push(``); + lines.push( + `> **Layout Pattern:** Content sections with images should use alternating left/right placement. Use \`flex-direction: row\` for odd sections and \`flex-direction: row-reverse\` for even sections (or match the spec's positioning).` + ); + } + } + + // Final polish task + if (sections.length > 1) { + taskNum++; + lines.push(``); + lines.push(`### Task ${taskNum}: Polish and cross-section integration`); + lines.push( + `- [ ] Compare implementation against frame screenshots in public/images/screenshots/` + ); + lines.push(`- [ ] Verify consistent spacing and alignment between sections`); + lines.push(`- [ ] Test responsive behavior across mobile/tablet/desktop`); + lines.push(`- [ ] Fix any visual discrepancies`); + } else if (sections.length === 1) { + taskNum++; + lines.push(``); + lines.push(`### Task ${taskNum}: Polish and verification`); + lines.push(`- [ ] Compare against frame screenshots in public/images/screenshots/`); + lines.push(`- [ ] Test responsive behavior`); + } + + lines.push(``); + return lines.join('\n'); +} diff --git a/src/integrations/figma/source.ts b/src/integrations/figma/source.ts index fe0c5d1e..0063599a 100644 --- a/src/integrations/figma/source.ts +++ b/src/integrations/figma/source.ts @@ -10,6 +10,10 @@ * - assets: Export icons and images (SVG, PNG, PDF) */ +import { createHash } from 'node:crypto'; +import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { type AuthMethod, BaseIntegration, @@ -17,16 +21,21 @@ import { type IntegrationResult, } from '../base.js'; import { figmaNodeToComponent, getFileExtension } from './parsers/component-code.js'; +import { extractContent, formatContentAsMarkdown } from './parsers/content-extractor.js'; import { - type ExtractedContent, - extractContent, - formatContentAsJson, - formatContentAsMarkdown, -} from './parsers/content-extractor.js'; -import { nodesToSpec } from './parsers/design-spec.js'; + type CompositeNodeResult, + collectCompositeNodes, + nodesToSpec, + selectPrimaryFrames, +} from './parsers/design-spec.js'; import { extractTokensFromFile, formatTokens } from './parsers/design-tokens.js'; +import { checkFonts, collectFontFamilies } from './parsers/font-checker.js'; +import { collectIconNodes } from './parsers/icon-collector.js'; +import { collectImageRefs } from './parsers/image-collector.js'; +import { extractSectionSummaries } from './parsers/plan-generator.js'; import type { FigmaFile, + FigmaImageFillsResponse, FigmaImagesResponse, FigmaIntegrationOptions, FigmaNode, @@ -44,6 +53,13 @@ export class FigmaIntegration extends BaseIntegration { private readonly API_BASE = 'https://api.figma.com/v1'; + /** + * Whether we detected a low-budget plan (starter/free with low limit-type). + * When true, skip non-essential API calls (icons, screenshots) to conserve + * the tiny request budget (6 req/month on starter-low). + */ + private lowBudget = false; + async fetch( identifier: string, options?: IntegrationOptions & FigmaIntegrationOptions @@ -83,23 +99,223 @@ export class FigmaIntegration extends BaseIntegration { let nodes: FigmaNode[]; let fileName: string; + let file: FigmaFile | null = null; - if (nodeIds.length > 0) { - // Fetch specific nodes + // Filter out canvas/page-level node IDs (e.g., "0:1") β€” these don't narrow + // the scope and prevent us from fetching the full file (needed for tokens). + const specificNodeIds = nodeIds.filter((id) => !id.match(/^0:\d+$/)); + + if (specificNodeIds.length > 0) { + // Fetch specific nodes (user selected specific frames/components) const response = await this.apiRequest( token, - `/files/${fileKey}/nodes?ids=${formatNodeIds(nodeIds)}` + `/files/${fileKey}/nodes?ids=${formatNodeIds(specificNodeIds)}` ); fileName = response.name; nodes = Object.values(response.nodes).map((n) => n.document); } else { - // Fetch entire file - const file = await this.apiRequest(token, `/files/${fileKey}`); + // Fetch entire file (reuse for tokens extraction below) + file = await this.apiRequest(token, `/files/${fileKey}`); fileName = file.name; nodes = file.document.children || []; } - const content = nodesToSpec(nodes, fileName); + // Check font availability + const fontFamilies = collectFontFamilies(nodes); + const fontChecks = checkFonts(fontFamilies); + + // Build font substitutions map for spec annotations + const fontSubstitutions = new Map(); + for (const check of fontChecks) { + if (!check.isGoogleFont && check.suggestedAlternative) { + fontSubstitutions.set(check.fontFamily, check.suggestedAlternative); + } + } + + // Collect image references from nodes + const imageRefs = collectImageRefs(nodes); + + // Fetch image fill download URLs (important β€” enables image downloads) + // This is the only additional API call beyond the file fetch β€” it's essential + // for downloading design images. Icons and screenshots are best-effort. + let imageFillUrls: Record = {}; + if (imageRefs.length > 0) { + try { + imageFillUrls = await this.fetchImageFillUrls(fileKey, token); + } catch { + // Image fill URL fetch failed β€” proceed without images + } + } + + // Collect icon-like nodes for SVG export + const iconNodes = collectIconNodes(nodes, 30); + let iconSvgUrls: Record = {}; + const exportedIcons = new Map(); + let frameScreenshots: Record = {}; + + // Detect composite visual groups early β€” needed to deduplicate with frame screenshots. + // Composites get a 2x render which is higher quality than the 1x frame screenshot, + // so we prefer the composite and skip the duplicate screenshot. + const compositeNodes: CompositeNodeResult[] = collectCompositeNodes(nodes); + const compositeIdSet = new Set(compositeNodes.map((c) => c.nodeId)); + const compositeImages = new Map(); + const compositeTextOverlays = new Set(); + let compositeRenderUrls: Record = {}; + + // The /images endpoint has very strict rate limits (~10 req/min). + // Icons + screenshots are best-effort: we combine all node IDs into + // a single /images call to minimize API usage. + // On low-budget plans (starter/free, 6 req/month), skip entirely to conserve budget. + if (!this.lowBudget) { + const topFrameIds = this.collectTopLevelFrameIds(nodes, 3); + // Deduplicate: skip frame screenshots for nodes that are also composites + const screenshotFrameIds = topFrameIds.filter((id) => !compositeIdSet.has(id)); + const iconIds = iconNodes.map((ic) => ic.nodeId); + const allRenderIds = [...iconIds, ...screenshotFrameIds]; + + if (allRenderIds.length > 0) { + // Delay after image fills endpoint to avoid bursting + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Fetch icon SVGs (single request, non-essential β€” won't retry on 429) + if (iconIds.length > 0) { + try { + const svgResponse = await this.apiRequest( + token, + `/images/${fileKey}?ids=${formatNodeIds(iconIds)}&format=svg`, + false + ); + if (!svgResponse.err && svgResponse.images) { + iconSvgUrls = svgResponse.images as Record; + for (const icon of iconNodes) { + if (iconSvgUrls[icon.nodeId]) { + exportedIcons.set(icon.nodeId, icon.filename); + } + } + } + } catch { + // Icon SVG export failed (likely rate limited) β€” proceed without + } + } + + // Fetch frame screenshots (single request, non-essential β€” won't retry on 429) + if (screenshotFrameIds.length > 0) { + try { + if (iconIds.length > 0) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + const screenshotResponse = await this.apiRequest( + token, + `/images/${fileKey}?ids=${formatNodeIds(screenshotFrameIds)}&format=png&scale=2`, + false + ); + if (!screenshotResponse.err) { + frameScreenshots = screenshotResponse.images; + } + } catch { + // Screenshot rendering failed β€” non-critical, proceed without + } + } + } + + // Fetch composite visual group renders (2x for higher quality) + if (compositeNodes.length > 0) { + const compositeIds = compositeNodes.map((c) => c.nodeId); + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const compositeResponse = await this.apiRequest( + token, + `/images/${fileKey}?ids=${formatNodeIds(compositeIds)}&format=png&scale=2`, + false // non-essential β€” won't retry on 429 + ); + if (!compositeResponse.err && compositeResponse.images) { + compositeRenderUrls = compositeResponse.images; + for (const comp of compositeNodes) { + if (compositeRenderUrls[comp.nodeId]) { + const safeName = comp.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-'); + compositeImages.set(comp.nodeId, `/images/composite-${safeName}.png`); + if (comp.hasTextOverlays) { + compositeTextOverlays.add(comp.nodeId); + } + } + } + } + } catch { + // Composite rendering failed β€” fall back to individual layer extraction + } + } + } + + const specOptions = { + fontSubstitutions, + imageFillUrls, + exportedIcons, + compositeImages: compositeImages.size > 0 ? compositeImages : undefined, + compositeTextOverlays: compositeTextOverlays.size > 0 ? compositeTextOverlays : undefined, + }; + const content = nodesToSpec(nodes, fileName, specOptions); + + // Extract structured section summaries for plan generation + const sectionSummaries = extractSectionSummaries(nodes, { + imageFillUrls, + compositeImages: compositeImages.size > 0 ? compositeImages : undefined, + compositeTextOverlays: compositeTextOverlays.size > 0 ? compositeTextOverlays : undefined, + exportedIcons: exportedIcons.size > 0 ? exportedIcons : undefined, + fontSubstitutions: fontSubstitutions.size > 0 ? fontSubstitutions : undefined, + }); + + // Extract tokens from the file data we already have (avoids re-fetching) + let tokensContent: string | undefined; + if (file) { + try { + const tokens = extractTokensFromFile(file); + const formatted = formatTokens(tokens, 'css'); + const totalCount = Object.values(tokens).reduce( + (sum, group) => sum + Object.keys(group).length, + 0 + ); + if (totalCount > 0) { + tokensContent = `# Design Tokens: ${fileName}\n\n\`\`\`css\n${formatted}\n\`\`\`\n`; + } + } catch { + // Token extraction failed β€” non-critical + } + } + + // Extract content structure from nodes we already have (avoids re-fetching) + let contentStructure: string | undefined; + try { + const extracted = extractContent(nodes, fileName); + const contentMd = formatContentAsMarkdown(extracted); + if (contentMd) { + // Keep only navigation and IA summary (compact version) + const contentLines = contentMd.split('\n'); + const summaryLines: string[] = []; + let inSection = false; + for (const line of contentLines) { + if ( + line.startsWith('## Navigation') || + line.startsWith('## Summary') || + line.startsWith('## Information Architecture') + ) { + inSection = true; + summaryLines.push(line); + } else if (line.startsWith('## ') && inSection) { + inSection = false; + } else if (inSection) { + summaryLines.push(line); + } + } + if (summaryLines.length > 0) { + contentStructure = summaryLines.join('\n'); + } + } + } catch { + // Content extraction failed β€” non-critical + } return { content, @@ -110,6 +326,22 @@ export class FigmaIntegration extends BaseIntegration { mode: 'spec', fileKey, nodeCount: nodes.length, + fontChecks, + imageRefs, + imageFillUrls, + imageCount: imageRefs.length, + frameScreenshots, + iconNodes, + iconSvgUrls, + tokensContent, + contentStructure, + compositeRenderUrls: + Object.keys(compositeRenderUrls).length > 0 ? compositeRenderUrls : undefined, + compositeNodes: compositeNodes.length > 0 ? compositeNodes : undefined, + compositeImages: compositeImages.size > 0 ? Object.fromEntries(compositeImages) : undefined, + compositeTextOverlays: + compositeTextOverlays.size > 0 ? [...compositeTextOverlays] : undefined, + sectionSummaries: sectionSummaries.length > 0 ? sectionSummaries : undefined, }, }; } @@ -333,6 +565,46 @@ ${formatted} }; } + /** + * Fetch download URLs for all image fills in a Figma file. + * Uses the /files/{key}/images endpoint which returns URLs for all imageRef values. + */ + private async fetchImageFillUrls( + fileKey: string, + token: string + ): Promise> { + const response = await this.apiRequest( + token, + `/files/${fileKey}/images` + ); + return response.meta?.images || {}; + } + + /** + * Collect top-level frame IDs for rendering as screenshots. + * Uses selectPrimaryFrames to avoid screenshotting duplicate frames. + * Returns up to `limit` frame IDs. + */ + private collectTopLevelFrameIds(nodes: FigmaNode[], limit: number): string[] { + const frameIds: string[] = []; + for (const node of nodes) { + if (node.type === 'CANVAS' && node.children) { + const primary = selectPrimaryFrames(node.children); + for (const child of primary) { + if (child.type === 'FRAME' && child.visible !== false) { + frameIds.push(child.id); + if (frameIds.length >= limit) return frameIds; + } + } + } else if (node.type === 'FRAME' && node.visible !== false) { + // When specific nodes were requested, they may already be frames + frameIds.push(node.id); + if (frameIds.length >= limit) return frameIds; + } + } + return frameIds; + } + /** * Extract text content and information architecture from Figma */ @@ -426,35 +698,194 @@ ${formatted} return results; } + // ============================================ + // Response cache β€” avoids repeated API calls during development/testing. + // Cached in ~/.ralph/figma-cache/ with a 1-hour TTL. + // On 429, falls back to stale cache if available. + // ============================================ + + private static readonly CACHE_DIR = join(homedir(), '.ralph', 'figma-cache'); + private static readonly CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + + private getCachePath(path: string): string { + const hash = createHash('sha256').update(path).digest('hex').slice(0, 16); + return join(FigmaIntegration.CACHE_DIR, `${hash}.json`); + } + + private readCache(path: string): { data: T; fresh: boolean } | null { + const cachePath = this.getCachePath(path); + // Use a single file descriptor for both stat and read to avoid TOCTOU race + let fd: number; + try { + fd = openSync(cachePath, 'r'); + } catch { + return null; + } + try { + const stat = fstatSync(fd); + const age = Date.now() - stat.mtimeMs; + const data = JSON.parse(readFileSync(fd, 'utf-8')) as T; + return { data, fresh: age < FigmaIntegration.CACHE_TTL_MS }; + } catch { + return null; + } finally { + closeSync(fd); + } + } + + private writeCache(path: string, data: unknown): void { + try { + // mkdirSync with recursive:true is safe even if dir already exists β€” no TOCTOU race + mkdirSync(FigmaIntegration.CACHE_DIR, { recursive: true }); + writeFileSync(this.getCachePath(path), JSON.stringify(data)); + } catch { + // Cache write failed β€” non-critical + } + } + /** - * Make an API request to Figma + * Make an API request to Figma with timeout, caching, and single retry on 429. + * @param essential - If true, retries once on 429. If false, throws immediately on 429. */ - private async apiRequest(token: string, path: string): Promise { - const response = await fetch(`${this.API_BASE}${path}`, { - headers: { - 'X-Figma-Token': token, - }, - }); + private async apiRequest(token: string, path: string, essential = true): Promise { + const timeoutMs = 30_000; + const maxAttempts = essential ? 2 : 1; // Only retry essential calls + const debug = !!process.env.RALPH_DEBUG; + + // Return fresh cache hit immediately (skip API call entirely) + const cached = this.readCache(path); + if (cached?.fresh) { + if (debug) { + console.log(` [figma debug] cache hit (fresh) for ${path.split('?')[0]}`); + } + return cached.data; + } - if (!response.ok) { - if (response.status === 401) { - this.error( - 'Invalid Figma token. Get a Personal Access Token from Figma settings and run:\n' + - 'ralph-starter config set figma.token ' + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + if (debug) { + console.log( + ` [figma debug] ${essential ? 'essential' : 'optional'} GET ${path} (attempt ${attempt + 1}/${maxAttempts})` ); } - if (response.status === 403) { - this.error('Access denied. Make sure your token has access to this file.'); + + let response: Response; + try { + response = await fetch(`${this.API_BASE}${path}`, { + headers: { 'X-Figma-Token': token }, + signal: controller.signal, + }); + } catch (err: unknown) { + clearTimeout(timer); + if (err instanceof Error && err.name === 'AbortError') { + // On timeout, try stale cache before failing + if (cached) { + if (debug) console.log(` [figma debug] timeout β€” using stale cache`); + console.log(' Using cached Figma data (API timed out)'); + return cached.data; + } + this.error( + `Figma API request timed out after ${timeoutMs / 1000}s. The file may be too large β€” try fetching specific frames with --figma-nodes.` + ); + } + throw err; + } finally { + clearTimeout(timer); } - if (response.status === 404) { - this.error('File not found. Check the file key or URL is correct.'); + + if (debug) { + const retryAfter = response.headers.get('retry-after'); + console.log( + ` [figma debug] ${response.status} ${response.statusText}${retryAfter ? ` (retry-after: ${retryAfter}s)` : ''}` + ); + } + + if (response.status === 429) { + // On rate limit, use stale cache if available (skip retry entirely) + if (cached) { + if (debug) console.log(` [figma debug] 429 β€” using stale cache`); + console.log(' Using cached Figma data (API rate limited)'); + return cached.data; + } + + // Extract rate limit diagnostic headers + const retryAfter = response.headers.get('retry-after'); + const planTier = response.headers.get('x-figma-plan-tier'); + const limitType = response.headers.get('x-figma-rate-limit-type'); + const retrySeconds = retryAfter ? Number.parseInt(retryAfter, 10) : null; + + // Flag low-budget plans so we skip non-essential calls later + if (limitType === 'low' || planTier === 'starter') { + this.lowBudget = true; + } + + if (debug) { + console.log( + ` [figma debug] plan=${planTier} limit-type=${limitType} retry-after=${retryAfter}s` + ); + } + + // CloudFront-level block: retry-after > 1 hour means CDN throttling, not transient rate limit. + // Retrying is pointless β€” fail fast with actionable advice. + if (retrySeconds && retrySeconds > 3600) { + const days = Math.ceil(retrySeconds / 86400); + const hints = [ + `Figma API blocked for ~${days} day(s) (CDN-level throttle).`, + planTier ? ` Plan tier: ${planTier} | Limit type: ${limitType || 'unknown'}` : '', + " This often happens with community files β€” the file owner's plan sets your rate limits.", + ' Workarounds:', + ' 1. Duplicate the file to your own Figma workspace (fixes owner-plan limits)', + ' 2. Use a VPN to get a fresh IP (bypasses CDN throttle)', + ' 3. Upgrade to a Figma paid plan with a Dev seat (10+ req/min)', + ] + .filter(Boolean) + .join('\n'); + this.error(hints); + } + + if (attempt + 1 < maxAttempts) { + // Wait and retry once for essential calls. + // IMPORTANT: respect Figma's retry-after header (typically 30-60s). + // Retrying too early resets the cooldown and makes things worse. + const waitMs = Math.min(retrySeconds ? retrySeconds * 1000 : 30_000, 60_000); + console.log( + ` Figma rate limit hit β€” waiting ${Math.ceil(waitMs / 1000)}s before retry...` + ); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + continue; + } + this.error( + `Figma API rate limit hit on ${path.split('?')[0]}. ` + + 'Wait 1-2 minutes before trying again.' + ); + } + + if (!response.ok) { + if (response.status === 401) { + this.error( + 'Invalid Figma token. Get a Personal Access Token from Figma settings and run:\n' + + 'ralph-starter config set figma.token ' + ); + } + if (response.status === 403) { + this.error('Access denied. Make sure your token has access to this file.'); + } + if (response.status === 404) { + this.error('File not found. Check the file key or URL is correct.'); + } + + const error = (await response.json().catch(() => ({}))) as { message?: string }; + this.error(`Figma API error: ${response.status} - ${error.message || response.statusText}`); } - const error = (await response.json().catch(() => ({}))) as { message?: string }; - this.error(`Figma API error: ${response.status} - ${error.message || response.statusText}`); + const data = (await response.json()) as T; + this.writeCache(path, data); + return data; } - return response.json() as Promise; + throw new Error('Figma API request failed after retries'); } getHelp(): string { @@ -516,6 +947,14 @@ Modes: assets Export icons and images with download scripts content Extract text content and IA for applying to existing templates +Rate Limits: + - Starter plan (Collab seat): 6 requests/month β€” upgrade for serious use + - Professional plan (Dev seat, $12/mo): 10-50 requests/min + - Responses are cached in ~/.ralph/figma-cache/ (1h TTL) + - On rate limit (429), stale cache is used automatically + - Community files use the file owner's plan limits, not yours + - Debug with: RALPH_DEBUG=1 ralph-starter run --from figma ... + Notes: - Figma file URLs can include node selections: ?node-id=X:Y - Asset export URLs expire after 30 days diff --git a/src/integrations/figma/types.ts b/src/integrations/figma/types.ts index daa96d79..25942a51 100644 --- a/src/integrations/figma/types.ts +++ b/src/integrations/figma/types.ts @@ -32,11 +32,15 @@ export interface FigmaNode { strokes?: Paint[]; strokeWeight?: number; strokeAlign?: 'INSIDE' | 'OUTSIDE' | 'CENTER'; + individualStrokeWeights?: { top: number; right: number; bottom: number; left: number }; + strokeDashes?: number[]; effects?: Effect[]; cornerRadius?: number; rectangleCornerRadii?: [number, number, number, number]; blendMode?: BlendMode; opacity?: number; + rotation?: number; + isMask?: boolean; layoutMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL'; itemSpacing?: number; paddingLeft?: number; @@ -46,6 +50,32 @@ export interface FigmaNode { primaryAxisAlignItems?: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'; counterAxisAlignItems?: 'MIN' | 'CENTER' | 'MAX' | 'BASELINE'; layoutGrow?: number; + // Auto-layout sizing (how this node sizes itself in its parent) + layoutSizingHorizontal?: 'FIXED' | 'HUG' | 'FILL'; + layoutSizingVertical?: 'FIXED' | 'HUG' | 'FILL'; + // Flex wrap for auto-layout containers + layoutWrap?: 'NO_WRAP' | 'WRAP'; + // Whether this child is flow-positioned or absolutely-positioned in parent + layoutPositioning?: 'AUTO' | 'ABSOLUTE'; + // Row gap for wrapped auto-layout (gap between rows) + counterAxisSpacing?: number; + // Self-alignment override in parent's cross-axis + layoutAlign?: 'INHERIT' | 'STRETCH' | 'MIN' | 'CENTER' | 'MAX'; + // Responsive size constraints + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + // Overflow clipping + clipsContent?: boolean; + // Scroll/sticky behavior + scrollBehavior?: 'SCROLLS' | 'FIXED' | 'STICKY_SCROLLS'; + // Scroll direction for scrollable containers + overflowDirection?: + | 'NONE' + | 'HORIZONTAL_SCROLLING' + | 'VERTICAL_SCROLLING' + | 'HORIZONTAL_AND_VERTICAL_SCROLLING'; componentPropertyDefinitions?: Record; componentPropertyReferences?: Record; // Text-specific @@ -122,6 +152,24 @@ export interface Paint { gradientStops?: GradientStop[]; scaleMode?: 'FILL' | 'FIT' | 'TILE' | 'STRETCH'; imageRef?: string; + /** 2Γ—3 affine transform for image crop/position within the fill: [[a, b, tx], [c, d, ty]] */ + imageTransform?: Transform; + /** Image rotation in degrees */ + rotation?: number; + /** Image adjustment filters (exposure, contrast, saturation, etc.) */ + filters?: ImageFilters; + /** Scale factor for TILE mode */ + scalingFactor?: number; +} + +export interface ImageFilters { + exposure?: number; + contrast?: number; + saturation?: number; + temperature?: number; + tint?: number; + highlights?: number; + shadows?: number; } export interface RGBA { @@ -165,6 +213,8 @@ export interface Effect { blendMode?: BlendMode; offset?: Vector; spread?: number; + /** Blur variant: NORMAL (uniform) or PROGRESSIVE (directional gradient blur) */ + blurType?: 'NORMAL' | 'PROGRESSIVE'; } export interface TypeStyle { @@ -187,6 +237,10 @@ export interface TypeStyle { lineHeightPercent?: number; lineHeightPercentFontSize?: number; lineHeightUnit?: 'PIXELS' | 'FONT_SIZE_%' | 'INTRINSIC_%'; + fontStyle?: string; + textTruncation?: 'DISABLED' | 'ENDING'; + maxLines?: number; + hyperlink?: { type: 'URL' | 'NODE'; url?: string; nodeID?: string }; } export interface ComponentPropertyDefinition { @@ -249,6 +303,12 @@ export interface FigmaImagesResponse { err: string | null; } +export interface FigmaImageFillsResponse { + meta: { + images: Record; + }; +} + export interface FigmaStylesResponse { status: number; error: boolean; diff --git a/src/integrations/figma/utils/url-parser.ts b/src/integrations/figma/utils/url-parser.ts index 38f83ec9..4ee4b077 100644 --- a/src/integrations/figma/utils/url-parser.ts +++ b/src/integrations/figma/utils/url-parser.ts @@ -56,9 +56,14 @@ export function parseFigmaUrl(input: string): FigmaUrlParts { // Extract node IDs if (urlMatch[3]) { - // Node IDs can be comma-separated and URL-encoded + // Node IDs can be comma-separated and URL-encoded. + // Figma URLs use dashes (0-1) but the REST API expects colons (0:1). const nodeIdStr = decodeURIComponent(urlMatch[3]); - result.nodeIds = nodeIdStr.split(',').map((id) => id.trim()); + result.nodeIds = nodeIdStr.split(',').map((id) => { + const trimmed = id.trim(); + // Convert "0-1" β†’ "0:1" (only for simple N-N patterns from URLs) + return /^\d+-\d+$/.test(trimmed) ? trimmed.replace('-', ':') : trimmed; + }); } return result; diff --git a/src/loop/agents.ts b/src/loop/agents.ts index 930c9b2c..e21ce778 100644 --- a/src/loop/agents.ts +++ b/src/loop/agents.ts @@ -16,6 +16,8 @@ export interface AgentRunOptions { cwd: string; auto?: boolean; maxTurns?: number; + /** Model to use (e.g., 'claude-sonnet-4-5-20250929'). Passed via --model to supported agents. */ + model?: string; /** Stream output to console in real-time */ streamOutput?: boolean; /** Callback for each line of output */ @@ -117,6 +119,10 @@ export async function runAgent( if (options.auto) { args.push('--dangerously-skip-permissions'); } + // Model override (e.g., 'claude-sonnet-4-5-20250929') + if (options.model) { + args.push('--model', options.model); + } // Streaming JSONL output for real-time progress args.push('--verbose'); args.push('--output-format', 'stream-json'); diff --git a/src/loop/context-builder.ts b/src/loop/context-builder.ts index e5968fe4..42fb6ded 100644 --- a/src/loop/context-builder.ts +++ b/src/loop/context-builder.ts @@ -37,6 +37,12 @@ export interface ContextBuildOptions { iterationLog?: string; /** Source integration type (github, linear, figma, notion, file) for source-specific prompts */ sourceType?: string; + /** Whether Figma images were downloaded to public/images/ */ + figmaImagesDownloaded?: boolean; + /** Font substitutions applied (original β†’ Google Fonts alternative) */ + figmaFontSubstitutions?: Array<{ original: string; substitute: string }>; + /** Path to design reference image (relative to cwd) β€” becomes the PRIMARY visual source of truth */ + designImagePath?: string; } export interface BuiltContext { @@ -249,7 +255,7 @@ Technology gotchas (CRITICAL β€” follow these exactly): - Do NOT run \`npm run build\` or \`npm run dev\` manually β€” the loop handles validation automatically (lint between tasks, full build at the end). Design quality (IMPORTANT): -- FIRST PRIORITY: If specs/ contains a design specification, follow it EXACTLY β€” match the described colors, spacing, layout, typography, and visual style faithfully. The spec is the source of truth. +${opts.designImagePath ? `- **DESIGN REFERENCE IMAGE** (THIS IS YOUR #1 SOURCE OF TRUTH): A screenshot of the exact target design has been saved to \`${opts.designImagePath}\`. Your VERY FIRST action before writing ANY code must be to use the Read tool to open this image and study it carefully. This image shows EXACTLY what the final result must look like β€” every section, every image position, every text placement, every visual detail. The text spec in specs/ provides supplementary data (exact hex colors, font names, spacing values), but the IMAGE is what you must match visually. After implementing each major section, re-open the design image and compare it to your code. If something looks different from the image, fix it immediately.` : `- FIRST PRIORITY: If specs/ contains a design specification, follow it EXACTLY β€” match the described colors, spacing, layout, typography, and visual style faithfully. The spec is the source of truth.`} - If no spec exists, choose ONE clear design direction (bold/minimal/retro/editorial/playful) and commit to it - Use a specific color palette with max 3-4 colors, not rainbow gradients - Avoid generic AI aesthetics: no purple-blue gradient backgrounds/text, no glass morphism/neumorphism, no Inter/Roboto defaults β€” pick distinctive typography (e.g. DM Sans, Playfair Display, Space Mono) @@ -257,14 +263,62 @@ ${ opts.sourceType === 'figma' ? ` Figma-to-code guidelines (CRITICAL β€” your spec comes from a Figma design file): -- AUTO-LAYOUT β†’ FLEXBOX/GRID: "horizontal" = \`display: flex; flex-direction: row\`. "Vertical" = \`flex-direction: column\`. "Wrap" = \`flex-wrap: wrap\`. Use gap for item spacing. -- COLORS: If the spec includes a "Design Tokens" section with CSS variables, put them in \`@theme inline { }\` and use Tailwind utilities (e.g. \`bg-primary\`, \`text-accent/80\`). If not, extract colors from the spec and create the @theme block yourself. -- TYPOGRAPHY: Use the EXACT font names from the spec. Add Google Fonts import if needed. Do NOT substitute with generic fonts. -- CONSTRAINTS: "Fill container" = \`width: 100%\` or \`flex: 1\`. "Fixed" = exact px value. "Hug contents" = \`width: fit-content\`. -- SPACING: Apply padding/margin exactly as specified in the spec (top right bottom left notation). +<<<<<<< HEAD +- AUTO-LAYOUT β†’ FLEXBOX/GRID: "horizontal" = \`display: flex; flex-direction: row\`. "Vertical" = \`flex-direction: column\`. "Wrap" = \`flex-wrap: wrap\`. Use \`gap\` for item spacing. Never use margin for gap spacing in flex containers. +- COLORS: Match colors EXACTLY as specified β€” copy hex/rgba values from design tokens verbatim. For opacity, use the exact value (e.g. \`/80\` not \`/75\`). If the spec includes a "Design Tokens" section with CSS variables, put them in \`@theme inline { }\` and use Tailwind utilities (e.g. \`bg-primary\`, \`text-accent/80\`). If not, extract colors from the spec and create the @theme block yourself. +- TYPOGRAPHY: ${opts.figmaFontSubstitutions?.length ? 'The spec includes a "Font Substitutions" section β€” use the substitute Google Fonts listed there. Import them via Google Fonts `` tag.' : 'Use the EXACT font names from the spec. Add Google Fonts `` import in the HTML head. Do NOT substitute with generic fonts.'} Apply exact font-size, font-weight, line-height, and letter-spacing values from the spec β€” do NOT round or approximate these values. +- SIZING: The spec shows explicit sizing modes per element: "fill container" = \`flex: 1\` (or \`width: 100%\` for block elements). "Hug contents" = \`width: fit-content\`. "Fixed" = exact px dimensions. These are more reliable than constraints β€” always use them when present. +- WRAP: When the spec shows "Wrap: flex-wrap: wrap", add \`flex-wrap: wrap\`. Use the "Row gap" value for \`row-gap\` in wrapped layouts. +- ABSOLUTE CHILDREN: Elements marked "Positioning: absolute" are absolutely positioned within their auto-layout parent. Use \`position: absolute\` with coordinates from the spec. +- OVERFLOW: "Overflow: hidden" = \`overflow: hidden\`. "Scrollable" = \`overflow-x: auto\` or \`overflow-y: auto\` as indicated. +- STICKY/FIXED: "Scroll behavior: position: sticky" = \`position: sticky; top: 0\`. "position: fixed" = \`position: fixed\`. +- SIZE CONSTRAINTS: Apply \`min-width\`/\`max-width\`/\`min-height\`/\`max-height\` EXACTLY as shown in the spec for responsive behavior. +- SPACING: Apply padding and margin EXACTLY as specified (top right bottom left notation). Do not simplify shorthand if values differ per side. Match gap values exactly. +- BORDER RADIUS: Use exact border-radius values from the spec (e.g., 12px means \`rounded-[12px]\` in Tailwind, not \`rounded-xl\`). For per-corner radii, apply each corner value individually. +- BORDERS: When the spec shows individual border sides (border-top, border-right, etc.), apply them individually β€” do NOT combine into shorthand \`border\`. For dashed borders, use \`border-style: dashed\`. +- SHADOWS: Reproduce box-shadow values exactly (x, y, blur, spread, color) from the design tokens. Do not substitute with generic Tailwind shadow utilities unless they match the exact spec values. +- EFFECTS: Implement ALL visible effects (blur, shadow, opacity). For \`backdrop-filter: blur()\`, apply exact values. Do not skip subtle effects like background blur or low-opacity overlays. +- ROTATION: Apply \`transform: rotate(Xdeg)\` exactly as specified. Set \`transform-origin\` if the element is positioned. +- IMAGE FILTERS: When the spec includes a "Filters" line (e.g. \`filter: brightness(1.10) contrast(0.90)\`), apply it to the image element exactly. - RESPONSIVE: The Figma spec shows a single breakpoint. Add responsive breakpoints: stack columns on mobile, adjust font sizes, add appropriate padding. -- IMAGES: Use placeholder images from https://placehold.co with exact dimensions from the spec (e.g. \`https://placehold.co/400x300\`). -- FIDELITY: Match the design EXACTLY β€” don't add extra elements, animations, or decorations not in the spec. +${ + opts.figmaImagesDownloaded + ? `- IMAGES: Design images have been downloaded to \`public/images/\`. Use the local file paths referenced in the spec (e.g. \`/images/abc123.png\`). For any missing images, fall back to \`https://placehold.co\` with exact dimensions. +- IMAGE IMPLEMENTATION: The spec marks each image with its Figma element name, scale mode, and CSS hints. Follow these rules: + * "Image (Background)" or "Image (Hero Background)" = container with content on top. Use \`position: relative\` on container, then either: + - CSS \`background-image\` + \`background-size: cover\`, OR + - \`\` with \`position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0\` + content with \`position: relative; z-index: 1\` + * Scale mode FILL = \`object-fit: cover\` (crop to fill). FIT = \`object-fit: contain\`. STRETCH = \`object-fit: fill\` + * For hero sections ("Hero Background"): the image MUST fill the entire section. Use \`min-height\` from the spec or \`100vh\`. Never let the image leave gaps. + * Match the image to the correct element by checking the "Element:" name in the spec +- IMAGE CROPPING: When the spec includes a "Crop position" line (e.g. \`object-position: 30% 20%\` or \`background-position: 30% 20%\`), you MUST apply it. This controls which part of the image is visible when cropped by \`object-fit: cover\`. Without it, CSS defaults to centering which may show the wrong region of the image. +- ICONS: Icon SVGs have been downloaded to \`public/images/icons/\`. The spec marks each icon with an "Icon (SVG)" section. Use \`\` or inline the SVG for color control. Size icons to match the spec dimensions.` + : '- IMAGES: Use placeholder images from https://placehold.co with exact dimensions from the spec (e.g. `https://placehold.co/400x300`). Match the aspect ratio exactly.\n- ICONS: If the spec references icon SVGs in `/images/icons/`, use those paths. Otherwise, use a popular icon library (Lucide, Heroicons) to match the icon intent described in the spec.' +} +- PARALLAX/LAYERED HEROES: When the spec describes a "Composite Background (visual layers only β€” text NOT included)", this means overlapping visual layers (mountains, gradients, photos) rendered as one image WITHOUT text baked in. Implement as: + * Container: \`position: relative; overflow: hidden; min-height: [spec value]\` + * Background: \`\` or \`background-image\` with \`position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0\` + * ALL text/content elements: \`position: relative; z-index: 1\` to layer OVER the background + * The text elements in the spec that follow the composite section are the overlays β€” place them on top +- ALTERNATING CONTENT SECTIONS: When numbered sections (01, 02, 03) have images alternating left/right: + * Use \`flex-direction: row\` for odd sections, \`flex-direction: row-reverse\` for even (or vice versa based on the spec) + * Maintain consistent image dimensions across all sections (match the spec dimensions exactly) + * Use consistent gap/spacing between text block and image across all sections + * The large background numbers (01, 02, 03) should use \`position: absolute\` with large font-size and low opacity +- INFERRED LAYOUT: When the spec shows "Inferred Layout" (detected from positions, not Figma auto-layout), treat these CSS hints as reliable β€” they were algorithmically derived from exact pixel positions in the design. Use the suggested \`flex-direction\`, \`gap\`, \`padding\`, and \`justify\` values. +- POSITIONING: The spec includes (x, y) positions for each element. Use these for: + * Elements that overlap or layer on top of each other (use \`position: absolute\` + \`top/left\` or \`inset\`) + * Verifying element order and spacing within flex/grid containers + * The spec includes \`z-index\` comments for overlapping siblings β€” apply them as CSS z-index values +- NAVIGATION: If the design has a fixed header/nav bar, implement it with \`position: fixed; top: 0; width: 100%; z-index: 50\` and add \`scroll-padding-top\` on \`\` equal to the nav height so anchor links don't hide content behind the nav. For scroll-linked navigation (active section highlighting), use \`IntersectionObserver\` to detect which section is in view. +- FIDELITY: Match the design EXACTLY β€” pixel-perfect implementation is the goal. Do not add extra elements, animations, hover effects, or decorations not described in the spec. Do not "improve" the design. The Figma spec is the single source of truth. +- LAYER ORDER: The spec includes z-index comments (back/middle/front) for overlapping elements. Implement stacking with CSS z-index. Later elements in the spec = higher z-index = rendered on top. +- VISUAL REFERENCE (CRITICAL): ${opts.designImagePath ? `The design reference image at \`${opts.designImagePath}\` is your PRIMARY visual source of truth β€” read it FIRST and refer back to it constantly.` : ''} Frame screenshots from the Figma design are saved in \`public/images/screenshots/\`. You MUST use the Read tool to open these PNG files and visually inspect them BEFORE writing code. These are the ground truth for what the final result should look like. After implementing each major section, open the ${opts.designImagePath ? 'design reference image' : 'screenshot'} again and compare it to your code. Pay close attention to: + * Image positioning, cropping, and aspect ratios + * Overlapping/layered elements and their visual stacking + * Text placement relative to background images + * Overall composition and visual hierarchy + * Element spacing and alignment ` : '' }`; diff --git a/src/loop/cost-tracker.ts b/src/loop/cost-tracker.ts index bf88555d..b9473e49 100644 --- a/src/loop/cost-tracker.ts +++ b/src/loop/cost-tracker.ts @@ -89,11 +89,30 @@ export interface CostTrackerStats { iterations: IterationCost[]; } +export interface PlanBudget { + name: string; + /** Monthly spending limit in USD (0 = unlimited/pay-as-you-go) */ + monthlyLimit: number; +} + +/** Known plan budgets (approximate monthly API spending limits) */ +export const KNOWN_PLANS: Record = { + max: { name: 'Claude Max', monthlyLimit: 200 }, + 'claude-max': { name: 'Claude Max', monthlyLimit: 200 }, + pro: { name: 'Claude Pro', monthlyLimit: 100 }, + 'claude-pro': { name: 'Claude Pro', monthlyLimit: 100 }, + team: { name: 'Claude Team', monthlyLimit: 150 }, + 'claude-team': { name: 'Claude Team', monthlyLimit: 150 }, + api: { name: 'API (pay-as-you-go)', monthlyLimit: 0 }, +}; + export interface CostTrackerConfig { model: string; maxIterations?: number; /** Maximum cost in USD before the loop should stop (0 = unlimited) */ maxCost?: number; + /** Plan budget for percentage display */ + planBudget?: PlanBudget; } /** @@ -310,6 +329,16 @@ export class CostTracker { }; } + /** + * Get plan usage percentage (null if no plan budget configured) + */ + getPlanPercentage(): string | null { + if (!this.config.planBudget?.monthlyLimit) return null; + const total = this.iterations.reduce((sum, i) => sum + i.cost.totalCost, 0); + const pct = (total / this.config.planBudget.monthlyLimit) * 100; + return pct.toFixed(1); + } + /** * Format stats for CLI display */ @@ -325,6 +354,13 @@ export class CostTracker { `Cost: ${formatCost(stats.totalCost.totalCost)} (${formatCost(stats.avgCostPerIteration.totalCost)}/iteration avg)`, ]; + if (this.config.planBudget?.monthlyLimit) { + const pct = this.getPlanPercentage(); + lines.push( + `Plan usage: ${pct}% of ${this.config.planBudget.name} ($${this.config.planBudget.monthlyLimit}/mo)` + ); + } + if (stats.totalCacheSavings > 0) { lines.push(`Cache savings: ${formatCost(stats.totalCacheSavings)}`); } diff --git a/src/loop/executor.ts b/src/loop/executor.ts index e1bb5a97..724b4489 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -236,12 +236,16 @@ export interface LoopOptions { contextBudget?: number; // Max input tokens per iteration (0 = unlimited) validationWarmup?: number; // Skip validation until N tasks completed (for greenfield builds) maxCost?: number; // Maximum cost in USD before stopping (0 = unlimited) + planBudget?: import('./cost-tracker.js').PlanBudget; // Plan budget for % display agentTimeout?: number; // Agent timeout in milliseconds (default: 300000 = 5 min) initialValidationFeedback?: string; // Pre-populate with errors (used by `fix` command) maxSkills?: number; // Cap skills included in prompt (default: 5) skipPlanInstructions?: boolean; // Skip IMPLEMENTATION_PLAN.md rules in preamble (fix --design) fixMode?: 'design' | 'scan' | 'custom'; // Display mode for fix command headers taskTitle?: string; // Human-readable task title (from issue/spec) for display + figmaImagesDownloaded?: boolean; // Whether Figma images were downloaded to public/images/ + figmaFontSubstitutions?: Array<{ original: string; substitute: string }>; // Font substitutions applied + designImagePath?: string; // Path to design reference image (relative to cwd) for pixel-perfect matching } export interface LoopResult { @@ -486,6 +490,7 @@ export async function runLoop(options: LoopOptions): Promise { model: options.model || 'claude-3-sonnet', maxIterations: maxIterations, maxCost: options.maxCost, + planBudget: options.planBudget, }) : null; @@ -734,7 +739,9 @@ export async function runLoop(options: LoopOptions): Promise { ? ` (${options.model.replace('claude-', '').replace('gpt-', '')})` : ''; const costSoFar = costTracker - ? ` β”‚ ${formatCost(costTracker.getStats().totalCost.totalCost)}` + ? ` β”‚ ${formatCost(costTracker.getStats().totalCost.totalCost)}${ + costTracker.getPlanPercentage() ? ` (${costTracker.getPlanPercentage()}%)` : '' + }` : ''; const subtitleSuffix = `${modelShort}${costSoFar}`; if (currentTask && totalTasks > 0) { @@ -817,6 +824,9 @@ export async function runLoop(options: LoopOptions): Promise { skipPlanInstructions: options.skipPlanInstructions, iterationLog, sourceType: options.sourceType, + figmaImagesDownloaded: options.figmaImagesDownloaded, + figmaFontSubstitutions: options.figmaFontSubstitutions, + designImagePath: options.designImagePath, }); const iterationTask = builtContext.prompt; @@ -846,6 +856,7 @@ export async function runLoop(options: LoopOptions): Promise { task: iterationTask, cwd: options.cwd, auto: options.auto, + model: options.model, // maxTurns removed - was causing issues, match wizard behavior streamOutput: !!process.env.RALPH_DEBUG, // Show raw JSON when debugging timeoutMs: options.agentTimeout, diff --git a/src/skills/auto-install.ts b/src/skills/auto-install.ts index 84c00b22..7ef2ba88 100644 --- a/src/skills/auto-install.ts +++ b/src/skills/auto-install.ts @@ -42,12 +42,20 @@ const WEB_NEGATIVE_KEYWORDS = [ 'flutter', 'swift', 'kotlin', + 'game', ]; -function buildSkillQueries(task: string): string[] { +function buildSkillQueries(task: string, sourceType?: string): string[] { const queries = new Set(); const text = task.toLowerCase(); + // Source-based skills (triggered by --from flag, not task text) + if (sourceType === 'figma') { + queries.add('figma'); + queries.add('figma implement design'); + queries.add('frontend design'); + } + // Framework-specific skills if (text.includes('astro')) queries.add('astro'); if (text.includes('react') || text.includes('jsx')) { @@ -74,7 +82,18 @@ function buildSkillQueries(task: string): string[] { } if (text.includes('design') || text.includes('ui') || text.includes('ux')) { - queries.add('ui design'); + // Skip generic "ui design" query when sourceType is set β€” the source-specific + // queries above are more targeted and avoid pulling in irrelevant results + // like game-ui-design, android-design-guidelines, etc. + if (!sourceType) { + queries.add('ui design'); + } + } + + // Also pick up figma from task text (in addition to --from flag) + if (text.includes('figma') && sourceType !== 'figma') { + queries.add('figma'); + queries.add('figma implement design'); } // CSS/styling tasks get design skills @@ -111,16 +130,41 @@ function buildSkillQueries(task: string): string[] { return Array.from(queries); } +/** Skills for non-web platforms β€” exclude from web/figma tasks unless explicitly requested */ +const PLATFORM_SKILLS = [ + 'android', + 'ios', + 'macos', + 'react-native', + 'expo', + 'flutter', + 'swift', + 'kotlin', +]; + /** * Check if a skill is relevant to the given task. - * Reuses the same logic as the executor's shouldAutoApplySkill. + * Filters out platform-specific skills (android, ios, react-native, etc.) + * unless the task explicitly mentions that platform. */ -function isSkillRelevantToTask(skill: ClaudeSkill, task: string): boolean { +function isSkillRelevantToTask(skill: ClaudeSkill, task: string, sourceType?: string): boolean { const name = skill.name.toLowerCase(); const desc = (skill.description || '').toLowerCase(); const text = `${name} ${desc}`; const taskLower = task.toLowerCase(); + // Filter out platform-specific skills unless the task mentions that platform + const matchesPlatform = PLATFORM_SKILLS.some((p) => name.includes(p)); + if (matchesPlatform) { + const taskMentionsPlatform = PLATFORM_SKILLS.some((p) => taskLower.includes(p)); + if (!taskMentionsPlatform) return false; + } + + // Figma source: prioritize figma/implement-design skills + if (sourceType === 'figma') { + if (name.includes('figma') || name.includes('implement-design')) return true; + } + const taskIsWeb = WEB_TASK_KEYWORDS.some((kw) => taskLower.includes(kw)); const isDesignSkill = @@ -133,6 +177,7 @@ function isSkillRelevantToTask(skill: ClaudeSkill, task: string): boolean { if (taskLower.includes('astro') && text.includes('astro')) return true; if (taskLower.includes('tailwind') && text.includes('tailwind')) return true; if (taskLower.includes('seo') && text.includes('seo')) return true; + if (taskLower.includes('figma') && text.includes('figma')) return true; return false; } @@ -175,6 +220,8 @@ function scoreCandidate(candidate: SkillCandidate, task: string): number { boost('react', taskLower.includes('react') ? 3 : 0); boost('next', taskLower.includes('next') ? 3 : 0); boost('tailwind', taskLower.includes('tailwind') ? 3 : 0); + boost('figma', taskLower.includes('figma') ? 5 : 0); // Figma skills are critical for design-to-code + boost('implement-design', taskLower.includes('figma') ? 4 : 0); boost('seo', taskLower.includes('seo') ? 3 : 2); // SEO is always useful for web projects // Boost based on install count (popularity as quality signal) @@ -260,14 +307,20 @@ async function installSkill(candidate: SkillCandidate, globalInstall: boolean): } } -export async function autoInstallSkillsFromTask(task: string, cwd: string): Promise { +export async function autoInstallSkillsFromTask( + task: string, + cwd: string, + sourceType?: string +): Promise { if (!task.trim()) return []; // Explicit disable is the only way to turn this off if (process.env.RALPH_DISABLE_SKILL_AUTO_INSTALL === '1') return []; - // Detect what's already installed + // Detect what's already installed (filters out platform-irrelevant skills like android/ios) const installedSkills = detectClaudeSkills(cwd); - const relevantInstalled = installedSkills.filter((s) => isSkillRelevantToTask(s, task)); + const relevantInstalled = installedSkills.filter((s) => + isSkillRelevantToTask(s, task, sourceType) + ); // Show installed skills to the user if (relevantInstalled.length > 0) { @@ -285,7 +338,7 @@ export async function autoInstallSkillsFromTask(task: string, cwd: string): Prom } // Search for complementary skills if we don't have enough relevant ones - const queries = buildSkillQueries(task); + const queries = buildSkillQueries(task, sourceType); if (queries.length === 0) return relevantInstalled.map((s) => s.name); const spinner = ora('Checking for complementary skills...').start(); diff --git a/src/ui/box.ts b/src/ui/box.ts index 12b693b3..b3543c1c 100644 --- a/src/ui/box.ts +++ b/src/ui/box.ts @@ -46,7 +46,7 @@ export function drawSeparator(label?: string, width?: number): string { const labelLen = label.length + 2; // space on each side const sideLen = Math.max(1, Math.floor((w - labelLen) / 2)); const left = '─'.repeat(sideLen); - const right = '─'.repeat(w - sideLen - labelLen); + const right = '─'.repeat(Math.max(0, w - sideLen - labelLen)); return chalk.dim(`${left} ${label} ${right}`); } diff --git a/src/utils/image-optimizer.ts b/src/utils/image-optimizer.ts new file mode 100644 index 00000000..5b1df3cb --- /dev/null +++ b/src/utils/image-optimizer.ts @@ -0,0 +1,102 @@ +/** + * Image Optimizer + * + * Compresses and resizes downloaded Figma images to reduce file sizes. + * Uses `sharp` as an optional dependency β€” gracefully degrades when unavailable. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; + +const MAX_IMAGE_BYTES = 1_000_000; // 1MB target +const MAX_IMAGE_DIMENSION = 2048; // Max width or height in pixels + +/** + * Optimize a single image file: resize if dimensions exceed max, compress PNG. + * Only processes files larger than 1MB. + * + * @returns Info about what was done (or skipped) + */ +export async function optimizeImage( + filePath: string, + targetMaxBytes: number = MAX_IMAGE_BYTES +): Promise<{ optimized: boolean; originalSize: number; newSize: number }> { + // Read file into memory once to avoid TOCTOU race between stat and read + let fileBuffer: Buffer; + try { + fileBuffer = readFileSync(filePath); + } catch { + return { optimized: false, originalSize: 0, newSize: 0 }; + } + const originalSize = fileBuffer.length; + + // Skip if already under target + if (originalSize <= targetMaxBytes) { + return { optimized: false, originalSize, newSize: originalSize }; + } + + try { + // Dynamic import β€” sharp is an optional dependency. + // Using Function constructor to prevent TypeScript from resolving the module at compile time. + // biome-ignore lint/security/noGlobalEval: dynamic optional dependency import + const sharpModule = (await new Function('return import("sharp")')().catch(() => null)) as { + default: (input: string | Buffer) => { + metadata: () => Promise<{ width?: number; height?: number }>; + resize: (...args: unknown[]) => unknown; + png: (opts: unknown) => { toBuffer: () => Promise }; + }; + } | null; + if (!sharpModule) { + return { optimized: false, originalSize, newSize: originalSize }; + } + + const sharp = sharpModule.default; + const image = sharp(fileBuffer); + const metadata = await image.metadata(); + + // Resize if dimensions exceed max (preserving aspect ratio) + // biome-ignore lint/suspicious/noExplicitAny: sharp pipeline type is complex + let pipeline: any = image; + const needsResize = + (metadata.width && metadata.width > MAX_IMAGE_DIMENSION) || + (metadata.height && metadata.height > MAX_IMAGE_DIMENSION); + + if (needsResize) { + pipeline = pipeline.resize(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, { + fit: 'inside', + withoutEnlargement: true, + }); + } + + // Compress PNG + const buffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer(); + + // Only write if it actually got smaller + if (buffer.length < originalSize) { + writeFileSync(filePath, buffer); + return { optimized: true, originalSize, newSize: buffer.length }; + } + + return { optimized: false, originalSize, newSize: originalSize }; + } catch { + // Optimization failed β€” keep original + return { optimized: false, originalSize, newSize: originalSize }; + } +} + +/** + * Optimize multiple image files in a directory. + * + * @returns Total bytes saved + */ +export async function optimizeImages(filePaths: string[]): Promise { + let totalSaved = 0; + + for (const filePath of filePaths) { + const result = await optimizeImage(filePath); + if (result.optimized) { + totalSaved += result.originalSize - result.newSize; + } + } + + return totalSaved; +} diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts new file mode 100644 index 00000000..2c4d5804 --- /dev/null +++ b/src/utils/sanitize.ts @@ -0,0 +1,178 @@ +/** + * Sanitization utilities for filesystem and network operations. + * Prevents path traversal, SSRF, and XSS when writing untrusted data to disk. + */ + +/** + * Sanitize a filename to prevent path traversal and filesystem issues. + * Strips directory separators, null bytes, and non-alphanumeric characters + * except dashes, dots, and underscores. + */ +export function sanitizeAssetFilename(input: string): string { + return input + .replace(/\0/g, '') // Strip null bytes + .replace(/[/\\]/g, '') // Strip directory separators + .replace(/\.\./g, '') // Strip path traversal + .replace(/[^a-zA-Z0-9._-]/g, '-') // Replace unsafe chars + .replace(/-+/g, '-') // Collapse multiple dashes + .replace(/^\.+/, '') // Strip leading dots (hidden files) + .slice(0, 255); // Filesystem filename length limit +} + +/** + * Validate that a URL points to an expected Figma CDN domain. + * Prevents SSRF if the API returned a malicious URL. + */ +export function isValidFigmaCdnUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.protocol === 'https:' && + (parsed.hostname.endsWith('.figma.com') || + parsed.hostname.endsWith('.amazonaws.com') || + parsed.hostname.endsWith('.cloudfront.net')) + ); + } catch { + return false; + } +} + +/** + * Sanitize SVG content to remove script elements, event handlers, + * and other potentially dangerous content. + * + * Uses substring-based parsing (not regex) to avoid false positives + * in static analysis while providing robust sanitization. + */ +export function sanitizeSvgContent(svg: string): string { + let result = removeScriptElements(svg); + result = removeEventHandlers(result); + result = removeJavascriptUris(result); + return result; +} + +/** + * Remove all elements and self-closing elements + let startIdx: number; + while ((startIdx = lower().indexOf(', or /) + const charAfter = result[startIdx + 7]; + if ( + charAfter !== ' ' && + charAfter !== '>' && + charAfter !== '/' && + charAfter !== '\t' && + charAfter !== '\n' + ) { + // Not a real script tag β€” skip past it to avoid infinite loop + const before = result.substring(0, startIdx + 7); + const after = result.substring(startIdx + 7); + result = before + after; + continue; + } + + const endTag = lower().indexOf(' of + const closeAngle = result.indexOf('>', endTag + 8); + result = + result.substring(0, startIdx) + + result.substring(closeAngle !== -1 ? closeAngle + 1 : endTag + 9); + } else { + // No closing tag β€” remove from