2026.04.01

Figmaをやめて、PenpotとClaudeで開発してみた

Figmaをやめてみた。PenpotとClaudeで開発してみた

もくじ


TL;DR

  • Penpot(OSS の Figma 代替)を Docker 環境でセルフホストし、デザインデータを完全にローカルで管理
  • Claude Code + MCP(Model Context Protocol) で Penpot をプログラマティックに操作。自然言語の指示だけで Web ページデザインが完成
  • Playwrightを使って画面から確認しながら、自動操作
  • AgentTeamsで何度も確認しながら、一致させる
  • デザインファイルの git 管理Next.js コード自動生成まで実現
  • セキュリティ要件の厳しいエンタープライズ環境でも導入可能な、新しい AI 駆動デザインワークフローの提案


1. はじめに

GMOインターネットグループ AI研究開発室のK.Sです。

「AIでデザインが自動生成できる」「Figmaのプラグインで業務効率化」という話があるが、
とはいえ、実際の開発現場では「デザインとコードの間にある溝」は依然として深く、多くのエンジニアがその橋渡しに日々苦労しているのが現実です。

私が所属するチームでも、長らく Figma を使ってきました。

しかし、いくつかの課題が無視できなくなってきました。

SaaS 型デザインツールの課題

デザインとコードの断絶

Figmaで作ったデザインをコードに落とし込む際、「Figmaではこうなっているけど、CSSだとこう書かないといけない」というやり取りがあります。

Figma の Dev Mode やコード生成機能も登場しましたが、実際のプロダクションコードとの乖離は依然として大きく、結局は手作業での調整が必要になります。

デザインのバージョン管理

コードは Git で管理されるのに、デザインは Figma のバージョン履歴に頼るしかありません。「誰がいつ何を変えたか」のトラッキングは限定的です。

ブランチ機能はありますが、Git ほど柔軟ではなく、マージコンフリクトの解消も直感的とは言えません。

エンジニアがデザインツールを使いこなすコスト

 

もし、AI に指示するだけでデザインができたら?

Macなどローカル環境で、Penpot(OSSのデザインツール)と Claude Code(AnthropicのAIコーディングエージェント)を MCP(Model Context Protocol) で接続し、このワークフローを実現した事例をご紹介します。

 

構成の全体像を先にお見せすると、こうなります。

[人] --自然言語--> [Claude Code CLI]
                            |
                            | MCP (JSON-RPC over SSE)
                            v
                    [MCP Server :4401]
                            |
                            | WebSocket
                            v
                    [Penpot Plugin iframe :4400]
                            |
                            | Plugin API (JavaScript)
                            v
                    [Penpot (Docker) :9001]
                            |
                    [PostgreSQL + ファイルシステム]

上から下まで、すべてローカル環境で完結します。デザインデータが外部に出ないです。

 

※実際に弊社の採用ページをclaude codeからの自然言語の指示だけで再現できました。

 

2. Penpot とは

OSS のデザインプラットフォーム

Penpotは、スペインの Kaleidos 社が開発するオープンソースのデザイン&プロトタイピングプラットフォームです。ライセンスは MPL-2.0(Mozilla Public License 2.0)で、自由にセルフホストが可能です。

2025年5月にリリースされた Penpot 2.x 系では、UIの大幅刷新、グリッドレイアウト対応、コンポーネントシステムの強化など、Figma に匹敵する機能が続々と追加されています。

Penpot の主な特徴

SVG ネイティブ

Penpot はデザインデータを内部的に SVG で管理しています。これは Figma が独自フォーマットを使っているのとは対照的で、エクスポートの品質が高く、Web 標準との親和性が抜群です。

Plugin API

Penpot 2.x では Plugin API が搭載され、JavaScript からプログラマティックにデザインを操作できるようになりました。ボードの作成、テキストの配置、画像のアップロード、Flex レイアウトの設定、プロトタイプのインタラクション設定

ほぼすべての操作を API 経由で行えます。

Plugin API があるから、Claude Code からの自動操作が実現できます。

セルフホスト可能

Docker Compose 一発で起動できる公式構成が提供されており、オンプレミスでの運用が容易です。データは手元の PostgreSQL とファイルシステムに保存され、クラウドへの依存はゼロです。

マルチプラットフォーム

Web ブラウザベースなので、macOS / Windows / Linux を問わず動作します。デスクトップアプリのインストールも不要です。

Figma との機能比較

Figma と Penpot の、比較を以下にまとめます。

 

項目 Figma Penpot
コラボレーション 非常に優秀。リアルタイム編集、コメント、Dev Mode リアルタイム編集対応。Figmaほどの洗練度はまだない
プラグインエコシステム 巨大。数千のプラグインが利用可能 成長中。Plugin APIは強力だがエコシステムは発展途上
コンポーネントシステム 成熟。Variants、Auto Layout、Variables 2.xで大幅改善。Flex Layout、Grid Layout対応
プロトタイピング 高度なアニメーション、Smart Animate 基本的な画面遷移。アニメーションは限定的
データフォーマット 独自フォーマット SVGネイティブ(Web標準準拠)
セルフホスト 不可(SaaSのみ) 可能(Docker Composeで簡単構築)
ライセンス プロプライエタリ MPL-2.0(OSS)
料金 無料プランあり / Pro $15/月〜 完全無料(セルフホスト) / SaaS版あり
API / 自動化 REST API、Figma MCP Plugin API、Penpot MCP
オフライン利用 限定的 セルフホストならフルオフライン可能

Penpot は「自由度と拡張性のプラットフォーム」です。特に、セルフホストができること、Plugin API でプログラマティックに操作できること。

率直に言えば、デザイナーが手作業でUIデザインを行う用途では、Figma のほうが快適です。

しかし、AI と連携してプログラマティックにデザインを生成するという用途では、話が変わります。
OSS であること、セルフホストできること、Plugin API が充実していることがAIとの相性がよいです。


3. セキュリティ懸念と解決策

SaaS 型デザインツールのリスク

デザインデータは、多くの人が思っている以上に機密性が高い情報です。

  • 未発表サービスの画面設計: 競合に知られたくないUIコンセプト
  • 社内ツールのUI: セキュリティ機能の配置、管理画面のレイアウト
  • 顧客データの表示形式: 個人情報がどのように表示されるかの設計
  • 金融系の取引画面: 規制対応の画面フロー

Penpot オンプレ運用のメリット

Penpot をセルフホストした場合、すべてのデータは Docker Volume 内に閉じており、ホストマシンのディスク上に存在します。

ネットワーク要件

Penpot のセルフホスト構成は、完全にオフラインで動作します。

唯一の例外は、PENPOT_TELEMETRY_ENABLED: true の設定です。これは匿名の利用統計を Penpot チームに送信する機能で、false に設定すれば完全にオフにできます。テレメトリの内容はオープンソースで監査可能です。

 

機密性の高いデザインデータ本体はローカルに閉じたまま、AI の「頭脳」だけをクラウドから借りている

データプレーンとコントロールプレーンが分離しています。


4. 環境構築

前提環境

この記事の環境構築には、以下が必要です。

docker-compose.yaml の解説

Penpot 公式の docker-compose.yaml を使います。

フラグ設定

x-flags: &penpot-flags
  PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookie

開発用途では、disable-email-verification(メール認証スキップ)と disable-secure-session-cookies(HTTPS 不要でセッションCookieを許可)を有効にしています。本番環境でインターネットに公開する場合は、これらのフラグを外してください

enable-prepl-server は Penpot の REPL サーバーを有効にするフラグで、デバッグに使えます。

公開URL設定

x-uri: &penpot-public-uri
  PENPOT_PUBLIC_URI: http://localhost:9001

ローカル開発なので localhost:9001 を指定しています。

 

Penpot の起動

# docker-compose.yaml があるディレクトリで 
docker compose up -d

初回はイメージのダウンロードに数分かかります。全サービスが起動したら、ブラウザで http://localhost:9001 にアクセスします。

初回セットアップ

  1. http://localhost:9001 にアクセス
  2. 「Create an account」をクリック
  3. メールアドレスとパスワードを入力して登録(disable-email-verification フラグにより、メール確認は不要)
  4. ログイン後、新規プロジェクトを作成
  5. プロジェクト内に新規ファイルを作成

これで Penpot のデザインファイルが1つ用意できました。

 

 

 

MCP Plugin のインストールと設定

Penpot を Claude Code から操作するために、MCP(Model Context Protocol)の仕組みを導入します。

次にexecute_code です。

このツールを通じて、Claude Code が Penpot の Plugin API を直接呼び出し、デザインを構築します。

 

MCP Serverpackages/server)は Express ベースの HTTP サーバーで、ポート 4401 で待ち受けます。

Claude Code からの MCP リクエスト(SSE または Streamable HTTP)を受け取り、WebSocket 経由で Plugin に転送します。

Penpot Pluginpackages/plugin)は、Penpot のブラウザ UI 内の iframe として動作します。MCP Server と WebSocket で接続し、受け取った JavaScript コードを Penpot の Plugin API コンテキスト内で実行します。

Claude Code
    |
    | (1) SSE 接続 → セッション確立
    | (2) JSON-RPC: tools/call "execute_code" { code: "..." }
    v
MCP Server (:4401)  ---- Express + SSE/Streamable HTTP
    |
    | (3) WebSocket 経由でタスクを送信
    v
Plugin Bridge  ---- WebSocket Server (:4402)
    |
    | (4) タスクをプラグインに転送
    v
Penpot Plugin (iframe)  ---- ブラウザ内で動作
    |
    | (5) new Function() でコードを動的実行
    | (6) penpot オブジェクトを通じて Plugin API を呼び出し
    v
Penpot Backend
    |
    | (7) デザインデータの読み書き
    v
PostgreSQL + ファイルシステム

 

Plugin 側の ExecuteCodeTaskHandler です。受け取った JavaScript コード文字列を new Function() で動的に実行し、penpot オブジェクト(Plugin API のエントリポイント)にアクセスできるコンテキストを提供します。

// ExecuteCodeTaskHandler.ts(抜粋・簡略化)
private readonly context = {
    penpot: penpot,        // Penpot Plugin API
    storage: {},           // 実行間で状態を共有するオブジェクト
    console: new ExecuteCodeTaskConsole(), // ログキャプチャ用
    penpotUtils: PenpotUtils,
};
async handle(task) {
    const fn = new Function(
        ...Object.keys(this.context),
        `return (async () => { ${task.params.code} })();`
    );
    const result = await fn(...Object.values(this.context));
    task.sendSuccess({ result, log: this.context.console.getLog() });
}

 

storage オブジェクトは実行間で永続化されるため、Claude Code が「まずデータを取得して storage に保存 → 次の呼び出しで storage の値を使って処理」という複数ステップのワークフローを実現できます。

MCP Server の起動

# penpot-repo/mcp ディレクトリで
cd penpot-repo/mcp
pnpm install
pnpm dev

Penpot へのプラグイン追加

MCP Server が起動したら、Penpot 側でプラグインを追加します。

  1. Penpot のデザインファイルを開いた状態で、プラグインマネージャーを開く
  2. プラグインの URL として http://localhost:4400 を入力(MCP Plugin の dev サーバーが配信する URL)
  3. 「INSTALL」をクリック
  4. プラグインパネルに「Penpot MCP Plugin」が表示される
  5. 「OPEN」をクリック → プラグインの iframe が開く
  6. iframe 内の「Connect」ボタンをクリック

「Connect」ボタンをクリックすると、Plugin が MCP Server の WebSocket ポート(4402)に接続します。MCP Server のログに New WebSocket connection established と表示されれば成功です。

Claude Code の MCP 設定

最後に、Claude Code 側に MCP Server の接続情報を設定します。

プロジェクトの .mcp.json に以下を記述します。

 

{
  "mcpServers": {
    "penpot": {
      "url": "http://localhost:4401/sse"
    }
  }
}

これで Claude Code が起動時に Penpot MCP Server と SSE 接続を確立し、execute_code などのツールが利用可能になります。

動作確認

すべての接続が完了したら、簡単な動作確認。Claude Code に以下のように話しかけます。
Penpot で新しいボードを作成して、赤い四角形を配置してください

Claude Code は以下のような流れで処理します。

  1. high_level_overview ツールで現在のファイル状態を確認
  2. penpot_api_info ツールで必要な API の使い方を確認
  3. execute_code ツールで JavaScript コードを実行

// Claude Code が生成・実行する JavaScript(例)
const board = penpot.createBoard();
board.name = "テストボード";
board.x = 0;
board.y = 0;
board.resize(400, 300);
board.fills = [{ fillColor: "#FFFFFF", fillOpacity: 1 }];
const rect = penpot.createRectangle();
rect.name = "赤い四角形";
rect.x = 50;
rect.y = 50;
rect.resize(200, 150);
rect.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }];
board.appendChild(rect);
return "ボードと四角形を作成しました";

 

Penpot のキャンバス上に、白い背景のボードと赤い四角形が現れるはずです。

MCP の仕組み図

ここまでの構成を図にまとめます。

┌─────────────────────────────────────────────────────────────────┐
│  ローカルマシン                                                   │
│                                                                  │
│  ┌──────────────┐     ┌──────────────────────────────────────┐   │
│  │ Claude Code  │     │  Docker Compose                      │   │
│  │   CLI        │     │                                      │   │
│  │              │     │  ┌──────────┐  ┌──────────────────┐  │   │
│  │  MCP Host    │     │  │ Frontend │  │ Backend          │  │   │
│  └──────┬───────┘     │  │ :9001    │  │ (Clojure)        │  │   │
│         │             │  └────┬─────┘  └────────┬─────────┘  │   │
│         │ SSE/HTTP    │       │                 │            │   │
│         v             │       │                 │            │   │
│  ┌──────────────┐     │       v                 v            │   │
│  │ MCP Server   │     │  ┌─────────┐     ┌──────────────┐    │   │
│  │ :4401        │     │  │Exporter │     │ PostgreSQL   │    │   │
│  │ (Express)    │     │  │(Chrome) │     │ :5432        │    │   │
│  └──────┬───────┘     │  └─────────┘     └──────────────┘    │   │
│         │             │                                      │   │
│         │ WebSocket   │  ┌──────────┐    ┌──────────────┐    │   │
│         v             │  │ Valkey   │    │ Mailcatcher  │    │   │
│  ┌──────────────┐     │  │ (Redis)  │    │ :1080        │    │   │
│  │ Penpot       │     │  └──────────┘    └──────────────┘    │   │
│  │ Plugin       │     │                                      │   │
│  │ (iframe)     │     └──────────────────────────────────────┘   │
│  └──────────────┘                                                │
│                                                                  │
│         ──── ネットワーク通信はすべて localhost 内 ────              │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
                          │
                          │ Anthropic API(プロンプトとツール呼び出しのみ)
                          v
                   ┌──────────────┐
                   │  Claude API  │
                   │  (Anthropic) │
                   └──────────────┘

外部に出るのは Claude API への通信のみであり、それも「テキストベースのプロンプトとツール呼び出し命令」に限られる。

実際のデザインデータ(画像やSVG)は全部ローカルに残ります。

MCP Server と Plugin は、Claude Code のセッションが生きている間は接続を維持します。

ただし、Plugin の iframe が長時間放置されると WebSocket が切断されることがあります。その場合は Plugin を再度 Open → Connect するだけで復帰できます(この「切断と再接続」の自動化については、次のセクション以降で Playwright を使った解決策を紹介します)。

ここまでで、Penpot + Claude Code + MCP の環境が整いました。

次は、この環境を使って実際にデザインを自動生成していく過程を書いていきます。


5. 全体アーキテクチャ

環境が構築できたところで、「Claude CodeがPenpotをどう操作しているのか」全体像の構成です。

システム構成図

┌─────────────────────────────────────────────────────────────────────┐
│                        開発者のマシン (macOS)                        │
│                                                                    │
│  ┌──────────────┐     ┌──────────────────┐     ┌────────────────┐  │
│  │ Claude Code  │────→│ MCP SSE Client   │────→│ MCP Server     │  │
│  │    CLI       │     │ (Python)         │     │  :4401         │  │
│  └──────┬───────┘     └──────────────────┘     └───────┬────────┘  │
│         │                                              │           │
│         │ Playwright CDP                    SSE / JSON-RPC         │
│         │ (:9222)                                      │           │
│         ▼                                              ▼           │
│  ┌──────────────┐     ┌──────────────────────────────────────────┐ │
│  │   Chrome     │────→│         Penpot Frontend  :9001           │ │
│  │  (headed)    │     │                                          │ │
│  └──────────────┘     │  ┌────────────────────────────────────┐  │ │
│                       │  │  Plugin iframe  :4400              │  │ │
│                       │  │  ┌──────────────────────────────┐  │  │ │
│                       │  │  │  execute_code(js)            │  │  │ │
│                       │  │  │  → penpot.createBoard()      │  │  │ │
│                       │  │  │  → penpot.createText()       │  │  │ │
│                       │  │  │  → penpot.uploadMediaUrl()   │  │  │ │
│                       │  │  └──────────────────────────────┘  │  │ │
│                       │  └────────────────────────────────────┘  │ │
│                       └──────────────┬───────────────────────────┘ │
│                                      │                             │
│  ┌───────────────────────────────────┼──── Docker ───────────────┐ │
│  │                                   ▼                           │ │
│  │  ┌──────────────┐  ┌──────────────────┐  ┌────────────────┐   │ │
│  │  │  PostgreSQL  │  │  Penpot Backend  │  │     MinIO      │   │ │
│  │  │  (メタデータ)  │ │  (Clojure)        │  │  (画像ストレージ) │  │ │
│  │  └──────────────┘  └──────────────────┘  └────────────────┘   │ │
│  └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

データの流れはシンプルです。

  1. Claude Code CLI が自然言語の指示を受け取り、Penpot Plugin APIのJavaScriptコードを生成する
  2. そのコードが MCP SSE ClientMCP Server を経由して、Penpotのブラウザ上で動作する Plugin iframe に届く
  3. Plugin iframe内でJavaScriptが実行され、Penpotの Plugin API を通じてデザイン要素が作成・編集される
  4. デザインデータは PostgreSQL(メタデータ)と MinIO(画像)に保存される
  5. PlaywrightChrome DevTools Protocol (CDP) 経由でブラウザを監視・制御する

 

ここでややこしいのが、通信経路が2本あるところです。

一つはMCPを通じたPlugin APIの実行経路、

もう一つはPlaywrightを通じたブラウザ制御の経路です。(こっちが結構重要でした)

 

MCP (Model Context Protocol) の仕組み

Penpot用のMCPサーバーがポート4401で動作し、execute_code というツールを提供しています。

通信の流れです。

Claude Code                MCP Server (:4401)              Plugin iframe
    │                           │                               │
    │── SSE接続 (/sse) ────────→ │                               │
    │←── session endpoint ──────│                               │
    │                           │                               │
    │── initialize (JSON-RPC) ─→│                               │
    │←── capabilities ──────────│                               │
    │                           │                               │
    │── tools/call ────────────→│                               │
    │   execute_code:           │── eval(code) ────────────────→│
    │   "penpot.createBoard()"  │                               │
    │                           │←── result ────────────────────│
    │←── tool result ───────────│                               │

 

最初にSSE(Server-Sent Events)でセッションを確立し、そのセッション上でJSON-RPCのやり取りをします。execute_code ツールが呼ばれると、渡されたJavaScriptコードがPlugin iframe内で eval() 的に実行され、結果がJSON-RPCのレスポンスとして返ってきます。

mcp_sse_client.py のコード解説

MCPクライアントの実装は、標準ライブラリだけで書かれたシンプルなPythonスクリプトです。

import http.client
import json
import sys
import time
def sse_connect_and_call(code):
    """Connect via SSE, get session, then call tool"""
    # Step 1: SSEエンドポイントに接続してセッション取得
    conn = http.client.HTTPConnection("localhost", 4401, timeout=180)
    conn.request("GET", "/sse", headers={"Accept": "text/event-stream"})
    resp = conn.getresponse()
    # SSEイベントを読んでendpointを取得
    endpoint = None
    buf = b""
    while True:
        chunk = resp.read(1)
        if not chunk:
            break
        buf += chunk
        if buf.endswith(b"\n\n"):
            text = buf.decode()
            buf = b""
            for line in text.strip().split("\n"):
                if line.startswith("data: "):
                    data = line[6:].strip()
                    if data.startswith("/"):
                        endpoint = data
                        break
            if endpoint:
                break

SSEのストリームを 1バイトずつ 読んでいます。

SSEはHTTPのレスポンスボディが改行区切りで流れ続けるプロトコルなので、バッファリングしながらイベント境界(\n\n)を検出しています。

セッション確立後のツール呼び出し部分はこうなっています。

# Step 4: ツール呼び出し
    conn4 = http.client.HTTPConnection("localhost", 4401, timeout=120)
    body = json.dumps({
        "jsonrpc": "2.0",
        "method": "tools/call",
        "params": {
            "name": "execute_code",
            "arguments": {"code": code}
        },
        "id": 2
    })
    conn4.request("POST", endpoint, body, {"Content-Type": "application/json"})

 

execute_code にJavaScriptコードの文字列を渡すだけ。シンプルですが、これがClaude CodeとPenpotを繋ぐブリッジです。

Claude Codeは毎回、実行したい操作に対応するJavaScriptコードを丸ごと生成して送り込みます。

 


6. ブラウザが固まる問題

最初のアプローチ: MCPだけで操作

最初のワークフローはこうでした。

  1. Claude Codeに「ボードを作って、テキストを配置して」と指示する
  2. Claude CodeがPlugin APIのコードを生成する
  3. MCPクライアント経由でコードを実行する
  4. 結果のJSONが返ってくる

テキスト1個、矩形1個くらいなら、これで何の問題もなく動きます。ところが、実際のWebページのデザインを再現しようとすると話は別です。

ヘッダー、ヒーローセクション、カード6枚、バナー3枚、フッター……要素数が増えてくると、Penpotのブラウザタブが固まり始めます。特に 要素の削除 が致命的でした。

// これが地雷 
element.remove();

element.remove() を実行すると、Penpotのフロントエンドが内部状態の再計算を始めるようで、大量の要素があるページでは高確率でフリーズします。

そしてフリーズすると、Plugin iframeのSSE接続が切れ、MCPサーバーとの通信が途絶えます。

フリーズが深刻だとPenpot自体がクラッシュし、ブラウザにこんなエラー画面が表示されます。

Something went wrong
[RETRY]

この「RETRY」ボタンをクリックすれば復帰できるのですが、MCPクライアントからはブラウザの画面が見えません。コードの実行結果が返ってこないまま、タイムアウトを待つしかないのです。

あるスクリプトを実行して成功。同じスクリプトをもう一度実行するとフリーズ。3回目は成功。4回目はPenpotがクラッシュ。体感では 5回に1回程度しか安定して完走しない 状態でした。

原因の特定も困難です。Plugin APIはエラー情報をほとんど返してくれません。成功した場合は結果のJSONが返りますが、失敗した場合は「何も返ってこない」だけです。ログを見ても、Penpotのバックエンド側には何も出ていないことがほとんどでした。問題はフロントエンドのiframe内で起きているため、ブラウザのDevToolsを開かない限り状況が分からないのです。

 

ブラウザを制御できないことの限界

MCPだけのアプローチには、根本的な限界がありました。

  • スクリーンショットが撮れない: デザインが意図通りにできているか確認するには、ブラウザの画面を見るしかない。しかしClaude Codeはブラウザの画面を見ることができない
  • エラー画面から復帰できない: 「RETRY」ボタンをクリックする手段がない
  • プラグインの再接続ができない: Plugin iframeが切断されたら、Penpotの「Plugins」メニュー → 「OPEN」 → iframe内の「Connect」ボタンを手動でクリックする必要がある

これらは全て「ブラウザのUIを操作する」必要がある作業です。MCPはPenpotの データ操作 はできますが、UI操作 はスコープ外なのです。

結局のところ、MCPだけのアプローチでは、何かトラブルが起きるたびに人間がブラウザの前に座って手動でリカバリする必要がありました。

これでは「AIに任せてデザインを作る」とは到底言えません。

この問題を解決するために導入したのが、Playwrightです。


7. Playwright導入

なぜPlaywrightを入れたか

Playwright は本来、Webアプリケーションのエンドツーエンドテスト用のブラウザ自動化フレームワークです。しかし今回は、テストではなく ブラウザの運用管理ツール として使います。

決め手になったのは、PlaywrightがChrome DevTools Protocol(CDP)経由で 既に起動中のブラウザに接続できる という機能です。

// CDPで既存のブラウザに接続
const browser = await chromium.connectOverCDP('http://localhost:9222');

ポート9222でリモートデバッグを有効にしてChromeを起動しておけば、あとからPlaywrightで接続して、ページの操作やスクリーンショットの取得ができます。Penpotが動いているブラウザに外から手を突っ込めるわけです。

MCPでは手が届かなかった以下の操作が全て自動化できるようになりました。

  • ブラウザの起動とPenpotへのログイン
  • スクリーンショットの取得(Claude Codeがデザインを確認できる)
  • エラー画面からの自動復帰(RETRYボタンのクリック)
  • Pluginの再接続(メニュー操作 → iframe内のボタンクリック)
  • ズーム操作やページ切り替え

penpot-pilot.mjs — ブラウザ操作

Playwrightによるブラウザ操作を一つのCLIツールにまとめたのが penpot-pilot.mjs です。

#!/usr/bin/env node
/**
 * Penpot Pilot - Playwright-based browser automation for Penpot
 *
 * Commands:
 *   launch              - Launch browser, login, open project
 *   screenshot [file]   - Take screenshot of current view
 *   page <name>         - Switch to a page by name
 *   zoom-fit            - Zoom to fit all content
 *   plugin-connect      - Connect MCP plugin
 *   status              - Current page info
 *   click-project <name>- Click a project to open it
 */

launch: ブラウザ起動 + 自動ログイン

async function launchNew() {
  const browser = await chromium.launchPersistentContext(USER_DATA_DIR, {
    headless: false,
    args: [`--remote-debugging-port=${CDP_PORT}`, '--no-first-run'],
    viewport: { width: 1440, height: 900 },
  });
  const page = browser.pages()[0] || await browser.newPage();
  await page.goto(PENPOT_URL, { waitUntil: 'networkidle', timeout: 30000 });
  return { browser, page, needsClose: false };
}

launchPersistentContext で永続的なプロファイルディレクトリ(/tmp/penpot-playwright-profile)を使ってブラウザを起動します。ログインセッションが維持されるので、毎回パスワードを入力する必要がありません。

--remote-debugging-port=9222 が重要で、CDP経由での外部接続が可能になります。MCPの操作とPlaywrightの操作を 同じブラウザインスタンス に対して行えるのです。

screenshot: 現在のビューをキャプチャ

async function doScreenshot(page, filename) {
  const name = filename || `penpot-${Date.now()}.png`;
  const filepath = name.startsWith('/') ? name : join(SCREENSHOTS_DIR, name);
  await page.screenshot({ path: filepath });
  console.log(filepath);
}

Claude Codeは生成したスクリーンショットを確認して、「テキストが小さすぎる」「余白が足りない」といったフィードバックを自分自身で行い、修正スクリプトを生成します。

人間が画面を見なくても、デザインのイテレーションが回るようにしました。

connectPlugin: MCPプラグイン接続

async function connectPlugin(page) {
  try {
    // Pluginsメニューを開く
    const pluginBtn = page.locator('button[title="Plugins"]').first();
    if (await pluginBtn.isVisible({ timeout: 2000 })) {
      await pluginBtn.click();
      await page.waitForTimeout(1000);
    }
    // "OPEN" ボタンをクリック
    const openBtn = page.locator('button:has-text("OPEN")').first();
    if (await openBtn.isVisible({ timeout: 2000 })) {
      await openBtn.click();
      await page.waitForTimeout(1000);
    }
    // Plugin iframe 内の "CONNECT" ボタンをクリック
    const connectBtn = page.locator('button:has-text("CONNECT")').first();
    if (await connectBtn.isVisible({ timeout: 3000 })) {
      await connectBtn.click();
      await page.waitForTimeout(1000);
    }
    console.log('Plugin connection attempted');
  } catch (e) {
    console.error(`Plugin connect failed: ${e.message}`);
  }
}

PluginメニューのUI操作を自動化しています。「Plugins」ボタン → 「OPEN」 → iframe内の「CONNECT」という3ステップを順番にクリックするだけですが、手動でやるとこれが地味に面倒なのです。特にPenpotがクラッシュして復帰するたびにプラグインが切断されるので、何十回もこの操作を繰り返すことになります。

既存ブラウザへの接続

async function connectToExisting() {
  try {
    const browser = await chromium.connectOverCDP(`http://localhost:${CDP_PORT}`);
    const contexts = browser.contexts();
    for (const ctx of contexts) {
      for (const p of ctx.pages()) {
        if (p.url().includes('localhost:9001')) {
          return { browser, page: p, needsClose: false };
        }
      }
    }
    // Penpotページが見つからない場合は新しいタブで開く
    const ctx = contexts[0];
    const page = await ctx.newPage();
    await page.goto(PENPOT_URL, { waitUntil: 'networkidle', timeout: 30000 });
    return { browser, page, needsClose: false };
  } catch {
    return null;
  }
}

CDPで接続した後、全ページをスキャンして localhost:9001(Penpot)が開かれているタブを探します。見つかればそのページを返し、見つからなければ新しいタブを開く。この「既に動いているものに繋ぐ」アプローチが、MCPとPlaywrightの共存を可能にしています。

reconnect-plugin.mjs — 自動リカバリ

penpot-pilot.mjs は汎用的なブラウザ操作ツールですが、最も頻繁に使うのは「クラッシュからの復帰」です。

import { chromium } from 'playwright';
const CDP_PORT = 9222;
async function main() {
  const browser = await chromium.connectOverCDP(`http://localhost:${CDP_PORT}`);
  const contexts = browser.contexts();
  let penpotPage = null;
  for (const ctx of contexts) {
    for (const p of ctx.pages()) {
      if (p.url().includes('localhost:9001')) {
        penpotPage = p;
        break;
      }
    }
    if (penpotPage) break;
  }
  if (!penpotPage) {
    console.error('No Penpot page found');
    process.exit(1);
  }
  // Step 1: エラー画面ならRETRYをクリック
  try {
    const retryBtn = penpotPage.locator('button:has-text("RETRY")');
    if (await retryBtn.isVisible({ timeout: 2000 })) {
      console.log('Error page, clicking RETRY...');
      await retryBtn.click();
      await penpotPage.waitForTimeout(8000); // リロードに8秒待つ
    }
  } catch {}
  // Step 2: ワークスペースの読み込みを待つ
  try {
    await penpotPage.waitForSelector('button[title*="Plugins"]', { timeout: 15000 });
    console.log('Workspace loaded');
  } catch (e) {
    console.log('Workspace not ready');
    process.exit(1);
  }
  // Step 3: Pluginsボタンをクリック
  const pluginBtn = penpotPage.locator('button[title*="Plugins"]');
  await pluginBtn.click();
  await penpotPage.waitForTimeout(1500);
  console.log('Clicked Plugins');
  // Step 4: Openをクリック
  try {
    const openBtn = penpotPage.locator(
      'button:has-text("Open"), button:has-text("OPEN")'
    ).first();
    await openBtn.waitFor({ timeout: 3000 });
    await openBtn.click();
    await penpotPage.waitForTimeout(2000);
    console.log('Clicked Open');
  } catch {}
  // Step 5: iframe内のConnectをクリック
  try {
    const iframe = penpotPage.frameLocator('iframe[src*="localhost:4400"]');
    const connectBtn = iframe.locator('button').filter({ hasText: /connect/i }).first();
    await connectBtn.waitFor({ timeout: 5000 });
    await connectBtn.click();
    await penpotPage.waitForTimeout(2000);
    console.log('Connected!');
  } catch (e) {
    console.log('Connect failed: ' + e.message);
  }
  await browser.close();
}

 

以前の手動運用では、こんな流れでした。

  1. MCPコマンドがタイムアウトする
  2. ブラウザの画面を見に行く
  3. 「あ、またクラッシュしてる」
  4. RETRYをクリックする
  5. 8秒くらい待つ
  6. Pluginsメニューを開く
  7. OPENをクリック
  8. Connectをクリック
  9. MCPコマンドを再実行する

これが1時間の作業で10回以上発生するのです。都度手作業でやっていたら、デザイン作業より復旧作業の方が長くなってしまいます。

自動化後は、Claude Codeが自分で「あ、MCP接続切れたな」と判断し、reconnect-plugin.mjs を実行して復帰し、中断したスクリプトを再実行するようになりました。人間は最初に launch コマンドでブラウザを起動するだけで、あとはClaude Codeに任せておけます。

iframe内のボタンを叩く難しさ

reconnect-plugin.mjs のコードで一つ注目してほしいのが、Step 5のiframe内の操作です。

const iframe = penpotPage.frameLocator('iframe[src*="localhost:4400"]'); 
const connectBtn = iframe.locator('button').filter({ hasText: /connect/i }).first();

Penpotのプラグインは iframe として埋め込まれており、メインページのDOMからは直接アクセスできません。Playwrightの frameLocator を使って、まずiframeを特定し、その中のボタンを探す必要があります。

Playwrightが入ったことで変わったこと

Playwrightの導入前と後を比較です。

項目 Playwright導入前 Playwright導入後
デザイン確認 人がブラウザを目視で確認 スクリーンショットを用いた Claude Code の自動検証
クラッシュ時の復帰 手動対応(RETRY → Plugin 再接続) 自動復帰(reconnect-plugin.mjs により再接続)
Plugin 切断対応 手動で 3 ステップの再接続 切断を自動検知し自動再接続
デザイン反復(1サイクル) 5〜10分(手動復帰時間を含む) 30秒〜1分
夜間バッチ運用 不可(人の介在が必要) 可(無人で継続実行可能)

 

特に「Claude Codeが自分でスクリーンショットを撮って確認する」というのが強力です。人間が「もうちょっと左」「もうちょっと大きく」と指示しなくても、Claude Code自身がデザインの品質を判断してイテレーションを回せます。

 


8. Claude Codeだけでデザインを完成させる

Playwright + MCPで、いよいよ実際のデザイン制作をしました。

今回はGMO次世代システム研究室の採用ページをPenpot上に再現してみました。

実際のワークフロー

デザインの制作は、以下のサイクルを何十回も繰り返して行いました。

┌───────────────────────────────────────────────────┐
│                                                   │
│  ① Claude Codeに指示                               │
│     「ヒーローセクションを作って。背景画像は          │
│      こちらのURL、ダークオーバーレイかけて」          │
│               │                                   │
│               ▼                                   │
│  ② Plugin APIのJSコードを生成                      │
│               │                                   │
│               ▼                                   │
│  ③ MCP経由で実行                                   │
│     (失敗したら → reconnect → リトライ)            │
│               │                                   │
│               ▼                                   │
│  ④ Playwrightでスクリーンショット撮影                │
│               │                                   │
│               ▼                                   │
│  ⑤ Claude Codeが画像を確認                         │
│     「テキストが背景に埋もれている。                  │
│      オーバーレイの不透明度を上げよう」               │
│               │                                   │
│               ▼                                   │
│  ⑥ 修正スクリプトを生成して再実行                    │
│               │                                   │
│               └──── ①に戻る ────────────────────── │
│                                                   │
└───────────────────────────────────────────────────┘

 

このサイクルをClaude Codeが自律的に回すのがポイントです。人間が指示するのは最初の「こういうデザインを作って」だけ。途中のデバッグ、リトライ、微調整は全てClaude Codeが判断して実行します。

Plugin APIでできること

Penpot Plugin APIは、デザインツールの操作をプログラマティックに行うためのAPIです。主要な機能を紹介します。

ボードとレイアウト

// ボード(フレーム)の作成
const board = penpot.createBoard();
board.name = "GMO / Hero";
board.x = 0;
board.y = 0;
board.resize(1440, 500);
// Flexboxレイアウトの適用
const flex = board.addFlexLayout();
flex.dir = "column";
flex.alignItems = "center";
flex.justifyContent = "center";
flex.rowGap = 16;
flex.horizontalPadding = 40;
flex.verticalPadding = 30;

 

addFlexLayout() が返すFlexオブジェクトに対してプロパティを設定します。ここで注意すべきは、horizontalPaddingverticalPaddingFlexオブジェクトに設定する ということです。ボード自体に設定しても効きません。このハマりポイントだけで1時間は溶かしました。

テキストと矩形

// テキストの作成
const text = penpot.createText("新しい技術で 新しい価値を提供する");
text.name = "Hero Tagline";
text.fontSize = "52";
text.fontWeight = "700";
text.fontFamily = "Noto Sans JP";
text.fills = [{ fillColor: "#ffffff", fillOpacity: 1 }];
// 矩形の作成
const rect = penpot.createRectangle();
rect.name = "Card Background";
rect.resize(300, 200);
rect.fills = [{ fillColor: "#ffffff", fillOpacity: 1 }];
rect.borderRadius = 8;
rect.strokes = [{
  strokeColor: "#e8e8e8",
  strokeWidth: 1,
  strokeAlignment: "outside",
  strokeOpacity: 1
}];

 

画像のアップロードと配置

// 外部URLから画像をアップロード
const imageData = await penpot.uploadMediaUrl(
  "hero-bg",
  "https://example.com/hero-image.jpg"
);
// 矩形のfillに画像を適用
rect.fills = [{ fillOpacity: 1, fillImage: imageData }];

 

uploadMediaUrl() は非同期関数で、外部URLから画像をダウンロードしてPenpotのアセットストレージ(MinIO)に保存します。戻り値の imageData オブジェクトを fills 配列に渡すことで、矩形の塗りとして画像が表示されます。

この関数が 外部URLを直接受け付ける ということです。画像をローカルにダウンロードしてからアップロードするような二度手間は不要です。ただし、SVG形式のURLを渡すと「http error」で失敗することがあります。PNG/JPG形式のURLを使うのが安全です。

 

 

 

GMO採用ページ再現の具体例

実際に作成したリファインスクリプトです。

Hero背景画像 + ダークオーバーレイ

ヒーローセクションの背景画像を配置し、テキストが読めるようにダークオーバーレイをかける処理です。

// refine/01_hero_bg.js
const heroBoards = penpot.currentPage.findShapes({ name: "GMO / Hero" });
if (heroBoards.length === 0) return "Hero board not found";
const hero = heroBoards[0];
// 外部URLから画像をアップロード
const imageUrl = "https://www.gmo-jisedai.com/wp-content/uploads/top-mv-scaled.jpg";
let imageData;
try {
  imageData = await penpot.uploadMediaUrl("hero-bg", imageUrl);
} catch(e) {
  return "Upload failed: " + e.message;
}
if (!imageData) return "imageData is null - upload may have failed";
// 背景用の矩形を作成(なければ)
let bgRect = null;
const heroChildren = hero.children;
for (let i = 0; i < heroChildren.length; i++) {
  const c = heroChildren[i];
  if (c.type === "rectangle" || (c.name && c.name.includes("bg"))) {
    bgRect = c;
    break;
  }
}
if (!bgRect) {
  bgRect = penpot.createRectangle();
  bgRect.name = "Hero BG Image";
  bgRect.x = hero.x;        // ← appendChildの前にx/yを設定!
  bgRect.y = hero.y;
  bgRect.resize(hero.width, hero.height);
  hero.insertChild(0, bgRect);  // 最背面に配置
}
// 画像をfillに設定
bgRect.fills = [{ fillOpacity: 1, fillImage: imageData }];

 

ここで最も重要なポイントが、コメントにも書いた x/y 座標を appendChild / insertChild の前に設定する というルールです。

これはPenpot Plugin APIの最大の罠と言っても過言ではありません。座標を設定する順序を間違えると、要素はレイヤーパネルには表示されるのに キャンバス上では見えない という、非常にデバッグしにくい状態になります。「コードは成功しているはずなのに何も表示されない」という状況に何度泣かされたことか。

ダークオーバーレイの実装テクニック

背景画像の上にテキストを置くと、画像の色彩によってはテキストが読めなくなります。一般的な対策は半透明の黒いオーバーレイを重ねることですが、Penpotでは fills配列を活用 して、別の矩形を重ねずにこれを実現できます。

// 複数fillsによるダークオーバーレイ
// fills配列の最初の要素が最前面に描画される
bgRect.fills = [
  { fillColor: "#0a0a1e", fillOpacity: 0.35 },  // ダークオーバーレイ
  { fillOpacity: 1, fillImage: imageData }         // 背景画像
];

 

fills 配列では、インデックス0の要素が最前面 に描画されます。つまり、配列の先頭にダークカラーの半透明fillを置き、2番目に画像fillを置くことで、画像の上にオーバーレイがかかる表現になります。

最初はオーバーレイ用の矩形を別途作成して重ねていましたが、z-orderの管理が煩雑になるので、このfills配列テクニックに落ち着きました。z-orderについては後述しますが、Penpotのレイヤー順序の仕様はかなり直感に反するので、要素を増やさずに済むならそれに越したことはありません。

実際、オーバーレイの不透明度は何度も調整しました。最初は0.3で始めて、スクリーンショットを確認すると「テキストがまだ見づらい」。0.4にして確認、「まだ微妙」。最終的に0.5まで上げて、ようやくテキストがクリアに読めるようになりました。

// refine/22_hero_text_fix.js
// オーバーレイの不透明度を0.35から0.5に調整
if (c.name === "Hero BG Image") {
  const imgFill = c.fills.find(f => f.fillImage);
  c.fills = [
    { fillColor: "#0a0a2e", fillOpacity: 0.5 },
    imgFill
  ];
}

こういった微調整を「スクリーンショット確認 → 修正スクリプト生成 → 実行」のサイクルで素早く回せるのが、良い点です。

ブログカード6枚の画像配置

ブログセクションには6枚のサムネイルカードを配置しました。

// refine/05_blog.js
const baseUrl = "https://www.gmo-jisedai.com/wp-content/uploads/";
const blogImages = [
  { card: "GMO / Blog Card 1", file: "paperbanana_logo.jpeg", imgName: "blog-paperbanana" },
  { card: "GMO / Blog Card 2", file: "logo-5.png", imgName: "blog-sglang" },
  { card: "GMO / Blog Card 3", file: "Gemini_Generated_Image_4g7d14.png", imgName: "blog-deepseek" },
  { card: "GMO / Blog Card 4", file: "2026Q1_blog4_eye_catch-scaled.png", imgName: "blog-q1-4" },
  { card: "GMO / Blog Card 5", file: "2026Q1_blog3_n8n_workflow_2.png", imgName: "blog-q1-3" },
  { card: "GMO / Blog Card 6", file: "2026Q1_blog2_n8n.png", imgName: "blog-q1-2" }
];
for (const blog of blogImages) {
  const cards = penpot.currentPage.findShapes({ name: blog.card });
  if (cards.length === 0) continue;
  const card = cards[0];
  const imageData = await penpot.uploadMediaUrl(blog.imgName, baseUrl + blog.file);
  if (!imageData) continue;
  // カード内の矩形プレースホルダーを探して画像を適用
  for (let i = 0; i < card.children.length; i++) {
    const c = card.children[i];
    if (c.type === "rectangle") {
      c.fills = [{ fillOpacity: 1, fillImage: imageData }];
      c.name = blog.imgName;
      break;
    }
  }
}

 

6枚の画像を順番にアップロードして配置しています。各カードの中から rectangle タイプの子要素を探し、それをサムネイルのプレースホルダーとして画像を適用する戦略です。

このスクリプトは一見単純ですが、実行中にPenpotがクラッシュすることが何度もありました。6枚の画像を連続でアップロードするのは負荷が高いようで、3枚目あたりでPlugin iframeが切断されることが頻発しました。結局、3枚ずつに分割して実行し、間にsleepを挟むことで安定化させました。

カードのスタイリング

ブログカードに角丸やボーダーを適用するスクリプトも作りました。

// refine/16_cards.js
// Blog cards - ボーダーと角丸を適用
for (let n = 1; n <= 6; n++) {
  const cards = penpot.currentPage.findShapes({ name: "GMO / Blog Card " + n });
  if (cards.length > 0) {
    const card = cards[0];
    card.fills = [{ fillColor: "#ffffff", fillOpacity: 1 }];
    card.strokes = [{
      strokeColor: "#e8e8e8",
      strokeWidth: 1,
      strokeAlignment: "outside",
      strokeOpacity: 1
    }];
    card.borderRadius = 4;
  }
}

strokes 配列で strokeAlignment: "outside" を指定しているのがポイントです。内側に線を引くとカード内のコンテンツが圧迫されるので、外側に描画します。

苦労したポイント集

ここからは、Plugin APIの「ドキュメントに書いてない挙動」に苦しんだエピソードを紹介します。これからPenpot Plugin APIを使おうとしている方の参考になれば幸いです。

x/y座標は appendChild より前に設定する

前述しましたが、これは本当に重要なので改めて強調します。

// NG: appendChildの後に座標設定 → 要素が見えなくなる
const rect = penpot.createRectangle();
board.appendChild(rect);
rect.x = 100;  // これでは見えない
rect.y = 200;
// OK: appendChildの前に座標設定
const rect = penpot.createRectangle();
rect.x = 100;  // 先に座標を設定
rect.y = 200;
board.appendChild(rect);  // その後にappendChild

 

 

この順序を間違えると、要素はPenpotのレイヤーパネルには表示されます。しかしキャンバス上には現れません。エラーも出ません。「成功した」と思い込んで次の作業に進み、スクリーンショットを見て初めて「あれ?何もない」と気づく。この罠に何十回はまりました。

推測ですが、appendChild の時点で内部的にレイアウト計算が走り、その後の座標変更が反映されないのではないかと思います。

ただし、Flexレイアウトの子要素 は例外です。Flex子要素はFlexエンジンが自動配置するので、x/y座標の手動設定は不要です。

z-orderの仕様

Penpotのz-order(レイヤー順序)は直感に反する仕様です。

children[0] = 最前面(一番上に描画される)
children[1] = その次
  ...
children[N-1] = 最背面(一番下に描画される)

 

つまり、children 配列の 先頭が最前面 です。CSSの z-index 的な「数字が大きいほど前面」という感覚とは逆です。

さらに厄介なのが appendChild の挙動です。

// appendChildは index 0 に追加する = 最前面に来る
board.appendChild(newElement);  // → children[0] に追加される

 

背景画像を一番後ろに配置したいのに、appendChild すると最前面に来てしまい、テキストが隠れる。この問題に対処するために insertChild を使います。



// insertChild(N) で指定位置に挿入
// children.length = 最も後ろ
board.insertChild(board.children.length, bgElement);  // 最背面に配置

z-order問題は何度も発生し、専用の修正スクリプトを何本も書く羽目になりました。最終的にはヘルパー関数で一括修正するアプローチに落ち着きました。



// refine/21_fix_zorder.js
// BG画像とOverlayを全セクションで最背面に移動するヘルパー
function fixZOrder(boardName) {
  const boards = penpot.currentPage.findShapes({ name: boardName });
  if (boards.length === 0) return { board: boardName, status: "not found" };
  const board = boards[0];
  const bgRects = [];
  const overlayRects = [];
  for (let i = 0; i < board.children.length; i++) {
    const c = board.children[i];
    if (c.name && c.name.includes("BG")) bgRects.push(c);
    else if (c.name && c.name.includes("Overlay")) overlayRects.push(c);
  }
  // BGを最背面に
  for (const bg of bgRects) {
    board.insertChild(board.children.length, bg);
  }
  // Overlayをその手前に
  for (const ov of overlayRects) {
    board.insertChild(board.children.length - bgRects.length, ov);
  }
  return { board: boardName, status: "fixed" };
}
// 全セクションで一括修正
const toFix = [
  "GMO / Recruit", "GMO / Banner 1", "GMO / Banner 2",
  "GMO / Banner 3", "GMO / インタビュー", "GMO / 大阪研究開発グループ"
];
for (const name of toFix) {
  fixZOrder(name);
}

 

animation付きinteractionが無言で失敗する

プロトタイプの画面遷移を設定する addInteraction にも罠がありました。

// OK: シンプルなナビゲーション遷移
shape.addInteraction('click', {
  type: 'navigate-to',
  destination: targetBoardObj
});
// → 正常に動作する
// NG: animationパラメータを付ける
shape.addInteraction('click', {
  type: 'navigate-to',
  destination: targetBoardObj,
  animation: { type: 'dissolve', duration: 300 }
});
// → addInteractionがnullを返す(エラーメッセージなし)

 

ディゾルブやスライドなどのアニメーション効果を付けたかったのですが、animation パラメータを含めると addInteraction が静かに null を返します。エラーもスローされません。Plugin APIのドキュメントにはanimationパラメータの記載があるのに、実際には動かないのです。

結局、animationなしのシンプルなナビゲーション遷移のみを使うことにしました。アニメーションが欲しい場合は、Penpotの UI上で手動設定するしかなさそうです。

children配列のイテレーション

JavaScript的には当然 for...of で回したくなりますが、Penpotの children プロパティは for...of に対応していません。

// NG: for...of は使えない
for (const child of board.children) {  // エラー
  console.log(child.name);
}
// OK: インデックスアクセス
for (let i = 0; i < board.children.length; i++) {
  const child = board.children[i];
  console.log(child.name);
}
// OK: findShapes を使う
const allTexts = penpot.currentPage.findShapes({ type: "text" });

 

children は配列ではなく、配列風のオブジェクト(Array-likeだがiterableではない)のようです。findShapes は通常の配列を返すので、ページ全体から検索する場合はこちらを使う方が安全です。

fillImage は board 型では無視される

これはデバッグに最も時間がかかった罠です。背景画像をボード(フレーム)に直接設定しようとすると、APIレベルでは成功したように見えます。

// NG: board型にfillImageを設定 → 無視される
const board = penpot.currentPage.findShapes({ name: "GMO / Hero" })[0];
const img = await penpot.uploadMediaUrl("bg", url);
board.fills = [{ fillOpacity: 1, fillImage: img }];
// 読み返すとfillImageが設定されている!
console.log(board.fills[0].fillImage);  // → ImageDataオブジェクト
// しかし実際のレンダリングには反映されない

 

APIの読み書きは成功するのに、キャンバス上では何も変わらない。エクスポートしたPNGにも反映されない。

fillImage が機能するのは rectangle 型の要素のみです。 board(frame)型では fillColor しか永続化されません。

// OK: rectangle型にfillImageを設定
const rect = penpot.createRectangle();
rect.name = "Hero BG Image";
rect.x = board.x;
rect.y = board.y;
rect.resize(board.width, board.height);
board.insertChild(board.children.length, rect);  // 最背面に
const img = await penpot.uploadMediaUrl("bg", url);
rect.fills = [{ fillOpacity: 1, fillImage: img }];  // → 正しく表示される

 

背景画像を使いたい場合は、ボードと同サイズの矩形を作成し、その矩形のfillに画像を設定してください。

uploadMediaUrl は host.docker.internal が必要

ローカルのHTTPサーバーから画像をアップロードする際、localhost では失敗するケースがあります。

// NG: localhostを指定
const img = await penpot.uploadMediaUrl("bg", "http://localhost:8888/hero.png");
// → "http error" で失敗
// OK: host.docker.internal を使用
const img = await penpot.uploadMediaUrl("bg", "http://host.docker.internal:8888/hero.png");
// → 正常にアップロード

 

Penpotのバックエンドは Dockerコンテナ内 で動作しています。uploadMediaUrl() がURLを取得する処理はバックエンド側で行われるため、ホストマシンの localhost にはアクセスできません。代わりに host.docker.internal(Docker for Mac/Windows がホストマシンを指すDNS名)を使う必要があります。

この問題がさらに厄介なのは、最初の数回は偶然成功することがある 点です。ブラウザのキャッシュやPlugin側の処理でURLが解決される場合もあるようで、再現性がありません。安全策として、ローカルリソースへのアクセスには常に host.docker.internal を使ってください。

opacity

ボードの opacity プロパティが1.0以外に設定されていると、子要素の色が微妙に変わります。

// Header board の opacity が 0.8、fills が [{ fillColor: "#ffffff" }] の場合:
// 子要素の画像は「80%の不透明度で白い背景の上に合成」されるため、
// すべてのピクセルが白方向にシフトする
// 例: 元画像のピクセル RGB(0, 50, 100)
// → 実際のレンダリング: RGB(51, 91, 131)
//    計算: 0.8 * orig + 0.2 * 255 = 0.8 * 0 + 51 = 51 (Rチャンネル)

 

これは目視では「なんとなく色が薄い」程度にしか見えないため、発見が非常に困難です。ピクセル単位の定量比較を行って初めて、Headerセクション全体の色が一律にシフトしていることに気づきました。

原因は、以前の操作で意図せず opacity: 0.8 が設定されていたこと。修正は一行です。

header.opacity = 1;
header.fills = [];  // 白背景も不要なら除去

 

この一行で、Headerの一致率が71.8%から99.9%に改善しました。要素の表示がおかしいと感じたら、まず親ボードの opacityfills を確認することをお勧めします。

日本語フォントの問題

最後に、日本語フォントの問題も触れておきます。

// refine/23_noto_font.js
// 全ての日本語テキストのフォントをNoto Sans JPに変更
const allTexts = penpot.currentPage.findShapes({ type: "text" });
let fixed = 0;
for (const t of allTexts) {
  const chars = t.characters || "";
  const hasJapanese = /[\u3000-\u9fff\uff00-\uffef]/.test(chars);
  if (hasJapanese) {
    t.fontFamily = "Noto Sans JP";
    t.fontId = "gfont-noto-sans-jp";
    fixed++;
  }
}

 

Penpotのデフォルトフォントは日本語に対応していないため、日本語テキストが文字化けや豆腐(四角)になります。Google Fontsから Noto Sans JP を使うことで解決しますが、fontFamily だけでなく fontId も設定する必要があります。fontId のフォーマットは gfont- + フォント名のケバブケースです。

リファインスクリプトの全体像

最終的に、GMOページのデザインを完成させるまでに 23本のリファインスクリプト を書きました。

refine/
├── 01_hero_bg.js          # Hero背景画像
├── 02_banners.js          # バナー画像3枚
├── 03_about_icons.js      # Aboutセクションのアイコン
├── 04_research.js         # 研究室カード
├── 05_blog.js             # ブログサムネイル6枚
├── 06_interview.js        # インタビューセクション
├── 07_recruit.js          # 採用セクション
├── 08_logos.js             # ロゴ配置
├── 09_final.js            # 全体仕上げ
├── 10_polish.js           # ポリッシュ
├── 11_cleanup.js          # 不要要素の削除
├── 12_hero_detail.js      # Hero詳細調整
├── 13_hero_enhance.js     # Hero装飾テキスト追加
├── 14_topics_bar.js       # トピックバー
├── 15_header_fix.js       # ヘッダー修正
├── 16_cards.js            # カードスタイリング
├── 17_hero_text.js        # Heroテキスト調整
├── 18_zorder.js           # z-order修正
├── 19_hero_bg_redo.js     # Hero背景やり直し
├── 20_final_polish.js     # 最終ポリッシュ
├── 21_fix_zorder.js       # z-order一括修正
├── 22_hero_text_fix.js    # テキスト視認性修正
└── 23_noto_font.js        # 日本語フォント修正

 

番号を見ると分かるように、一発でうまくいったわけではありません。18_zorder.js で修正したz-orderが 19_hero_bg_redo.js で壊れて 21_fix_zorder.js で再修正、といった試行錯誤の軌跡が残っています。

特にHeroセクションは 01_hero_bg.js12_hero_detail.js13_hero_enhance.js17_hero_text.js19_hero_bg_redo.js22_hero_text_fix.js6回も手を入れています。最初のイメージからデザインを詰めていく過程は、人間がFigmaで作業するのと本質的に変わりません。違いは、「テキストをもう少し大きく」という指示がコードになっているというだけです。

ステップバイステップの実行例

これらのスクリプトが実際にどう実行されるか、MCPクライアント経由の呼び出し例を示します。

# Hero背景画像を配置
python3 /tmp/mcp_sse_client.py "$(cat refine/01_hero_bg.js)"
# 実行結果
# {
#   "success": true,
#   "heroSize": { "w": 1440, "h": 500 },
#   "bgRectName": "Hero BG Image",
#   "imageData": { "width": 2560, "height": 1440, "name": "hero-bg" }
# }
# スクリーンショットで確認
node scripts/penpot-pilot.mjs screenshot hero-step1.png
# Claude Codeがスクリーンショットを確認して次のステップへ...

 

MCPクライアントにJavaScriptコードの文字列を渡し、結果のJSONで成否を確認し、スクリーンショットで視覚的に検証する。これが基本パターンです。

Claude Codeがこの一連の操作を自律的に行い、必要に応じてPlaywrightでリカバリし、最終的に人間が「OK」と言うまでイテレーションを回し続けます。

design-loop.py ― 実行と視覚確認の一体化

リファインスクリプトの開発を進めるうちに、「MCPでコード実行 → Playwrightでスクリーンショット → 確認」という3ステップを毎回手動で行うのが煩雑になってきました。そこで作成したのが design-loop.py です。

# コード実行 + スクリーンショット(ワンコマンド)
python3 scripts/design-loop.py '
  const board = penpot.currentPage.findShapes({ name: "GMO / Hero" })[0];
  return { name: board.name, w: board.width, h: board.height };
' --board "GMO / Hero"
# コード実行 + MCPエクスポート(より高品質)
python3 scripts/design-loop.py '$(cat refine/01_hero_bg.js)' --export "GMO Page"
# スクリーンショットのみ
python3 scripts/design-loop.py --screenshot

 

出力はJSON形式で、コード実行の結果とスクリーンショットのパスが一度に返ってきます。

{
  "code_result": { "name": "GMO / Hero", "w": 1440, "h": 620 },
  "screenshot": { "path": "/tmp/penpot-visual-check.png", "status": "ok" }
}

 

--export オプションを使うと、Playwrightのスクリーンショット(ブラウザのUIやプラグインパネルが映り込む)ではなく、MCPの export_shape ツールによるクリーンなエクスポートが得られます。これは後述するピクセル比較に不可欠です。

MCPセッションの確立からコード実行、スクリーンショット取得までを 単一のPythonプロセス で完結させていることです。外部コマンドを複数呼び出す方式だと、MCPセッションの確立だけで2-3秒かかるのが毎回積み重なります。design-loop.py では一度確立したSSE接続を使い回すので、応答が速くなります。

ピクセル単位の精度を追求する ― 99.9%一致への道

GMOの採用ページをPenpot上に再現する過程で、最終的に「オリジナルのスクリーンショットとPenpot上のデザインをピクセル単位で一致させる」という挑戦を行いました。ここでは、その手法と得られた知見を紹介します。

定量比較の手法

「デザインが似ているか」を目視で判断するのは主観的です。そこで、numpy を使ったピクセル単位の定量比較スクリプトを作成しました。

from PIL import Image
import numpy as np
# オリジナルとPenpot版をそれぞれMCPエクスポートで取得
orig = np.array(Image.open("/tmp/original-hp.png"))[:5220, :1440, :3]
gmo  = np.array(Image.open("/tmp/gmo-page.png"))[:5220, :1440, :3]
# ピクセルごとの差分を計算
diff = np.abs(orig.astype(int) - gmo.astype(int))
# ±30の許容範囲で一致率を算出(JPEG圧縮ノイズ等を考慮)
tolerance_match = (diff <= 30).all(axis=2).mean() * 100
# 完全一致率
exact_match = (diff == 0).all(axis=2).mean() * 100
print(f"±30 tolerance: {tolerance_match:.1f}%")
print(f"Exact match:   {exact_match:.1f}%")

 

±30の許容範囲を設ける理由は、PenpotのエクスポーターがPNGを生成する際のレンダリング差異(アンチエイリアシング、色空間変換など)を吸収するためです。完全一致率も計測することで、「ほぼ一致」と「完全に同一」を区別します。

さらに、ページ全体を9つのセクション(Header, Hero, Banners, About, Research, Blog, Interview, Recruit, Footer)に分割し、セクションごとの一致率を計算することで、どの部分に問題があるか を即座に特定できるようにしました。

Section      |  ±30 Match |      Exact
-----------------------------------------------
Header       |      99.9% |      97.4%
Hero         |     100.0% |      99.8%
Banners      |     100.0% |     100.0%
About        |     100.0% |      99.5%
Research     |     100.0% |     100.0%
Blog         |     100.0% |     100.0%
Interview    |     100.0% |     100.0%
Recruit      |     100.0% |     100.0%
Footer       |     100.0% |     100.0%
-----------------------------------------------
OVERALL      |     100.0% |      99.9%

 

このセクション分割比較が、問題の特定と修正のイテレーションを劇的に速くしました。「全体で62%」という数字だけでは何をすべきか分かりませんが、「Researchが35%」と分かれば、ピンポイントで修正に着手できます。

 

 

 

得られた知見

この「Claude Codeによるデザイン制作」を通じて得られた知見をまとめます。

うまくいったこと:

  • 自然言語での指示からPlugin APIのコードを正確に生成できる
  • スクリーンショットベースのセルフレビューが意外と高精度
  • Playwrightによる自動リカバリで無人運用が実現できた
  • 23本のスクリプトで1ページ分のデザインが完成した
  • ピクセル単位の定量比較で99.9%の一致率を達成
  • design-loop.py で実行+視覚確認のサイクルを高速化

発見したPlugin APIの罠:

  • fillImage は board 型では無視される(rectangle 型のみ有効)
  • uploadMediaUrl はDockerコンテナ内のバックエンドが実行するため host.docker.internal が必要
  • 親ボードの opacity が1.0でないと、子要素の色が背景色と合成されてシフトする
  • insertChild で既存要素の親を変更できる(ドキュメント未記載)

課題として残ったこと:

  • Plugin APIの安定性(クラッシュ頻度が高い)
  • 要素数が増えると操作が遅くなる
  • アニメーション関連のAPIが未成熟
  • 日本語フォントの扱いに手間がかかる
  • z-orderの管理が煩雑(直感的でない仕様)

それでも、「自然言語でデザインを指示して作る」というワークフローが現実に動きました。
「エンジニアだけでUIまで作れる」点は普通に実用的でした

そして、ピクセル単位の定量比較という手法を導入したことで、「なんとなく似ている」から「99.9%一致している」という客観的な評価が可能になりました。この定量アプローチは、デザインのQAやリグレッションテストにも応用できます。

 

 

 


9. デザインをgitで管理する

ここまでで、Claude Codeからの自然言語指示でPenpot上にデザインを作成し、Playwrightで安定運用する仕組みが整いました。しかし、ソフトウェア開発者としてどうしても気になることがあります。

「このデザイン、バージョン管理できないの?」

Figmaをはじめとする多くのデザインツールでは、バージョン管理はツール側の独自機能に頼ることになります。Figmaの「Version History」は確かに便利ですが、開発チームが普段使っているgitのワークフローとは完全に別の世界です。つまり、こんな問題が起きます。

  • コードの変更はgit logで追えるのに、デザインの変更はFigmaを開かないと分からない
  • コードレビューはGitHub PRで行うのに、デザインレビューはFigma上のコメントで行う
  • リリースタグにコードは紐づくのに、「このリリースの時のデザインはどれ?」が分からない
  • デザインの変更承認フローがコードの承認フローと統合できない

エンジニアとデザイナーの協業において、この「二重管理」は地味ですが根深い問題です。Penpotをセルフホストしている我々には、これを解決する手段があります。

9.1 Penpotのデータはどこにあるのか

まず、Penpotがデザインデータをどこに保存しているのかを理解しましょう。docker-compose.yamlを見ると、大きく2つのストレージがあることが分かります。

PostgreSQL(メタデータ・構造情報)

penpot-postgres:
  image: "postgres:15"
  volumes:
    - penpot_postgres_v15:/var/lib/postgresql/data
  environment:
    - POSTGRES_DB=penpot
    - POSTGRES_USER=penpot
    - POSTGRES_PASSWORD=penpot

 

デザインファイルの構造情報(ボード、シェイプ、テキスト、レイアウト設定など)はすべてPostgreSQLに格納されています。Penpotの内部では、デザインデータはJSON形式でシリアライズされ、データベースのカラムとして保存されています。ページの構造、要素の座標、フォント設定、Flexレイアウトの設定値…これらすべてがRDBの中に存在します。

ファイルシステム / MinIO(画像アセット)

penpot-backend:
  volumes:
    - penpot_assets:/opt/data/assets
  environment:
    PENPOT_OBJECTS_STORAGE_BACKEND: fs
    PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets

アップロードした画像やサムネイルなどのバイナリアセットは、ファイルシステム(もしくはS3互換ストレージ)に保存されます。デフォルト構成ではDockerボリューム上のファイルシステムが使われていますが、本番環境ではMinIOやAWS S3に切り替えることも可能です。

この構造を知ることで、バックアップ戦略が見えてきます。

9.2 .penpotエクスポートAPIの発見

Penpotには、デザインファイルをエクスポートするREST APIが存在します。

POST /api/rpc/command/export-binfile

 

このエンドポイントに対して、エクスポートしたいファイルのIDを送ると、.penpot形式のバイナリファイルとしてダウンロードできます。面白いのは、このAPIの応答がSSE(Server-Sent Events)ストリーム形式であることです。エクスポートの進捗状況がストリームで返ってきて、最後にダウンロードURLが含まれます。

そして、この.penpotファイルの中身を調べてみると、実はZIPアーカイブであることが分かりました。

$ unzip -l exported.penpot
Archive:  exported.penpot
  Length      Date    Time    Name
---------  ---------- -----   ----
    12847  2026-02-15 10:30   manifest.json
   284923  2026-02-15 10:30   00000000-0000-0000-0000-000000000001.json
    45231  2026-02-15 10:30   media/image-001.png
    67432  2026-02-15 10:30   media/image-002.jpg
---------                     -------
   410433                     4 files

 

JSONファイルがデザインの構造データ、media/ディレクトリ以下に画像アセットが入っています。つまり、このファイル一つでデザインの完全なスナップショットが取れるのです。

9.3 penpot-export.sh ― ワンコマンドでエクスポート

この仕組みを自動化するために作成したのがpenpot-export.shです。実際のスクリプトを見てみましょう。

#!/bin/bash
# Penpot Export Script
# Usage: ./penpot_export.sh [output_file] [file_id]
set -euo pipefail
PENPOT_URL="${PENPOT_URL:-http://localhost:9001}"
OUTPUT="${1:-penpot-export.penpot}"
DIRECT_FILE_ID="${2:-}"

 

使い方は極めてシンプルです。

# 対話モード(ファイル選択あり)
./scripts/penpot-export.sh my-design.penpot
# ファイルID直指定(CI向け)
./scripts/penpot-export.sh my-design.penpot "00000000-0000-0000-0000-000000000001"

 

スクリプトの処理は4つのステップで構成されています。

Step 1: Playwrightでブラウザから認証トークンを自動取得

ここが最も工夫したポイントです。PenpotのAPIを叩くには認証が必要ですが、ログインAPIを直接叩くのではなく、既に開いているブラウザセッションからCookieを拝借します。

// Playwright CDP接続でブラウザのCookieを取得
import { chromium } from 'playwright';
const browser = await chromium.connectOverCDP('http://localhost:9222');
const contexts = browser.contexts();
let page;
for (const ctx of contexts) {
  for (const p of ctx.pages()) {
    if (p.url().includes('localhost:9001')) { page = p; break; }
  }
  if (page) break;
}
const cookies = await page.context().cookies();
const auth = cookies.find(c => c.name === 'auth-token');
console.log(auth.value);

 

ポート9222でリモートデバッグしているChromeに接続し、Penpotページのauth-token Cookieを取得します。これなら認証情報をスクリプトにハードコードする必要がありません。Playwrightが既にブラウザ制御に使われているので、この仕組みとの相性も抜群です。

Step 2: プロジェクト・ファイル一覧の取得

PROJECTS=$(curl -s -H "Accept: application/json" -H "$AUTH_HEADER" \
  "${PENPOT_URL}/api/rpc/command/get-all-projects")

 

取得した認証トークンを使って、プロジェクト一覧とファイル一覧を取得します。対話モードでは選択肢が表示され、ファイルIDを直接指定した場合はこのステップはスキップされます。

Step 3: export-binfile API呼び出し(SSEストリーム)

SSE_RESPONSE=$(curl -s -X POST \
  "${PENPOT_URL}/api/rpc/command/export-binfile" \
  -H "Content-Type: application/json" \
  -H "$AUTH_HEADER" \
  -d "{\"fileId\":\"${FILE_ID}\",\"includeLibraries\":false,\"embedAssets\":true}")
# SSEレスポンスからダウンロードURLをパース
ASSET_URL=$(echo "$SSE_RESPONSE" | grep '"~#uri"' | sed 's/.*"~#uri":"//;s/".*//')

 

embedAssets: trueを指定することで、画像アセットもすべて.penpotファイルに含めます。SSEレスポンスの中から~#uriというTransit形式のURIを抽出してダウンロードURLを取得するのがポイントです。

Step 4: ダウンロード

curl -s -H "$AUTH_HEADER" -o "$OUTPUT" "$ASSET_URL"

 

最終的に.penpotファイルがローカルに保存されます。

実行結果はこんな感じです。

$ ./scripts/penpot-export.sh designs/chat-app.penpot
Getting auth token from browser session...
Auth token acquired.
Fetching projects...
Found 2 projects.
Available files:
---
  [0] GMO Recruit Page  (project: Default (Your Penpot))  id: abc123...
  [1] Chat App Wireframes  (project: Default (Your Penpot))  id: def456...
Select file number [0]: 1
Exporting 'Chat App Wireframes' (def456...)...
Download URL: /assets/tmp/export-xyz789.penpot
Saved: designs/chat-app.penpot (1.2M)
Done!

 

ワンコマンドで、デザインファイルの完全なスナップショットがローカルに落ちてきます。

9.4 gitでデザインを管理するワークフロー

.penpotファイルが手に入れば、あとは普段のgitワークフローに乗せるだけです。

基本的なディレクトリ構成

my-project/
  src/           # アプリケーションコード
  designs/       # デザインファイル
    chat-app.penpot
    admin-panel.penpot
  .gitattributes

 

Git LFSの設定

.penpotファイルはバイナリなので、Git LFSで管理するのが賢明です。

# Git LFSの初期化
git lfs install
# .penpotファイルをLFS対象に
git lfs track "*.penpot"
# .gitattributesをコミット
git add .gitattributes
git commit -m "Track .penpot files with Git LFS"

 

.gitattributesの内容はこうなります。

*.penpot filter=lfs diff=lfs merge=lfs -text

 

デザイン変更のコミットフロー

# デザイン変更後、エクスポート
./scripts/penpot-export.sh designs/chat-app.penpot
# 差分確認(サイズ変更で変更があったか分かる)
git status
# modified:   designs/chat-app.penpot
# コミット
git add designs/chat-app.penpot
git commit -m "feat(design): チャット画面にメッセージ既読表示を追加"

 

PRベースのデザインレビュー

# フィーチャーブランチを作成
git checkout -b feature/read-receipts
# デザイン作業 → エクスポート
./scripts/penpot-export.sh designs/chat-app.penpot
# コードとデザインを一緒にコミット
git add designs/chat-app.penpot src/components/ChatBubble.tsx
git commit -m "feat: 既読表示の追加(デザイン + 実装)"
# PRを作成
git push -u origin feature/read-receipts
gh pr create --title "既読表示機能の追加" \
  --body "## デザイン変更
- チャットバブルに既読マーク(青いチェック2つ)を追加
- .penpotファイルを更新
## コード変更
- ChatBubble コンポーネントに readStatus props を追加"

 

コードとデザインの変更が同じPR上でレビューできるようになります。「このデザイン変更に対応するコードはこれ」という関係が、コミットログから一目瞭然です。

リリースタグとの紐づけ

# リリース時にタグを打つ
git tag -a v1.2.0 -m "チャットアプリ v1.2.0 - 既読表示対応"
# あとからデザインを復元したい場合
git checkout v1.2.0 -- designs/chat-app.penpot
# → この .penpot ファイルをPenpotにインポートすれば当時のデザインが復元される

 

9.5 PostgreSQLダンプという選択肢

.penpotエクスポートとは別に、PostgreSQLのダンプを直接取得するアプローチもあります。

# Penpotのデータベースを丸ごとダンプ
docker exec penpot-penpot-postgres-1 \
  pg_dump -U penpot -d penpot \
  --format=custom \
  -f /tmp/penpot-backup.dump
# ホストにコピー
docker cp penpot-penpot-postgres-1:/tmp/penpot-backup.dump ./backups/
# リストア
docker exec -i penpot-penpot-postgres-1 \
  pg_restore -U penpot -d penpot --clean /tmp/penpot-backup.dump

 

.penpotエクスポートとの使い分けはこうなります。

方式 粒度 用途 メリット
.penpotエクスポート ファイル単位 git管理・共有 ポータブル、インポートで復元可能
pg_dump DB全体 フルバックアップ 全プロジェクトを一括、整合性保証
pg_dump + テーブル指定 テーブル単位 特定データの抽出 柔軟なクエリが可能

CIでの自動バックアップも簡単に組めます。

# CI/CDパイプライン例(GitHub Actions)
# .github/workflows/backup-designs.yml
# - cron: '0 2 * * *'  # 毎日AM2時
# - run: |
#     docker exec penpot-postgres pg_dump -U penpot -d penpot -Fc > backup.dump
#     ./scripts/penpot-export.sh designs/latest.penpot
#     git add designs/ backups/
#     git commit -m "chore: daily design backup $(date +%Y-%m-%d)"


10. デザインからコード自動生成

「デザインからコードを自動生成する」。

Penpot + Claude Codeの組み合わせでは、Claude Codeはデザインの構造データ(座標、サイズ、色、フォント、レイアウト)をすべて読み取った上で、文脈を理解してコードに変換できるからです。

10.1 実際にやったこと: LINE風チャットアプリ

 

今回の実験では、Penpot上に作成したLINE風チャットアプリのワイヤーフレームから、Next.js(App Router)+ Tailwind CSSのコードを自動生成しました。

対象画面(6画面)

画面 ファイル 内容
ログイン login/page.tsx メール/パスワード入力、ログインボタン
サインアップ signup/page.tsx 名前/メール/パスワード入力、アバター設定
チャット一覧 chatlist/page.tsx チャットリスト、検索バー、タブバー
チャットルーム chatroom/page.tsx メッセージバブル、入力欄
プロフィール profile/page.tsx アバター、名前・ステータス編集
設定 settings/page.tsx 設定項目一覧、ログアウト

生成の流れは以下の通りです。

  1. Penpot上にワイヤーフレームを作成(Claude Code + Plugin APIで自動生成)
  2. Playwrightでスクリーンショットを取得し、デザインの内容を確認
  3. Plugin APIでデザインデータ(要素の構造、座標、色、テキスト)を取得
  4. Claude Codeがデザインデータを解析し、Next.jsコードを生成
  5. ファイルとして保存

10.2 生成されたコードの品質

実際に生成されたコードを見てみましょう。まず、ログイン画面です。

// Auto-generated from Penpot wireframe "Login" screen — LINE style
import Link from "next/link";
export default function LoginPage() {
  return (
    <div className="min-h-screen bg-white flex flex-col items-center max-w-[393px] mx-auto">
      {/* Logo */}
      <div className="mt-[100px] w-20 h-20 rounded-[20px] bg-[#06C755]
                      flex items-center justify-center">
        <span className="text-4xl font-bold text-white">L</span>
      </div>
      {/* App Name & Subtitle */}
      <h1 className="mt-4 text-[28px] font-bold text-[#111]">LINE</h1>
      <p className="mt-1 text-sm text-[#888]">Welcome back</p>
      {/* Form Fields */}
      <div className="w-full px-6 mt-10 space-y-4">
        <input
          type="email"
          placeholder="Email or phone number"
          className="w-full h-14 px-4 rounded-xl border border-[#ddd]
                     text-sm text-[#111] placeholder-[#aaa]
                     outline-none focus:border-[#06C755] focus:border-2
                     transition-colors"
        />
        <input
          type="password"
          placeholder="Password"
          className="w-full h-14 px-4 rounded-xl border border-[#ddd]
                     text-sm text-[#111] placeholder-[#aaa]
                     outline-none focus:border-[#06C755] focus:border-2
                     transition-colors"
        />
      </div>
      {/* Login Button */}
      <div className="w-full px-6 mt-8">
        <Link href="/chatlist">
          <button className="w-full h-[52px] rounded-[26px] bg-[#06C755]
                             text-white text-base font-semibold
                             hover:bg-[#05B34C] active:bg-[#04A043]
                             transition-colors">
            Log In
          </button>
        </Link>
      </div>
      {/* Sign Up Link */}
      <Link href="/signup"
            className="mt-6 text-sm font-medium text-[#06C755] hover:underline">
        Create new account
      </Link>
    </div>
  );
}

 

Tailwind CSSクラスの適切な使用

rounded-xlspace-y-4transition-colorsなど、Tailwindのユーティリティクラスが的確に使われています。デザインの角丸やスペーシングがそのままTailwindの値にマッピングされている点は見事です。

画面遷移の設定

Next.jsのLinkコンポーネントが適切に使われ、ログインボタンは/chatlistへ、サインアップリンクは/signupへと遷移するようになっています。Penpot上でPrototype機能(addInteraction)で設定した画面遷移が、そのままNext.jsのルーティングに変換されているのです。

次に、もう少し複雑なチャット一覧画面を見てみましょう。

// Auto-generated from Penpot wireframe "Chat List" screen — LINE style
import Link from "next/link";
const chats = [
  { name: "Tanaka Yuki", msg: "OK, see you tomorrow!",
    time: "14:32", unread: 2, initial: "T" },
  { name: "Suzuki Mei", msg: "Photo sent",
    time: "13:10", unread: 0, initial: "S" },
  { name: "Sato Ken", msg: "Thanks for the help",
    time: "12:05", unread: 1, initial: "K" },
  { name: "Project A", msg: "Yamada: Meeting at 3pm",
    time: "11:30", unread: 5, initial: "P" },
  { name: "Watanabe Aoi", msg: "Hello!",
    time: "Yest.", unread: 0, initial: "W" },
];
export default function ChatListPage() {
  return (
    <div className="min-h-screen bg-white flex flex-col max-w-[393px] mx-auto">
      {/* Search Bar */}
      <div className="px-6 mt-4">
        <div className="h-11 rounded-[22px] bg-[#f0f0f0]
                        flex items-center px-4">
          <span className="text-sm text-[#aaa]">Search</span>
        </div>
      </div>
      {/* Chat Items */}
      <div className="flex-1 mt-4">
        {chats.map((chat, i) => (
          <Link href="/chatroom" key={i}
                className="flex items-center px-4 py-3
                           hover:bg-[#f7f7f7] transition-colors">
            {/* Avatar */}
            <div className="w-12 h-12 rounded-full
                            flex items-center justify-center shrink-0
                            bg-[#06C755]/15">
              <span className="text-lg font-semibold text-[#06C755]">
                {chat.initial}
              </span>
            </div>
            {/* Name & Message */}
            <div className="ml-3 flex-1 min-w-0">
              <p className="text-[15px] font-semibold text-[#111] truncate">
                {chat.name}
              </p>
              <p className="text-[13px] text-[#888] truncate">
                {chat.msg}
              </p>
            </div>
            {/* Unread Badge */}
            {chat.unread > 0 && (
              <div className="w-[22px] h-[22px] rounded-full bg-[#06C755]
                              flex items-center justify-center">
                <span className="text-[11px] font-semibold text-white">
                  {chat.unread}
                </span>
              </div>
            )}
          </Link>
        ))}
      </div>
    </div>
  );
}

データ構造の抽出

です。デザイン上では単に5つのチャット項目が並んでいるだけですが、Claude Codeはそれをchats配列として抽出し、.map()でレンダリングする構造に変換しています。繰り返し要素を認識し、データドリブンなコンポーネントに変換するのは、単純なピクセルtoコード変換では実現できない、AIならではの能力です。

チャットルーム画面でも同様です。

const messages = [
  { self: false, text: "Hey! How are you?", time: "14:20" },
  { self: true,  text: "I am great, thanks!", time: "14:21" },
  { self: false, text: "Are we meeting tomorrow?", time: "14:25" },
  { self: true,  text: "Yes! Let me check the schedule.", time: "14:26" },
  { self: true,  text: "3pm works for me", time: "14:26" },
  { self: false, text: "Perfect! See you then", time: "14:28" },
  { self: false, text: "OK, see you tomorrow!", time: "14:32" },
];

 

自分のメッセージ(self: true)と相手のメッセージ(self: false)を区別し、右寄せ/左寄せ、背景色(LINEグリーン/白)の出し分けまで正確に実装されています。Penpotのデザイン上で視覚的に表現されていた「自分のメッセージは緑、相手のメッセージは白」というルールが、そのままコードのロジックに変換されているわけです。

10.3 6画面の構成と遷移

生成された6画面は、Next.jsのApp Routerに対応したディレクトリ構成になっています。

generated/chat-app/app/
  login/page.tsx      ← エントリーポイント
  signup/page.tsx     ← ログインから遷移
  chatlist/page.tsx   ← ログイン後のメイン画面
  chatroom/page.tsx   ← チャット一覧からタップ
  profile/page.tsx    ← 設定からプロフィール編集
  settings/page.tsx   ← タブバーから遷移

 

画面遷移もPenpotのPrototype設定に基づいて正確に実装されています。

login → (Log In) → chatlist
login → (Create new account) → signup
signup → (Sign Up) → chatlist
chatlist → (チャットタップ) → chatroom
chatlist → (Settings タブ) → settings
chatroom → (< ボタン) → chatlist
settings → (プロフィールタップ) → profile
settings → (Log Out) → login
profile → (< ボタン) → settings

 

すべての遷移がnext/linkLinkコンポーネントで実装されており、ブラウザのバック/フォワードボタンも正常に動作します。

10.4 コードの「ビフォー・アフター」

従来のワークフローと比較してみましょう。

Before: 従来のFigma → コーディングフロー

  1. デザイナーがFigmaでデザインを作成(数時間〜数日)
  2. デザインレビュー、修正のやりとり(数日)
  3. エンジニアがFigmaを見ながら手作業でコーディング(数時間〜数日)
  4. 「デザインと実装が微妙に違う」→ 調整(数時間)
  5. レスポンシブ対応を追加(数時間)

合計: 数日〜数週間

After: Penpot + Claude Codeフロー

  1. Claude Codeにデザイン指示(数分)
  2. Plugin APIでPenpotにデザイン自動生成(数分)
  3. スクリーンショットで確認、フィードバック(数分)
  4. デザインデータからNext.jsコード自動生成(数分)
  5. コードをそのまま実行可能

合計: 数十分

もちろん、プロダクション品質のコードにするには追加の作業が必要です。しかし、「ゼロからスタートして動くプロトタイプが手に入るまで」の時間が劇的に短縮されるのは間違いありません。

10.5 コーディングルール適用の可能性

Claude Codeの強力な点は、コード生成時にプロジェクト固有のルールを適用できることです。

CLAUDE.mdでプロジェクトルールを定義

# コーディングルール
- コンポーネントは社内デザインシステム `@company/ui` を使うこと
- カラーコードは直接指定せず、CSS変数 `var(--color-primary)` を使うこと
- フォントサイズはTailwindのデフォルトスケール(text-sm, text-base等)を使うこと
- アイコンは `lucide-react` を使うこと
- フォームは `react-hook-form` + `zod` でバリデーション

このようなルールをプロジェクトのCLAUDE.mdに書いておけば、Claude Codeはデザインデータからコードを生成する際にこれらのルールを自動的に適用します。

例えば、上の設定があれば、生成されるコードはこう変わるでしょう。

// ルール適用前
<button className="bg-[#06C755] text-white rounded-[26px]">
  Log In
</button>
// ルール適用後
<Button variant="primary" size="lg" rounded="full">
  Log In
</Button>

11. さらなる可能性

ここまでで「Penpot + Claude Code」の基本的なワークフローは完成しました。しかし、この仕組みにはまだまだ発展の余地があります。

11.1 デザインデータのDB管理

.penpotファイルがJSON + 画像のZIPアーカイブであることは既に説明しました。この構造を活用すれば、デザインデータをプログラムから自由に操作できます。

デザインコンポーネントの検索・再利用

import zipfile
import json
# .penpotファイルを展開
with zipfile.ZipFile('chat-app.penpot', 'r') as zf:
    # manifest.jsonからファイル構造を取得
    manifest = json.loads(zf.read('manifest.json'))
    # 各ページのJSONを解析
    for file_entry in manifest['files']:
        data = json.loads(zf.read(f"{file_entry}.json"))
        # ここでコンポーネント情報を抽出可能

 

例えば、こんなことが可能になります。

  • 全プロジェクトのボタンコンポーネントを横断検索して、デザインの一貫性をチェック
  • 使用されているカラーコードを一覧化し、デザインシステムとの差分を検出
  • フォントの使用状況を集計し、未許可フォントの使用を警告

デザインシステムの自動整合性チェック

CI/CDパイプラインに組み込めば、「デザインがスタイルガイドに準拠しているか」を自動チェックすることも可能です。

# CI/CDでのデザインリントのイメージ
./scripts/penpot-export.sh /tmp/latest.penpot
python scripts/design-lint.py /tmp/latest.penpot \
  --rules design-system-rules.json \
  --report lint-report.json

 

カラーパレットの逸脱、フォントサイズの不統一、余白の不整合…これらを自動検出することで、デザインの品質を維持できます。

 

11.2 CI/CDパイプラインとの統合

今回構築した仕組みの各パーツは、すべてコマンドラインから実行可能です。つまり、CI/CDに統合できるということです。

理想的なパイプライン

デザイン変更(Penpot操作)
    ↓
penpot-export.sh でエクスポート
    ↓
git commit & push
    ↓
GitHub Actions / GitLab CI
    ↓
┌────────────────────────────┐
│ 1. デザインリント           │
│ 2. コード自動生成           │
│ 3. ユニットテスト           │
│ 4. E2Eテスト(Playwright) │
│ 5. プレビューデプロイ       │
└────────────────────────────┘
    ↓
PRにプレビューURLコメント
    ↓
レビュー & マージ
    ↓
本番デプロイ

 

デザインの変更がコードの変更と同じパイプラインに乗ることで、デザインとコードの乖離が原理的に発生しにくくなります。デザイン変更→コード変更→テスト→デプロイまでが一つの流れとして自動化。

11.3 他のAIエージェントとの連携

オープンソースのエコシステム

Penpotはコードが公開されているため、MCPサーバーのカスタマイズが自由にできます。Plugin APIの拡張も、Penpot本体のフォークも可能です。特定のワークフローに最適化したMCPツールを自作できるのは、OSSならではの強みです。

デザイン←→コード双方向同期の可能性

今回は「デザイン→コード」の片方向変換を実現しましたが、逆方向――「コードの変更をデザインに反映する」ことも技術的には可能です。


コード変更(例: ボタンの色を変更)
    ↓
AST解析でスタイル変更を検出
    ↓
Plugin API経由でPenpotのデザインを更新
    ↓
デザインとコードが常に同期

これが実現すれば、デザイナーとエンジニアのどちらが先に変更しても、もう一方に自動的に反映されます。

 

11.4 マルチエージェント構成

Claude Codeのようなエージェントを複数立ち上げ、それぞれに異なる役割を持たせるマルチエージェント構成も考えられます。

  • デザインエージェント: ユーザーの要件からPenpotでデザインを自動生成
  • コーディングエージェント: デザインデータからフロントエンドコードを生成
  • テストエージェント: 生成されたコードのE2Eテストを作成・実行
  • レビューエージェント: デザインとコードの整合性をチェック

これらのエージェントがMCPを介して連携することで、「要件定義→デザイン→実装→テスト」の全工程を自動化できる可能性があります。現時点では実験的な構想ですが、MCPのプロトコルとPenpotのPlugin APIの組み合わせも、いいと思います。。


12. まとめ

12.1 本記事で実現できたこと

本記事では、Penpot + Claude Codeの組み合わせで以下を実現しました。

  • Penpotをオンプレ環境でセルフホスト: Docker Compose一発で立ち上がるデザイン環境。デザインデータは完全にローカルに留まります
  • Claude CodeからMCP経由で自然言語デザイン: 「LINEのようなチャットアプリのUIを作って」という指示だけで、Penpot上にデザインが自動生成される
  • Playwrightによるブラウザ自動制御: MCP接続の切断やPenpotのクラッシュから自動復旧。スクリーンショットによるデザイン確認も自動化
  • デザインファイルのgitバージョン管理: .penpotエクスポートスクリプトとGit LFSで、デザインの変更履歴をコードと同じgitリポジトリで管理
  • Penpotのデザインデータからコード自動生成: 6画面のLINE風チャットアプリをNext.js + Tailwind CSSで自動生成。画面遷移やスタイルまで正確に再現

12.2 Figma vs Penpot + Claude Code

最後に、公平な視点でFigmaとの比較。

観点 Figma Penpot + Claude Code
デザインツールの完成度 非常に高い。プラグインエコシステムも充実 発展途上だが基本機能は十分。Plugin APIは強力
リアルタイムコラボ 業界トップレベルのリアルタイムコラボ 対応しているが、Figmaほどのスムーズさはまだない
学習コスト デザイナーには低い。エンジニアには中〜高 エンジニアには低い(自然言語でデザインできる)
コスト Professional $15/月/人〜 無料(セルフホスト。インフラコストのみ)
セキュリティ クラウド依存。データは米国サーバー 完全オンプレ。データは社内に閉じる
バージョン管理 Figma独自のVersion History git管理可能。PR、タグ、ブランチ全対応
自動化 Figma API + MCP(限定的) Plugin API + MCP(フルプログラマティック)
コード生成 プラグイン依存。品質にばらつき Claude Codeがプロジェクト文脈を理解して生成
オフライン利用 不可 可能(完全ローカル環境)
カスタマイズ性 プラグインの範囲内 OSS。何でも変更可能

 

Figmaが向いている場面

  • デザイナー主導のプロジェクト
  • 大規模チームでのリアルタイムコラボレーションが必要な場合
  • 既存のFigmaプラグインエコシステムを活用したい場合
  • 安定性と成熟度を重視する場合

Penpot + Claude Codeが向いている場面

  • セキュリティ要件が厳しいエンタープライズ環境(金融、医療、政府系)
  • エンジニアがデザインも行う少人数チーム
  • デザインとコードの一貫管理が必要な場合
  • コスト最適化が重要な場合
  • 自動化・CI/CD統合を重視する場合
  • オフラインまたは閉域ネットワークでの運用が必要な場合

なので、どっちかを選ぶというより、用途で使い分けるのが現実的だと思います。Figmaは優れたデザインツールであります。

一方で、Penpot + Claude Codeは、特にエンジニアリング主導の開発プロセスにおいて、面白い存在になるかもしれません。

12.3 今後の展望

今回の実験を通じて、「AIがデザインもコードも書く時代」がすでに手の届くところまで来ていることを実感しました。

Penpotはまだ発展途上のプロジェクトですが、だからこそ伸びしろがあります。
OSSであることの強み――コミュニティによる改善、カスタマイズの自由度、ベンダーロックインからの解放

そしてClaude CodeのようなAIエージェントの進化は、ツールの使い方そのものを変えつつあります。「GUIを手で操作する」から「自然言語で指示する」への転換は、エンジニアがコードを書くのと同じ感覚で、デザインを「書く」ことができるイメージかもしれません。

 

 

グループ研究開発本部 AI研究開発室では、データサイエンティスト/機械学習エンジニアを募集しています。ビッグデータの解析業務などAI研究開発室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧 からご応募をお待ちしています。

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事