frameshot-mcp 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +38 -126
  2. package/dist/index.js +507 -35
  3. package/package.json +10 -2
package/README.md CHANGED
@@ -2,58 +2,23 @@
2
2
 
3
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)
4
4
 
5
- > Instant cross-browser component preview for AI agents. One call, one screenshot no dev server, no Storybook, no ceremony.
5
+ > Give your AI agent eyes. Render UI components and get screenshots back in 120ms.
6
6
 
7
- **Works with:** Claude Code · Cursor · Codex · Windsurf · any MCP-compatible tool
7
+ <!-- TODO: demo GIF here -->
8
+ <!-- ![demo](docs/demo.gif) -->
8
9
 
9
- ## The Problem
10
-
11
- You're building UI with an AI agent. The agent writes a component but can't see it. You have to:
12
- 1. Start the dev server
13
- 2. Log in
14
- 3. Navigate to the right page
15
- 4. Scroll to the component
16
- 5. Tell the agent what's wrong
17
-
18
- **frameshot** skips all of that. The agent renders the component itself, sees the result, and fixes it.
19
-
20
- ## Quick Start
21
-
22
- ### Claude Code
23
-
24
- ```bash
25
- claude mcp add frameshot -- npx frameshot-mcp@latest
26
10
  ```
27
-
28
- That's it. Now your AI agent can see UI.
29
-
30
- ### Cursor (`.cursor/mcp.json`)
31
-
32
- ```json
33
- {
34
- "mcpServers": {
35
- "frameshot": {
36
- "command": "npx",
37
- "args": ["frameshot-mcp@latest"]
38
- }
39
- }
40
- }
11
+ AI writes code → AI calls frameshot → AI sees the result → AI self-corrects
41
12
  ```
42
13
 
43
- ### VS Code Copilot (`.vscode/mcp.json`)
14
+ ## Install
44
15
 
45
- ```json
46
- {
47
- "servers": {
48
- "frameshot": {
49
- "command": "npx",
50
- "args": ["frameshot-mcp@latest"]
51
- }
52
- }
53
- }
16
+ ```bash
17
+ claude mcp add frameshot -- npx frameshot-mcp@latest
54
18
  ```
55
19
 
56
- ### Manual setup
20
+ <details>
21
+ <summary>Cursor / VS Code / Other</summary>
57
22
 
58
23
  ```json
59
24
  {
@@ -66,115 +31,62 @@ That's it. Now your AI agent can see UI.
66
31
  }
67
32
  ```
68
33
 
69
- > **Note:** On first run, Playwright will download Chromium (~150MB). For additional engines: `npx playwright install firefox webkit`
34
+ </details>
70
35
 
71
36
  ## Tools
72
37
 
73
- ### `render_component`
38
+ | Tool | What it does |
39
+ |------|-------------|
40
+ | `render_component` | Render React/Vue/Svelte/HTML → screenshot. Tailwind built-in. |
41
+ | `render_responsive` | Render at mobile + tablet + desktop in one call. |
42
+ | `render_variants` | Render multiple prop/state variants in one call. |
43
+ | `screenshot_url` | Screenshot any URL (e.g. localhost:3000). |
44
+ | `audit_a11y` | Run axe-core accessibility audit on your component. |
45
+ | `diff_component` | Visual regression: compare before/after code, get pixel diff. |
46
+ | `capture_animation` | Capture CSS animation frames over time (multi-screenshot). |
74
47
 
75
- Render isolated component code → get screenshot back instantly.
48
+ ### Example
76
49
 
77
50
  ```typescript
78
51
  render_component({
79
- code: `
80
- function App() {
81
- return (
82
- <div className="p-8 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl">
83
- <h1 className="text-4xl font-bold text-white">Hello World</h1>
84
- </div>
85
- )
86
- }
87
- `,
52
+ code: `function App() {
53
+ return <div className="p-8 bg-blue-500 text-white rounded-xl">Hello</div>
54
+ }`,
88
55
  framework: "react",
89
- engines: ["chromium", "firefox", "webkit"]
90
- })
91
- // Returns 3 screenshots — one per browser engine
92
- ```
93
-
94
- | Parameter | Default | Description |
95
- |-----------|---------|-------------|
96
- | `code` | required | Component source code |
97
- | `framework` | `"react"` | `"react"` · `"vue"` · `"html"` |
98
- | `engines` | `["chromium"]` | Browser engines to render in |
99
- | `width` | `1280` | Viewport width |
100
- | `height` | `800` | Viewport height |
101
- | `fullPage` | `true` | Capture full scroll height |
102
-
103
- ### `screenshot_url`
104
-
105
- Screenshot a running app (e.g. localhost) across browsers.
106
-
107
- ```typescript
108
- screenshot_url({
109
- url: "http://localhost:3000/components/button",
56
+ darkMode: true,
110
57
  engines: ["chromium", "firefox", "webkit"]
111
58
  })
112
59
  ```
113
60
 
114
- ## Why Not Storybook?
61
+ ## Why frameshot?
115
62
 
116
63
  | | Storybook | frameshot |
117
64
  |---|-----------|-----------|
118
- | Setup | Write .stories.tsx, configure addons, run server | **Zero** — pass code, get image |
119
- | Speed | Heavy dev server startup | **~120ms** warm render |
120
- | Cross-browser | Chromatic ($149+/mo) | **Free** — Playwright built-in |
121
- | AI-native | Requires pre-written stories | **Works with any code snippet** |
122
- | Auth required? | Need full app context | **No** — isolated component render |
123
-
124
- frameshot is not a Storybook replacement. Storybook is for documentation and design systems. frameshot is for **seeing what you just wrote, right now**.
65
+ | Setup | .stories.tsx + addons + server | **Zero** |
66
+ | Speed | Dev server startup | **~120ms** |
67
+ | Cross-browser | Chromatic ($149+/mo) | **Free** |
68
+ | AI-native | Needs pre-written stories | **Any code snippet** |
125
69
 
126
70
  ## Performance
127
71
 
128
72
  | Scenario | Time |
129
73
  |----------|------|
130
- | Warm render (browser reused) | **~120ms** |
131
- | Cold start (first render) | ~4s |
132
- | Cross-browser (3 engines parallel) | ~300ms warm |
133
-
134
- The browser pool stays warm with Tailwind pre-cached. After cold start, renders are **sub-200ms**.
135
-
136
- ## Framework Support
137
-
138
- ### React (JSX + Tailwind)
139
- ```jsx
140
- function App() {
141
- const [count, setCount] = React.useState(0)
142
- return <button onClick={() => setCount(c => c+1)} className="btn">{count}</button>
143
- }
144
- ```
145
-
146
- ### Vue 3 (Composition API + Tailwind)
147
- ```javascript
148
- const App = {
149
- setup() {
150
- const count = ref(0)
151
- return { count }
152
- },
153
- template: `<button @click="count++">{{ count }}</button>`
154
- }
155
- ```
74
+ | Warm render | **~120ms** |
75
+ | Cold start | ~4s |
76
+ | 3 engines parallel | ~300ms |
156
77
 
157
- ### HTML (+ Tailwind)
158
- ```html
159
- <div class="flex gap-4 p-8">
160
- <button class="px-4 py-2 bg-blue-500 text-white rounded">Click me</button>
161
- </div>
162
- ```
78
+ Browser pool stays warm. Tailwind pre-cached. Sub-200ms after first run.
163
79
 
164
- ## Environment Variables
80
+ ## Recipes
165
81
 
166
- | Variable | Description |
167
- |----------|-------------|
168
- | `FRAMESHOT_BROWSER_PATH` | Custom Chromium executable path |
82
+ - [Claude Code skill setup](examples/claude-code-skill.md) — Auto-preview components with `/project:preview`
83
+ - [Cursor rules](examples/cursor-rules.md) — Auto-verify UI on every edit
169
84
 
170
85
  ## Development
171
86
 
172
87
  ```bash
173
- git clone https://github.com/kamegoro/frameshot.git
174
- cd frameshot
175
- npm install
176
- npx playwright install chromium
177
- npm run build
88
+ git clone https://github.com/kamegoro/frameshot.git && cd frameshot
89
+ npm install && npx playwright install chromium && npm run build
178
90
  ```
179
91
 
180
92
  ## License
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/index.ts
9
+ import { execSync } from "child_process";
9
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
12
 
@@ -14524,7 +14525,9 @@ function date4(params) {
14524
14525
  config(en_default());
14525
14526
 
14526
14527
  // src/renderer.ts
14528
+ import pixelmatch from "pixelmatch";
14527
14529
  import { chromium, firefox, webkit } from "playwright";
14530
+ import { PNG } from "pngjs";
14528
14531
  var pool = /* @__PURE__ */ new Map();
14529
14532
  var ENGINES = ["chromium", "firefox", "webkit"];
14530
14533
  async function warmup(engines = ENGINES) {
@@ -14542,8 +14545,10 @@ async function getSlot(engine) {
14542
14545
  headless: true,
14543
14546
  ...engine === "chromium" ? { channel: "chrome" } : {}
14544
14547
  });
14545
- } catch (e) {
14546
- throw new Error(`${engine} is not installed. Run: npx playwright install ${engine}`);
14548
+ } catch (_e) {
14549
+ throw new Error(
14550
+ `${engine} is not installed. Run: npx playwright install ${engine}`
14551
+ );
14547
14552
  }
14548
14553
  const context = await browser.newContext({
14549
14554
  viewport: { width: 1280, height: 800 },
@@ -14558,47 +14563,67 @@ async function getSlot(engine) {
14558
14563
  pool.set(engine, slot);
14559
14564
  return slot;
14560
14565
  }
14561
- function wrapComponent(code, framework) {
14566
+ function wrapComponent(code, framework, darkMode = false, css = "") {
14562
14567
  const tailwind = '<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>';
14568
+ const tailwindConfig = `<script>tailwind.config={darkMode:'class'}</script>`;
14569
+ const customCss = css ? `<style>${css}</style>` : "";
14563
14570
  const baseStyle = `<style>*{margin:0;box-sizing:border-box}body{padding:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}</style>`;
14571
+ const htmlClass = darkMode ? ' class="dark"' : "";
14564
14572
  if (framework === "html") {
14565
14573
  if (code.includes("<html")) return code;
14566
- return `<!DOCTYPE html><html><head><meta charset="utf-8">${tailwind}${baseStyle}</head><body>${code}</body></html>`;
14574
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}${baseStyle}${customCss}</head><body>${code}</body></html>`;
14567
14575
  }
14568
14576
  if (framework === "react") {
14569
- return `<!DOCTYPE html><html><head><meta charset="utf-8">${tailwind}
14577
+ const cleanedCode = code.replace(/['"]use client['"];?\n?/g, "").replace(/['"]use server['"];?\n?/g, "").replace(/import\s+.*?\s+from\s+['"]next\/image['"];?\n?/g, "").replace(/import\s+.*?\s+from\s+['"]next\/link['"];?\n?/g, "");
14578
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14570
14579
  <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
14571
14580
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
14572
14581
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
14573
- ${baseStyle}</head><body><div id="root"></div>
14582
+ ${baseStyle}${customCss}</head><body><div id="root"></div>
14574
14583
  <script type="text/babel">
14575
- ${code}
14584
+ const Image = (props) => React.createElement('img', {...props, src: props.src?.src || props.src});
14585
+ const Link = ({href, children, ...props}) => React.createElement('a', {href, ...props}, children);
14586
+ ${cleanedCode}
14576
14587
  const _C = typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:typeof Default!=='undefined'?Default:null;
14577
14588
  if(_C)ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(_C));
14578
14589
  </script></body></html>`;
14579
14590
  }
14580
14591
  if (framework === "vue") {
14581
- return `<!DOCTYPE html><html><head><meta charset="utf-8">${tailwind}
14592
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14582
14593
  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
14583
- ${baseStyle}</head><body><div id="app"></div>
14594
+ ${baseStyle}${customCss}</head><body><div id="app"></div>
14584
14595
  <script>
14585
14596
  const{createApp,ref,reactive,computed,onMounted,watch,watchEffect}=Vue;
14586
14597
  ${code}
14587
14598
  const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
14588
14599
  if(_C)createApp(_C).mount('#app');
14600
+ </script></body></html>`;
14601
+ }
14602
+ if (framework === "svelte") {
14603
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14604
+ <script src="https://unpkg.com/svelte@4/compiler.cjs"></script>
14605
+ ${baseStyle}${customCss}</head><body><div id="app"></div>
14606
+ <script type="module">
14607
+ import "https://unpkg.com/svelte@4/internal/index.mjs";
14608
+ ${code}
14609
+ const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
14610
+ if(_C)new _C({target:document.getElementById('app')});
14589
14611
  </script></body></html>`;
14590
14612
  }
14591
14613
  return code;
14592
14614
  }
14593
14615
  async function renderSingle(engine, html, options) {
14594
- const {
14595
- width = 1280,
14596
- height = 800,
14597
- fullPage = true,
14598
- waitFor = 0
14599
- } = options;
14616
+ const { width = 1280, height = 800, fullPage = true, waitFor = 0 } = options;
14600
14617
  const slot = await getSlot(engine);
14601
14618
  const { page } = slot;
14619
+ const consoleErrors = [];
14620
+ const onError = (msg) => {
14621
+ if (msg.type() === "error") {
14622
+ consoleErrors.push(msg.text());
14623
+ }
14624
+ };
14625
+ page.on("console", onError);
14626
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
14602
14627
  const currentViewport = page.viewportSize();
14603
14628
  if (currentViewport?.width !== width || currentViewport?.height !== height) {
14604
14629
  await page.setViewportSize({ width, height });
@@ -14612,16 +14637,23 @@ async function renderSingle(engine, html, options) {
14612
14637
  w: document.documentElement.scrollWidth,
14613
14638
  h: document.documentElement.scrollHeight
14614
14639
  }));
14640
+ page.removeListener("console", onError);
14615
14641
  return {
14616
14642
  engine,
14617
14643
  image: screenshot.toString("base64"),
14618
14644
  width: metrics.w,
14619
- height: metrics.h
14645
+ height: metrics.h,
14646
+ consoleErrors
14620
14647
  };
14621
14648
  }
14622
14649
  async function render(code, framework, options = {}) {
14623
14650
  const engines = options.engines ?? ["chromium"];
14624
- const html = wrapComponent(code, framework);
14651
+ const html = wrapComponent(
14652
+ code,
14653
+ framework,
14654
+ options.darkMode ?? false,
14655
+ options.css ?? ""
14656
+ );
14625
14657
  const results = await Promise.all(
14626
14658
  engines.map((e) => renderSingle(e, html, options))
14627
14659
  );
@@ -14631,9 +14663,22 @@ async function screenshotUrl(url2, options = {}) {
14631
14663
  const engines = options.engines ?? ["chromium"];
14632
14664
  const results = await Promise.all(
14633
14665
  engines.map(async (engine) => {
14634
- const { width = 1280, height = 800, fullPage = true, waitFor = 0 } = options;
14666
+ const {
14667
+ width = 1280,
14668
+ height = 800,
14669
+ fullPage = true,
14670
+ waitFor = 0
14671
+ } = options;
14635
14672
  const slot = await getSlot(engine);
14636
14673
  const { page } = slot;
14674
+ const consoleErrors = [];
14675
+ const onError = (msg) => {
14676
+ if (msg.type() === "error") {
14677
+ consoleErrors.push(msg.text());
14678
+ }
14679
+ };
14680
+ page.on("console", onError);
14681
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
14637
14682
  const currentViewport = page.viewportSize();
14638
14683
  if (currentViewport?.width !== width || currentViewport?.height !== height) {
14639
14684
  await page.setViewportSize({ width, height });
@@ -14649,11 +14694,137 @@ async function screenshotUrl(url2, options = {}) {
14649
14694
  w: document.documentElement.scrollWidth,
14650
14695
  h: document.documentElement.scrollHeight
14651
14696
  }));
14652
- return { engine, image: screenshot.toString("base64"), width: metrics.w, height: metrics.h };
14697
+ page.removeListener("console", onError);
14698
+ return {
14699
+ engine,
14700
+ image: screenshot.toString("base64"),
14701
+ width: metrics.w,
14702
+ height: metrics.h,
14703
+ consoleErrors
14704
+ };
14653
14705
  })
14654
14706
  );
14655
14707
  return results;
14656
14708
  }
14709
+ async function auditA11y(code, framework, options = {}) {
14710
+ const html = wrapComponent(
14711
+ code,
14712
+ framework,
14713
+ options.darkMode ?? false,
14714
+ options.css ?? ""
14715
+ );
14716
+ const slot = await getSlot("chromium");
14717
+ const { page } = slot;
14718
+ const { width = 1280, height = 800 } = options;
14719
+ const currentViewport = page.viewportSize();
14720
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
14721
+ await page.setViewportSize({ width, height });
14722
+ }
14723
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
14724
+ const axeSource = await import("axe-core").then((m) => m.source);
14725
+ await page.addScriptTag({ content: axeSource });
14726
+ const results = await page.evaluate(() => {
14727
+ return window.axe.run();
14728
+ });
14729
+ return {
14730
+ violations: results.violations.map(
14731
+ (v) => ({
14732
+ id: v.id,
14733
+ impact: v.impact,
14734
+ description: v.description,
14735
+ helpUrl: v.helpUrl,
14736
+ nodes: v.nodes.map((n) => ({
14737
+ html: n.html,
14738
+ target: n.target
14739
+ }))
14740
+ })
14741
+ ),
14742
+ passes: results.passes.length,
14743
+ incomplete: results.incomplete.length
14744
+ };
14745
+ }
14746
+ async function diffComponent(before, after, framework, options = {}) {
14747
+ const [beforeResults, afterResults] = await Promise.all([
14748
+ render(before, framework, { ...options, engines: ["chromium"] }),
14749
+ render(after, framework, { ...options, engines: ["chromium"] })
14750
+ ]);
14751
+ const beforeBuf = Buffer.from(beforeResults[0].image, "base64");
14752
+ const afterBuf = Buffer.from(afterResults[0].image, "base64");
14753
+ const beforePng = PNG.sync.read(beforeBuf);
14754
+ const afterPng = PNG.sync.read(afterBuf);
14755
+ const width = Math.max(beforePng.width, afterPng.width);
14756
+ const height = Math.max(beforePng.height, afterPng.height);
14757
+ const normalizedBefore = new PNG({ width, height });
14758
+ const normalizedAfter = new PNG({ width, height });
14759
+ PNG.bitblt(
14760
+ beforePng,
14761
+ normalizedBefore,
14762
+ 0,
14763
+ 0,
14764
+ beforePng.width,
14765
+ beforePng.height,
14766
+ 0,
14767
+ 0
14768
+ );
14769
+ PNG.bitblt(
14770
+ afterPng,
14771
+ normalizedAfter,
14772
+ 0,
14773
+ 0,
14774
+ afterPng.width,
14775
+ afterPng.height,
14776
+ 0,
14777
+ 0
14778
+ );
14779
+ const diffPng = new PNG({ width, height });
14780
+ const diffPixels = pixelmatch(
14781
+ normalizedBefore.data,
14782
+ normalizedAfter.data,
14783
+ diffPng.data,
14784
+ width,
14785
+ height,
14786
+ { threshold: 0.1 }
14787
+ );
14788
+ const totalPixels = width * height;
14789
+ return {
14790
+ before: beforeResults[0].image,
14791
+ after: afterResults[0].image,
14792
+ diff: PNG.sync.write(diffPng).toString("base64"),
14793
+ diffPixels,
14794
+ totalPixels,
14795
+ diffPercentage: Math.round(diffPixels / totalPixels * 1e4) / 100
14796
+ };
14797
+ }
14798
+ async function captureAnimation(code, framework, options = {}) {
14799
+ const { frames = 5, duration: duration3 = 1e3 } = options;
14800
+ const html = wrapComponent(
14801
+ code,
14802
+ framework,
14803
+ options.darkMode ?? false,
14804
+ options.css ?? ""
14805
+ );
14806
+ const slot = await getSlot("chromium");
14807
+ const { page } = slot;
14808
+ const { width = 1280, height = 800 } = options;
14809
+ const currentViewport = page.viewportSize();
14810
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
14811
+ await page.setViewportSize({ width, height });
14812
+ }
14813
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
14814
+ const interval = duration3 / (frames - 1);
14815
+ const results = [];
14816
+ for (let i = 0; i < frames; i++) {
14817
+ if (i > 0) {
14818
+ await page.waitForTimeout(interval);
14819
+ }
14820
+ const screenshot = await page.screenshot({ type: "png", fullPage: false });
14821
+ results.push({
14822
+ timestamp: Math.round(i * interval),
14823
+ image: screenshot.toString("base64")
14824
+ });
14825
+ }
14826
+ return results;
14827
+ }
14657
14828
  async function shutdown() {
14658
14829
  for (const slot of pool.values()) {
14659
14830
  await slot.browser.close().catch(() => {
@@ -14663,6 +14834,18 @@ async function shutdown() {
14663
14834
  }
14664
14835
 
14665
14836
  // src/index.ts
14837
+ async function ensureBrowser() {
14838
+ try {
14839
+ await warmup(["chromium"]);
14840
+ } catch {
14841
+ try {
14842
+ execSync("npx playwright install chromium", { stdio: "pipe" });
14843
+ await warmup(["chromium"]);
14844
+ } catch {
14845
+ }
14846
+ }
14847
+ }
14848
+ await ensureBrowser();
14666
14849
  var server = new McpServer({
14667
14850
  name: "frameshot",
14668
14851
  version: "0.1.0"
@@ -14672,20 +14855,47 @@ server.tool(
14672
14855
  "Instantly render a React/Vue/HTML component and return screenshots across browser engines. Zero setup needed \u2014 just pass your code. Tailwind CSS is built-in. Use this to visually verify UI code without starting a dev server.",
14673
14856
  {
14674
14857
  code: external_exports.string().describe("Component code to render"),
14675
- framework: external_exports.enum(["html", "react", "vue"]).default("react").describe("Framework: html, react, or vue"),
14676
- engines: external_exports.array(external_exports.enum(["chromium", "firefox", "webkit"])).optional().default(["chromium"]).describe('Browser engines to render in. Use ["chromium","firefox","webkit"] for cross-browser check.'),
14858
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14859
+ engines: external_exports.array(external_exports.enum(["chromium", "firefox", "webkit"])).optional().default(["chromium"]).describe(
14860
+ 'Browser engines to render in. Use ["chromium","firefox","webkit"] for cross-browser check.'
14861
+ ),
14677
14862
  width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14678
14863
  height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
14679
- fullPage: external_exports.boolean().optional().default(true).describe("Capture full scroll height")
14864
+ fullPage: external_exports.boolean().optional().default(true).describe("Capture full scroll height"),
14865
+ darkMode: external_exports.boolean().optional().default(false).describe("Render with Tailwind dark mode (adds 'dark' class to html)"),
14866
+ colorSchemes: external_exports.array(external_exports.enum(["light", "dark"])).optional().describe(
14867
+ 'Render both: ["light","dark"] returns 2 screenshots for comparison'
14868
+ ),
14869
+ css: external_exports.string().optional().describe("Custom CSS to inject (design tokens, variables, etc)")
14680
14870
  },
14681
- async ({ code, framework, engines, width, height, fullPage }) => {
14871
+ async ({
14872
+ code,
14873
+ framework,
14874
+ engines,
14875
+ width,
14876
+ height,
14877
+ fullPage,
14878
+ darkMode,
14879
+ colorSchemes,
14880
+ css
14881
+ }) => {
14682
14882
  try {
14683
- const results = await render(code, framework, {
14684
- width,
14685
- height,
14686
- fullPage,
14687
- engines
14688
- });
14883
+ const start = performance.now();
14884
+ const schemes = colorSchemes ?? (darkMode ? ["dark"] : ["light"]);
14885
+ const allResults = await Promise.all(
14886
+ schemes.map(
14887
+ (scheme) => render(code, framework, {
14888
+ width,
14889
+ height,
14890
+ fullPage,
14891
+ engines,
14892
+ darkMode: scheme === "dark",
14893
+ css
14894
+ })
14895
+ )
14896
+ );
14897
+ const elapsed = Math.round(performance.now() - start);
14898
+ const results = allResults.flat();
14689
14899
  const content = results.flatMap((r) => [
14690
14900
  {
14691
14901
  type: "image",
@@ -14694,13 +14904,18 @@ server.tool(
14694
14904
  },
14695
14905
  {
14696
14906
  type: "text",
14697
- text: `[${r.engine}] ${r.width}x${r.height}`
14907
+ text: `[${r.engine}] ${r.width}x${r.height} (${elapsed}ms)${r.consoleErrors.length ? `
14908
+ \u26A0\uFE0F Console errors:
14909
+ ${r.consoleErrors.join("\n")}` : ""}`
14698
14910
  }
14699
14911
  ]);
14700
14912
  return { content };
14701
14913
  } catch (error51) {
14702
14914
  const msg = error51 instanceof Error ? error51.message : String(error51);
14703
- return { content: [{ type: "text", text: `Render failed: ${msg}` }], isError: true };
14915
+ return {
14916
+ content: [{ type: "text", text: `Render failed: ${msg}` }],
14917
+ isError: true
14918
+ };
14704
14919
  }
14705
14920
  }
14706
14921
  );
@@ -14730,19 +14945,276 @@ server.tool(
14730
14945
  },
14731
14946
  {
14732
14947
  type: "text",
14733
- text: `[${r.engine}] ${r.width}x${r.height}`
14948
+ text: `[${r.engine}] ${r.width}x${r.height}${r.consoleErrors.length ? `
14949
+ \u26A0\uFE0F Console errors:
14950
+ ${r.consoleErrors.join("\n")}` : ""}`
14734
14951
  }
14735
14952
  ]);
14736
14953
  return { content };
14737
14954
  } catch (error51) {
14738
14955
  const msg = error51 instanceof Error ? error51.message : String(error51);
14739
- return { content: [{ type: "text", text: `Screenshot failed: ${msg}` }], isError: true };
14956
+ return {
14957
+ content: [{ type: "text", text: `Screenshot failed: ${msg}` }],
14958
+ isError: true
14959
+ };
14960
+ }
14961
+ }
14962
+ );
14963
+ var DEVICE_PRESETS = {
14964
+ mobile: { width: 375, height: 667 },
14965
+ tablet: { width: 768, height: 1024 },
14966
+ desktop: { width: 1280, height: 800 }
14967
+ };
14968
+ server.tool(
14969
+ "render_responsive",
14970
+ "Render a component at mobile, tablet, and desktop sizes in one call. Returns 3 screenshots for responsive verification.",
14971
+ {
14972
+ code: external_exports.string().describe("Component code to render"),
14973
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14974
+ devices: external_exports.array(external_exports.enum(["mobile", "tablet", "desktop"])).optional().default(["mobile", "tablet", "desktop"]).describe("Device sizes to render")
14975
+ },
14976
+ async ({ code, framework, devices }) => {
14977
+ try {
14978
+ const results = await Promise.all(
14979
+ devices.map(async (device) => {
14980
+ const preset = DEVICE_PRESETS[device];
14981
+ const [result] = await render(code, framework, {
14982
+ width: preset.width,
14983
+ height: preset.height,
14984
+ fullPage: true,
14985
+ engines: ["chromium"]
14986
+ });
14987
+ return { device, ...result };
14988
+ })
14989
+ );
14990
+ const content = results.flatMap((r) => [
14991
+ {
14992
+ type: "image",
14993
+ data: r.image,
14994
+ mimeType: "image/png"
14995
+ },
14996
+ {
14997
+ type: "text",
14998
+ text: `[${r.device}] ${r.width}x${r.height}`
14999
+ }
15000
+ ]);
15001
+ return { content };
15002
+ } catch (error51) {
15003
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15004
+ return {
15005
+ content: [
15006
+ { type: "text", text: `Responsive render failed: ${msg}` }
15007
+ ],
15008
+ isError: true
15009
+ };
15010
+ }
15011
+ }
15012
+ );
15013
+ server.tool(
15014
+ "render_variants",
15015
+ "Render multiple variants of a component (different props/states) in one call. Returns a screenshot for each variant. Use this to verify buttons in all states, theme variations, etc.",
15016
+ {
15017
+ code: external_exports.string().describe("Component code (must export a function that accepts props)"),
15018
+ variants: external_exports.array(
15019
+ external_exports.object({
15020
+ label: external_exports.string().describe("Label for this variant (e.g. 'disabled', 'loading')"),
15021
+ props: external_exports.string().describe("Props as JSON string to pass to the component")
15022
+ })
15023
+ ).describe("Array of variants to render"),
15024
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15025
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15026
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15027
+ },
15028
+ async ({ code, variants, framework, width, height }) => {
15029
+ try {
15030
+ const results = await Promise.all(
15031
+ variants.map(async (variant) => {
15032
+ const wrappedCode = framework === "react" ? `${code}
15033
+ const _VARIANT_PROPS = ${variant.props};
15034
+ function _VariantWrapper() { return <App {..._VARIANT_PROPS} />; }` : code;
15035
+ const renderFramework = framework;
15036
+ const overrideCode = framework === "react" ? wrappedCode.replace(
15037
+ /const _C = typeof App/,
15038
+ "const _C = typeof _VariantWrapper!=='undefined'?_VariantWrapper:typeof App"
15039
+ ) : wrappedCode;
15040
+ const [result] = await render(overrideCode, renderFramework, {
15041
+ width,
15042
+ height,
15043
+ fullPage: true,
15044
+ engines: ["chromium"]
15045
+ });
15046
+ return { label: variant.label, ...result };
15047
+ })
15048
+ );
15049
+ const content = results.flatMap((r) => [
15050
+ {
15051
+ type: "image",
15052
+ data: r.image,
15053
+ mimeType: "image/png"
15054
+ },
15055
+ {
15056
+ type: "text",
15057
+ text: `[${r.label}] ${r.width}x${r.height}${r.consoleErrors.length ? `
15058
+ \u26A0\uFE0F Console errors:
15059
+ ${r.consoleErrors.join("\n")}` : ""}`
15060
+ }
15061
+ ]);
15062
+ return { content };
15063
+ } catch (error51) {
15064
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15065
+ return {
15066
+ content: [
15067
+ { type: "text", text: `Variants render failed: ${msg}` }
15068
+ ],
15069
+ isError: true
15070
+ };
15071
+ }
15072
+ }
15073
+ );
15074
+ server.tool(
15075
+ "audit_a11y",
15076
+ "Run an accessibility audit (axe-core) on a rendered component. Returns WCAG violations with impact level, description, and affected HTML nodes. Use this to catch a11y issues before shipping.",
15077
+ {
15078
+ code: external_exports.string().describe("Component code to audit"),
15079
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15080
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15081
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15082
+ },
15083
+ async ({ code, framework, width, height }) => {
15084
+ try {
15085
+ const result = await auditA11y(code, framework, { width, height });
15086
+ if (result.violations.length === 0) {
15087
+ return {
15088
+ content: [
15089
+ {
15090
+ type: "text",
15091
+ text: `\u2705 No accessibility violations found. (${result.passes} rules passed, ${result.incomplete} need review)`
15092
+ }
15093
+ ]
15094
+ };
15095
+ }
15096
+ const report = result.violations.map((v) => {
15097
+ const nodes = v.nodes.slice(0, 3).map((n) => ` ${n.target.join(" > ")}
15098
+ ${n.html}`).join("\n");
15099
+ return `[${v.impact?.toUpperCase()}] ${v.id}
15100
+ ${v.description}
15101
+ ${v.helpUrl}
15102
+ ${nodes}`;
15103
+ }).join("\n\n");
15104
+ return {
15105
+ content: [
15106
+ {
15107
+ type: "text",
15108
+ text: `Found ${result.violations.length} accessibility violation(s):
15109
+
15110
+ ${report}
15111
+
15112
+ (${result.passes} rules passed, ${result.incomplete} need review)`
15113
+ }
15114
+ ]
15115
+ };
15116
+ } catch (error51) {
15117
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15118
+ return {
15119
+ content: [{ type: "text", text: `A11y audit failed: ${msg}` }],
15120
+ isError: true
15121
+ };
15122
+ }
15123
+ }
15124
+ );
15125
+ server.tool(
15126
+ "diff_component",
15127
+ "Visual regression test: render before/after code and return a pixel diff image with percentage changed. Use this during refactoring to catch unintended visual changes.",
15128
+ {
15129
+ before: external_exports.string().describe("Component code BEFORE the change"),
15130
+ after: external_exports.string().describe("Component code AFTER the change"),
15131
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15132
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15133
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15134
+ },
15135
+ async ({ before, after, framework, width, height }) => {
15136
+ try {
15137
+ const result = await diffComponent(before, after, framework, {
15138
+ width,
15139
+ height
15140
+ });
15141
+ const content = [
15142
+ {
15143
+ type: "text",
15144
+ text: `Visual diff: ${result.diffPercentage}% pixels changed (${result.diffPixels}/${result.totalPixels})`
15145
+ },
15146
+ { type: "text", text: "Before:" },
15147
+ {
15148
+ type: "image",
15149
+ data: result.before,
15150
+ mimeType: "image/png"
15151
+ },
15152
+ { type: "text", text: "After:" },
15153
+ {
15154
+ type: "image",
15155
+ data: result.after,
15156
+ mimeType: "image/png"
15157
+ },
15158
+ { type: "text", text: "Diff (red = changed pixels):" },
15159
+ {
15160
+ type: "image",
15161
+ data: result.diff,
15162
+ mimeType: "image/png"
15163
+ }
15164
+ ];
15165
+ return { content };
15166
+ } catch (error51) {
15167
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15168
+ return {
15169
+ content: [{ type: "text", text: `Diff failed: ${msg}` }],
15170
+ isError: true
15171
+ };
15172
+ }
15173
+ }
15174
+ );
15175
+ server.tool(
15176
+ "capture_animation",
15177
+ "Capture multiple frames of a CSS animation or transition over time. Returns sequential screenshots to verify animation behavior, timing, and smoothness.",
15178
+ {
15179
+ code: external_exports.string().describe("Component code with CSS animations/transitions"),
15180
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15181
+ frames: external_exports.number().optional().default(5).describe("Number of frames to capture"),
15182
+ duration: external_exports.number().optional().default(1e3).describe("Total capture duration in ms"),
15183
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15184
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15185
+ },
15186
+ async ({ code, framework, frames, duration: duration3, width, height }) => {
15187
+ try {
15188
+ const results = await captureAnimation(code, framework, {
15189
+ frames,
15190
+ duration: duration3,
15191
+ width,
15192
+ height
15193
+ });
15194
+ const content = results.flatMap((r) => [
15195
+ {
15196
+ type: "image",
15197
+ data: r.image,
15198
+ mimeType: "image/png"
15199
+ },
15200
+ {
15201
+ type: "text",
15202
+ text: `[${r.timestamp}ms]`
15203
+ }
15204
+ ]);
15205
+ return { content };
15206
+ } catch (error51) {
15207
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15208
+ return {
15209
+ content: [
15210
+ { type: "text", text: `Animation capture failed: ${msg}` }
15211
+ ],
15212
+ isError: true
15213
+ };
14740
15214
  }
14741
15215
  }
14742
15216
  );
14743
15217
  var transport = new StdioServerTransport();
14744
- warmup(["chromium"]).catch(() => {
14745
- });
14746
15218
  await server.connect(transport);
14747
15219
  process.on("SIGINT", async () => {
14748
15220
  await shutdown();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frameshot-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Instant cross-browser component preview for AI agents. One MCP call, one screenshot — no dev server, no Storybook, no ceremony.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,6 +17,9 @@
17
17
  "dev": "tsup src/index.ts --format esm --watch",
18
18
  "start": "node dist/index.js",
19
19
  "typecheck": "tsc --noEmit",
20
+ "lint": "biome check src/",
21
+ "lint:fix": "biome check --write src/",
22
+ "format": "biome format --write src/",
20
23
  "prepublishOnly": "npm run build"
21
24
  },
22
25
  "keywords": [
@@ -49,10 +52,15 @@
49
52
  },
50
53
  "dependencies": {
51
54
  "@modelcontextprotocol/sdk": "^1.12.1",
52
- "playwright": "^1.52.0"
55
+ "axe-core": "^4.12.1",
56
+ "pixelmatch": "^7.2.0",
57
+ "playwright": "^1.52.0",
58
+ "pngjs": "^7.0.0"
53
59
  },
54
60
  "devDependencies": {
61
+ "@biomejs/biome": "^2.5.0",
55
62
  "@types/node": "^22.0.0",
63
+ "@types/pngjs": "^6.0.5",
56
64
  "tsup": "^8.0.0",
57
65
  "typescript": "^5.7.0"
58
66
  },