frameshot-mcp 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,35 +1,21 @@
1
1
  # frameshot
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/frameshot-mcp)](https://www.npmjs.com/package/frameshot-mcp) [![npm downloads](https://img.shields.io/npm/dm/frameshot-mcp)](https://www.npmjs.com/package/frameshot-mcp) [![GitHub stars](https://img.shields.io/github/stars/kamegoro/frameshot)](https://github.com/kamegoro/frameshot) [![CI](https://github.com/kamegoro/frameshot/actions/workflows/ci.yml/badge.svg)](https://github.com/kamegoro/frameshot/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
+ [![npm version](https://img.shields.io/npm/v/frameshot-mcp)](https://www.npmjs.com/package/frameshot-mcp) [![npm downloads](https://img.shields.io/npm/dm/frameshot-mcp)](https://www.npmjs.com/package/frameshot-mcp) [![GitHub stars](https://img.shields.io/github/stars/kamegoro/frameshot)](https://github.com/kamegoro/frameshot) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
4
 
5
- > **Give your AI agent eyes.** One MCP call, one screenshot120ms. Stop coding blind.
5
+ **Give your AI agent eyes.** Render any component — with real imports, Tailwind, and CSS Modules resolved via Vite directly in your terminal or AI chat. No stories. No config. Just point at a file.
6
6
 
7
7
  <p align="center">
8
- <img src="docs/demo.svg" alt="frameshot demo — AI writes code, frameshot renders it, AI sees and self-corrects" width="720" />
8
+ <img src="docs/demo.gif" alt="frameshot demo" width="720" />
9
9
  </p>
10
10
 
11
- ## The Problem
12
-
13
- AI coding agents (Claude Code, Cursor, Copilot, Cline) write UI code **blind**. They generate HTML/CSS/React but never see the rendered result. You end up:
14
-
15
- - Switching to the browser to check every change
16
- - Screenshotting and pasting back into chat
17
- - Burning tokens on fix loops that never converge
18
-
19
- **frameshot closes this loop.** The agent calls one tool, gets a screenshot back in 120ms, and self-corrects.
20
-
21
- ```
22
- AI writes code → frameshot renders → AI sees result → AI self-corrects → ships
23
- ```
24
-
25
- ## Install
11
+ **Claude Code**
26
12
 
27
13
  ```bash
28
14
  claude mcp add frameshot -- npx frameshot-mcp@latest
29
15
  ```
30
16
 
31
17
  <details>
32
- <summary>Cursor / VS Code / Windsurf / Other MCP clients</summary>
18
+ <summary>Cursor · VS Code · Windsurf · Cline</summary>
33
19
 
34
20
  ```json
35
21
  {
@@ -44,72 +30,155 @@ claude mcp add frameshot -- npx frameshot-mcp@latest
44
30
 
45
31
  </details>
46
32
 
47
- ## What it does
48
-
49
- | Tool | Purpose |
50
- |------|---------|
51
- | `render_component` | Render React/Vue/Svelte/HTML code screenshot. Tailwind built-in. |
52
- | `render_file` | Render a file from disk (auto-detects framework from extension). |
53
- | `screenshot_url` | Screenshot any URL (localhost:3000, staging, prod). |
54
- | `render_responsive` | Mobile + tablet + desktop in one call. |
55
- | `render_variants` | Multiple prop/state variants at once. |
56
- | `render_theme` | Light + dark mode side-by-side. |
57
- | `render_interaction` | Simulate click/hover/type, then screenshot result. |
58
- | `render_grid` | Multiple snippets in a labeled grid image. |
59
- | `diff_component` | Before/after pixel diff with % changed. |
60
- | `audit_a11y` | axe-core accessibility audit (WCAG violations). |
61
- | `perf_audit` | DOM element count, depth, render timing. |
62
- | `render_matrix` | Viewport × theme matrix in one call. |
63
- | `capture_animation` | Multi-frame CSS animation capture. |
64
- | `diff_reference` | Compare render against a reference image (Figma QA). |
65
- | `render_catalog` | Render all components in a directory (zero-config Storybook). |
66
- | `snapshot_save` | Save render as named baseline snapshot. |
67
- | `snapshot_check` | Compare current render against saved snapshot. |
68
- | `snapshot_list` | List all saved snapshots. |
69
-
70
- ### Example
71
-
72
- ```typescript
73
- // AI renders its own output to verify
74
- render_component({
75
- code: `function App() {
76
- return (
77
- <div className="p-8 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-2xl shadow-xl">
78
- <h1 className="text-3xl font-bold">Pricing</h1>
79
- <p className="text-5xl mt-4">$29<span className="text-lg">/mo</span></p>
80
- </div>
81
- )
82
- }`,
83
- framework: "react"
84
- })
85
- // → Screenshot returned in 118ms. AI can see it looks correct.
33
+ ---
34
+
35
+ ## Using with Claude
36
+
37
+ Once installed, just tell Claude what you want:
38
+
39
+ ```
40
+ "Render src/components/PricingCard.tsx and check the layout looks right"
41
+ "Refactor the dashboard to use CSS grid render it before and after and make sure nothing broke"
42
+ "Show me all components in src/components/ as screenshots"
43
+ ```
44
+
45
+ Claude will automatically call `render_file`, `diff_component`, or `render_catalog` as needed. No manual tool calls required.
46
+
47
+ ---
48
+
49
+ ## Try it now
50
+
51
+ No AI client required. Works with any React / Vue / Svelte project:
52
+
53
+ ```bash
54
+ npx frameshot render src/components/Button.tsx
86
55
  ```
87
56
 
88
- ## Why frameshot?
57
+ > First run installs Playwright's Chromium (~150MB). Subsequent renders are fast.
89
58
 
90
- | | Storybook | Chromatic/Percy | Browser MCP | **frameshot** |
91
- |---|-----------|-----------------|-------------|---------------|
92
- | Setup | stories + addons + server | SaaS signup + billing | Browser install | **`npx` — done** |
93
- | Speed | Dev server startup | Cloud round-trip | 2-5s | **~120ms** |
94
- | Cost | Free (stories = labor) | $149-800+/mo | Free | **Free forever** |
95
- | AI-native | Needs pre-written stories | No MCP support | Full-page only | **Any code snippet** |
96
- | Frameworks | React (+ addons) | Whatever Storybook supports | HTML only | **React/Vue/Svelte/HTML** |
97
- | Cross-browser | Chromatic ($$$) | Per-snapshot pricing | Single browser | **3 engines, free** |
59
+ ---
98
60
 
99
- ## Performance
61
+ ## Render
100
62
 
101
- | Scenario | Time |
102
- |----------|------|
103
- | Warm render | **~120ms** |
104
- | Cold start (first run) | ~4s |
105
- | 3 browsers in parallel | ~300ms |
106
- | Responsive (3 viewports) | ~350ms |
63
+ Point to a file. Get a screenshot. All imports resolved.
64
+
65
+ ```
66
+ render_file("src/components/Dashboard.tsx")
67
+ ```
68
+
69
+ <p align="center">
70
+ <img src="docs/example-output.png" alt="Rendered PricingCard with full Tailwind styling" width="280" />
71
+ </p>
72
+
73
+ Real Tailwind. Real CSS Modules. Real dependencies — not a CDN polyfill.
74
+
75
+ ---
76
+
77
+ ## Visual diff
78
+
79
+ Catch regressions before you ship. Before / after / changed pixels — all at once.
80
+
81
+ <p align="center">
82
+ <img src="docs/diff-before.png" alt="Before" width="210" />
83
+ &nbsp;
84
+ <img src="docs/diff-after.png" alt="After" width="210" />
85
+ &nbsp;
86
+ <img src="docs/diff-result.png" alt="Diff" width="210" />
87
+ </p>
88
+
89
+ ```
90
+ diff_component("src/components/MetricCard.tsx")
91
+ → 0.7% changed
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Tools
97
+
98
+ ### `render_file` — Render a component with full dependency resolution
99
+
100
+ <p align="center">
101
+ <img src="docs/example-output.png" alt="Rendered PricingCard" width="280" />
102
+ </p>
107
103
 
108
- Browser pool stays warm between calls. Tailwind CSS is pre-cached. Sub-200ms after first run.
104
+ Real Tailwind. Real CSS Modules. Real imports resolved by Vite.
105
+
106
+ ---
107
+
108
+ ### `diff_component` — Visual diff against git HEAD
109
+
110
+ <p align="center">
111
+ <img src="docs/diff-before.png" alt="Before" width="200" />
112
+ &nbsp;
113
+ <img src="docs/diff-after.png" alt="After" width="200" />
114
+ &nbsp;
115
+ <img src="docs/diff-result.png" alt="Diff" width="200" />
116
+ </p>
117
+
118
+ Pixel-accurate before/after comparison. Returns diff percentage.
119
+
120
+ ---
121
+
122
+ ### `render_catalog` — Screenshot every component in a directory
123
+
124
+ ```bash
125
+ npx frameshot catalog src/components/
126
+ ```
127
+
128
+ Renders all components at once and saves PNGs to the output directory.
129
+
130
+ ---
131
+
132
+ ### `audit_a11y` — Accessibility audit with axe-core
133
+
134
+ Returns WCAG violations with severity and selector. No browser extension needed.
135
+
136
+ ---
137
+
138
+ | Tool | What it does |
139
+ |------|-------------|
140
+ | `render_file` | **Render a file via Vite** — full dep resolution, props support |
141
+ | `render_component` | Render a self-contained snippet (React / Vue / Svelte / HTML) |
142
+ | `screenshot_url` | Screenshot any URL — localhost, staging, prod |
143
+ | `diff_component` | Pixel diff before/after with % changed |
144
+ | `render_responsive` | Mobile + tablet + desktop in one call |
145
+ | `render_theme` | Light + dark mode side by side |
146
+ | `audit_a11y` | axe-core accessibility audit (WCAG) |
147
+ | `render_catalog` | Render every component in a directory |
148
+
149
+ <details>
150
+ <summary>All 18 tools</summary>
151
+
152
+ | Tool | What it does |
153
+ |------|-------------|
154
+ | `render_file` | Render a project file with full Vite dependency resolution |
155
+ | `render_component` | Render a self-contained snippet → screenshot |
156
+ | `screenshot_url` | Screenshot any URL with retry and network idle wait |
157
+ | `render_responsive` | Mobile + tablet + desktop in one call |
158
+ | `render_variants` | Multiple prop/state variants at once |
159
+ | `render_theme` | Light + dark mode side by side |
160
+ | `render_interaction` | Simulate click/hover/type, then screenshot |
161
+ | `render_grid` | Multiple snippets in a labeled grid |
162
+ | `render_matrix` | Viewport × theme matrix in one call |
163
+ | `capture_animation` | Multi-frame CSS animation capture |
164
+ | `diff_component` | Before/after pixel diff with % changed |
165
+ | `diff_reference` | Compare render against a reference image (Figma QA) |
166
+ | `audit_a11y` | axe-core accessibility audit |
167
+ | `perf_audit` | DOM count, depth, render timing |
168
+ | `render_catalog` | Render all components in a directory |
169
+ | `snapshot_save` | Save a render as named baseline |
170
+ | `snapshot_check` | Compare current render against saved baseline |
171
+ | `snapshot_list` | List all saved snapshots |
172
+
173
+ </details>
174
+
175
+ ---
109
176
 
110
177
  ## GitHub Action
111
178
 
112
- Add visual previews to every PR free Chromatic alternative:
179
+ Free Chromatic alternative. Auto-detects changed components and posts before/after screenshots on every PR.
180
+
181
+ Add this file to your repo:
113
182
 
114
183
  ```yaml
115
184
  # .github/workflows/visual-preview.yml
@@ -119,29 +188,85 @@ jobs:
119
188
  preview:
120
189
  runs-on: ubuntu-latest
121
190
  steps:
122
- - uses: actions/checkout@v4
123
- - uses: kamegoro/frameshot@main
191
+ - uses: actions/checkout@v7
124
192
  with:
125
- paths: "./src/components/*.tsx"
126
- framework: react
193
+ fetch-depth: 0
194
+ - uses: kamegoro/frameshot@v0.8.0
195
+ ```
196
+
197
+ That's it. No config needed — changed `.tsx`, `.jsx`, `.vue`, `.svelte` files are detected automatically.
198
+
199
+ Optionally scope to specific paths or customize which files are included:
200
+
201
+ ```yaml
202
+ - uses: kamegoro/frameshot@v0.8.0
203
+ with:
204
+ # Render only these files (default: auto-detect changed components)
205
+ paths: "./src/components/*.tsx"
206
+
207
+ # File extensions to treat as components
208
+ # Default: .jsx,.tsx,.vue,.svelte,.astro,.mdx
209
+ extensions: ".jsx,.tsx,.vue"
210
+
211
+ # Patterns to exclude (matched against filename)
212
+ # Default: *.test.*,*.spec.*,*.stories.*,*.story.*
213
+ exclude: "*.test.*,*.spec.*,*.stories.*"
214
+ ```
215
+
216
+ ---
217
+
218
+ ## CLI — advanced usage
219
+
220
+ ```bash
221
+ npx frameshot render src/Card.tsx --props '{"title":"Hello"}'
222
+ npx frameshot catalog src/components/ --recursive
223
+ npx frameshot diff src/components/Header.tsx
127
224
  ```
128
225
 
129
- Screenshots are posted as a PR comment automatically.
226
+ Images display inline in iTerm2, Kitty, and Sixel terminals. Saved to `.frameshot/` by default.
227
+
228
+ ---
229
+
230
+ ## Performance
231
+
232
+ Typical measured times (varies by machine and project size):
233
+
234
+ | Scenario | Time |
235
+ |----------|------|
236
+ | Warm render (Vite server cached) | **~200–500ms** |
237
+ | CDN fallback (no Vite) | **~120ms** |
238
+ | Cold start (first render) | ~1–3s |
239
+ | Subsequent renders (same session) | ~200ms |
240
+
241
+ Vite server is cached per project root. Browser pool stays warm between MCP calls.
242
+
243
+ ---
244
+
245
+ ## vs. alternatives
130
246
 
131
- ## Recipes
247
+ | | Storybook | Chromatic | Browser MCP | **frameshot** |
248
+ |---|-----------|-----------|-------------|---------------|
249
+ | Setup | Stories + config | SaaS signup | Browser install | **`npx` — done** |
250
+ | Speed | Dev server startup | Cloud round-trip | 2–5s | **~200ms warm** |
251
+ | Cost | Free (labor cost) | $149–800+/mo | Free | **Free forever** |
252
+ | Stories needed | Yes | Yes | No | **No** |
253
+ | Real imports | Via Storybook | Via Storybook | No | **Via Vite** |
254
+ | Works offline | Yes | No | Yes | **Yes** |
255
+ | AI-native (MCP) | No | No | Full-page only | **Component-level** |
132
256
 
133
- - [Claude Code skill](examples/claude-code-skill.md) — Auto-preview on `/project:preview`
134
- - [Cursor rules](examples/cursor-rules.md) — Verify UI on every edit
257
+ ---
135
258
 
136
259
  ## Development
137
260
 
138
261
  ```bash
139
- git clone https://github.com/kamegoro/frameshot.git && cd frameshot
140
- npm install && npx playwright install chromium && npm run build && npm test
262
+ git clone https://github.com/kamegoro/frameshot.git
263
+ cd frameshot && npm install
264
+ npx playwright install chromium
265
+ npm run build && npm test
141
266
  ```
142
267
 
143
- See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
268
+ See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for architecture details.
144
269
 
145
- ## License
270
+ ---
146
271
 
147
- MIT
272
+ MIT © [kamegoro](https://github.com/kamegoro)
package/action.yml CHANGED
@@ -1,33 +1,30 @@
1
1
  name: "frameshot — Visual Preview"
2
- description: "Render components and attach before/after screenshots to PRs. Free visual regression for every pull request."
2
+ description: "Render changed components with full Vite dependency resolution and attach before/after screenshots to PRs."
3
3
  branding:
4
4
  icon: "camera"
5
5
  color: "blue"
6
6
 
7
7
  inputs:
8
8
  paths:
9
- description: "Glob patterns for component files to render (newline or comma separated)"
10
- required: true
11
- framework:
12
- description: "Framework: react, vue, svelte, or html"
9
+ description: "Glob patterns for component files to render. Leave empty to auto-detect changed component files."
13
10
  required: false
14
- default: "react"
15
- width:
16
- description: "Viewport width (px)"
11
+ default: ""
12
+ extensions:
13
+ description: "Comma-separated file extensions to treat as components. Default covers React, Vue, Svelte, Astro, MDX."
17
14
  required: false
18
- default: "1280"
19
- height:
20
- description: "Viewport height (px)"
15
+ default: ".jsx,.tsx,.vue,.svelte,.astro,.mdx"
16
+ exclude:
17
+ description: "Patterns to exclude (comma-separated). Matches against full file path."
21
18
  required: false
22
- default: "800"
23
- dark-mode:
24
- description: "Render with dark mode"
19
+ default: "*.test.*,*.spec.*,*.stories.*,*.story.*"
20
+ width:
21
+ description: "Viewport width in px. Leave empty for auto-fit (recommended)."
25
22
  required: false
26
- default: "false"
27
- tailwind-version:
28
- description: "Tailwind CSS version (3 or 4)"
23
+ default: ""
24
+ height:
25
+ description: "Viewport height in px. Leave empty for auto-fit (recommended)."
29
26
  required: false
30
- default: "3"
27
+ default: ""
31
28
  comment:
32
29
  description: "Post a PR comment with screenshots"
33
30
  required: false
@@ -41,30 +38,48 @@ runs:
41
38
  using: "composite"
42
39
  steps:
43
40
  - name: Setup Node.js
44
- uses: actions/setup-node@v4
41
+ uses: actions/setup-node@v6
45
42
  with:
46
43
  node-version: "20"
47
44
 
48
45
  - name: Install frameshot
49
46
  shell: bash
50
- run: npm install -g frameshot-mcp@latest
47
+ run: |
48
+ cd "${{ github.action_path }}"
49
+ npm install --prefer-offline frameshot-mcp@latest
51
50
 
52
51
  - name: Install Playwright Chromium
53
52
  shell: bash
54
- run: npx playwright install chromium
53
+ run: |
54
+ cd "${{ github.action_path }}"
55
+ npx playwright install chromium
56
+
57
+ - name: Install project dependencies (for Vite pipeline)
58
+ shell: bash
59
+ run: |
60
+ # Install deps in any subdirectory that has vite as a dependency
61
+ for pkg in $(find "$GITHUB_WORKSPACE" -name "package.json" -not -path "*/node_modules/*" -maxdepth 3); do
62
+ dir=$(dirname "$pkg")
63
+ if grep -q '"vite"' "$pkg" 2>/dev/null; then
64
+ echo "Installing deps in $dir"
65
+ cd "$dir" && npm install --legacy-peer-deps 2>/dev/null || true
66
+ cd "$GITHUB_WORKSPACE"
67
+ fi
68
+ done
55
69
 
56
70
  - name: Render changed components
57
71
  id: render
58
72
  shell: bash
59
73
  env:
60
74
  INPUT_PATHS: ${{ inputs.paths }}
61
- INPUT_FRAMEWORK: ${{ inputs.framework }}
75
+ INPUT_EXTENSIONS: ${{ inputs.extensions }}
76
+ INPUT_EXCLUDE: ${{ inputs.exclude }}
62
77
  INPUT_WIDTH: ${{ inputs.width }}
63
78
  INPUT_HEIGHT: ${{ inputs.height }}
64
- INPUT_DARK_MODE: ${{ inputs.dark-mode }}
65
- INPUT_TAILWIND_VERSION: ${{ inputs.tailwind-version }}
79
+ INPUT_BASE_REF: ${{ github.event.pull_request.base.sha }}
66
80
  run: |
67
- node "${{ github.action_path }}/scripts/render-changed.mjs"
81
+ cd "${{ github.action_path }}"
82
+ node scripts/render-changed.mjs
68
83
 
69
84
  - name: Comment on PR
70
85
  if: inputs.comment == 'true' && github.event_name == 'pull_request'
@@ -83,26 +98,114 @@ runs:
83
98
  return;
84
99
  }
85
100
 
86
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.png'));
87
- if (files.length === 0) {
88
- console.log('No screenshot files found');
101
+ const manifestPath = path.join(dir, 'manifest.json');
102
+ if (!fs.existsSync(manifestPath)) {
103
+ console.log('No manifest found');
104
+ return;
105
+ }
106
+
107
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
108
+ if (manifest.length === 0) {
109
+ console.log('No components rendered');
89
110
  return;
90
111
  }
91
112
 
92
- // Upload screenshots as artifacts and build comment
113
+ // Store image in repo on a dedicated branch and return raw URL.
114
+ // Uses a special orphan branch "frameshot-previews" as image storage.
115
+ async function uploadImageToGitHub(filePath) {
116
+ const bytes = fs.readFileSync(filePath);
117
+ const base64 = bytes.toString('base64');
118
+ const filename = `pr-${context.issue.number}-${path.basename(filePath)}-${Date.now()}.png`;
119
+ const BRANCH = 'frameshot-previews';
120
+
121
+ // Ensure branch exists
122
+ try {
123
+ await github.rest.repos.getBranch({
124
+ owner: context.repo.owner,
125
+ repo: context.repo.repo,
126
+ branch: BRANCH,
127
+ });
128
+ } catch {
129
+ // Branch doesn't exist — create it from main
130
+ try {
131
+ const { data: ref } = await github.rest.git.getRef({
132
+ owner: context.repo.owner,
133
+ repo: context.repo.repo,
134
+ ref: 'heads/main',
135
+ });
136
+ await github.rest.git.createRef({
137
+ owner: context.repo.owner,
138
+ repo: context.repo.repo,
139
+ ref: `refs/heads/${BRANCH}`,
140
+ sha: ref.object.sha,
141
+ });
142
+ } catch (e) {
143
+ console.log('Failed to create branch:', e.message);
144
+ return null;
145
+ }
146
+ }
147
+
148
+ // Commit the image file
149
+ try {
150
+ await github.rest.repos.createOrUpdateFileContents({
151
+ owner: context.repo.owner,
152
+ repo: context.repo.repo,
153
+ path: filename,
154
+ message: `Add PR #${context.issue.number} preview image`,
155
+ content: base64,
156
+ branch: BRANCH,
157
+ });
158
+ return `https://raw.githubusercontent.com/${context.repo.owner}/${context.repo.repo}/${BRANCH}/${filename}`;
159
+ } catch (e) {
160
+ console.log('Failed to commit image:', e.message);
161
+ return null;
162
+ }
163
+ }
164
+
165
+ const hasBeforeAfter = manifest.some(m => m.hasBefore);
93
166
  let body = '## 📸 frameshot — Visual Preview\n\n';
94
- body += '| Component | Screenshot |\n|---|---|\n';
95
167
 
96
- for (const file of files) {
97
- const name = file.replace('.png', '').replace(/_/g, '/');
98
- const imgPath = path.join(dir, file);
99
- const imgData = fs.readFileSync(imgPath, 'base64');
100
- body += `| \`${name}\` | <img src="data:image/png;base64,${imgData}" width="400" /> |\n`;
168
+ if (hasBeforeAfter) {
169
+ body += '| Component | Before | After | Diff |\n|---|---|---|---|\n';
170
+ } else {
171
+ body += '| Component | Screenshot |\n|---|---|\n';
172
+ }
173
+
174
+ for (const entry of manifest) {
175
+ const afterFile = path.join(dir, `${entry.name}_after.png`);
176
+ if (!fs.existsSync(afterFile)) continue;
177
+
178
+ if (hasBeforeAfter) {
179
+ const beforeFile = path.join(dir, `${entry.name}_before.png`);
180
+ const diffFile = path.join(dir, `${entry.name}_diff.png`);
181
+
182
+ // Upload sequentially to avoid commit conflicts
183
+ const afterUrl = await uploadImageToGitHub(afterFile);
184
+ const beforeUrl = fs.existsSync(beforeFile) ? await uploadImageToGitHub(beforeFile) : null;
185
+ const diffUrl = fs.existsSync(diffFile) ? await uploadImageToGitHub(diffFile) : null;
186
+
187
+ const beforeCell = beforeUrl
188
+ ? `<img src="${beforeUrl}" width="240" />`
189
+ : entry.hasBefore ? '*(before unavailable)*' : '*(new)*';
190
+ const afterCell = afterUrl
191
+ ? `<img src="${afterUrl}" width="240" />`
192
+ : '*(after unavailable)*';
193
+ const diffCell = diffUrl && entry.diffPercentage > 0
194
+ ? `<img src="${diffUrl}" width="240" /><br/>${entry.diffPercentage.toFixed(1)}% changed`
195
+ : entry.hasBefore ? '✅ No change' : '—';
196
+
197
+ body += `| \`${entry.name}\` | ${beforeCell} | ${afterCell} | ${diffCell} |\n`;
198
+ } else {
199
+ const afterUrl = await uploadImageToGitHub(afterFile);
200
+ const afterCell = afterUrl
201
+ ? `<img src="${afterUrl}" width="360" />`
202
+ : '*(unavailable)*';
203
+ body += `| \`${entry.name}\` | ${afterCell} |\n`;
204
+ }
101
205
  }
102
206
 
103
207
  body += '\n---\n*Generated by [frameshot](https://github.com/kamegoro/frameshot)*';
104
208
 
105
- // Find existing comment
106
209
  const { data: comments } = await github.rest.issues.listComments({
107
210
  owner: context.repo.owner,
108
211
  repo: context.repo.repo,