Claude Codeを使ったブラウザベースのビジュアル編集ツールとして、design-loopを作りました。

design-loopのスクリーンショット

左パネルに開発中のサイトのプレビュー、右パネルにClaude Codeのターミナルが表示されます。プレビュー上の要素をクリックすると、その要素のコンポーネント情報やスタイルがClaude Codeに渡されます。

インストール

curl -fsSL https://raw.githubusercontent.com/azu/design-loop/main/install.sh | sh

使い方

ソースコードがあるディレクトリで、開発サーバーがすでに起動している場合は、--urlでURLを指定するだけで起動できます。

design-loop --url http://localhost:3000

--commandオプションを使うと、開発サーバーの起動もdesign-loopに任せられます。開発サーバーが起動してからプロキシが自動的に接続します。

design-loop --url http://localhost:3000 --command "npm run dev"

起動すると、プレビューとClaude Codeのターミナルを統合したブラウザUIが開きます。プレビュー上の要素をクリックすると、コンポーネント名やファイルパス、スタイルなどのコンテキストがClaude Codeへの指示に含まれます。

Design Mode

v1.3.0で、ブラウザ上でページを直接編集できるDesign Modeを追加しました。

要素をクリックして「どこを変えたい」と伝えるだけでなく、テキストを直接書き換えたり、要素をドラッグ&ドロップで並べ替えたりできます。変更はログとして蓄積され、「Apply Changes」ボタンでまとめてClaude Codeに送信します。Claude Codeが変更内容を解釈し、実際のソースコードに反映します。

  • テキスト編集: 見出し・段落・ボタンなどのテキストをクリックして直接編集(IME対応)
  • ドラッグ&ドロップ: 要素を親コンテナ内でドラッグして並び替え
  • Undo: Cmd+Z / Ctrl+Zで変更を取り消し

要素の選択モード(Select Element)とDesign Modeは排他的で、同時には使えません。選択モードは「Claude Codeに何を変えるか伝える」ためのもので、Design Modeは「自分で変更してClaude Codeにコードへ反映させる」ためのものです。

作った背景

Claude CodeでWebサイトやアプリの見た目を調整するとき、次のような作業フローを繰り返します。

  1. ブラウザでサイトを確認
  2. 「このボタンのスタイルを変えたい」と感じる
  3. DevToolsで要素を特定し、どのコンポーネントか調べる
  4. エディタでファイルを開く
  5. Claude Codeに「このコンポーネントを変更して」と指示する

この行き来が繰り返されるのは、「サイトを見ている」コンテキストと「コードを指示する」コンテキストが分断されているからです。

指示を出す際に「どのファイルのどのコンポーネントか」を毎回説明するのは、LLMに対して本来不要なコンテキストを埋める作業です。ページ上で要素をクリックすれば自明な情報を、言葉で説明しています。

design-loopは、ブラウザ上でサイトを見ながら要素を選択し、その情報をそのままClaude Codeへ渡す仕組みを作ることでこの問題を解決します。

design-loopはエンジニア以外の人でも使えることを意識して作っています。DevToolsの知識がなくても、ブラウザ上で要素をクリックするだけでClaude Codeに指示を出せます。

技術的な実装

類似の課題をElectronやTauriのようなデスクトップアプリとして解決する方法もあります。しかしdesign-loopはCLI + HTTPプロキシという構成を選びました。

デスクトップアプリは配布が面倒です。コード署名、インストーラー、アップデート機構が必要になります。CLIツールとしてHTTPプロキシを立てる形なら、curlでインストールできます。また、HTTPプロキシであれば既存の開発サーバーに対して透過的に動作させられるため、フレームワーク側に手を加える必要がありません。

プロキシサーバーによるHTML注入

design-loopの中心はHTTPプロキシサーバーです。既存の開発サーバー(http://localhost:3000など)へのリクエストを中継し、HTMLレスポンスに対してスクリプトを動的に注入します。

ブラウザ → design-loopプロキシ → 開発サーバー
                ↓ HTMLにscriptを注入
ブラウザ ← design-loopプロキシ ← 開発サーバー

注入するスクリプトは2つです。

  1. WebSocketをオーバーライドするスクリプト(HMR接続をプロキシ経由に書き換え)
  2. 要素選択やデザインモードを実現するdesign-loop-inject.js

HTMLの注入は、Cloudflare WorkersのHTMLRewriterに似たストリーミング方式で行っています。HTMLレスポンス全体をバッファリングせず、ストリームを流しながら2か所に挿入します。

[先頭] WebSocketオーバーライドスクリプトを書き込む
  ↓ upstreamからのHTMLをそのままストリーミング
[末尾] <script src="/design-loop-inject.js"> を追記

バッファリングしない理由は、React 18やNext.js App RouterのSSRストリーミングに対応するためです。HTMLをすべて受け取ってから書き換えると、ストリーミングレスポンスが遅延してしまいます。

WebSocketスクリプトをストリームの先頭に注入するのは、次のページコンテンツが読み込まれる前にHMR接続のパッチを当てる必要があるからです。タイミングが遅れると、Next.js/webpackがすでにWebSocket接続を作成した後になってしまいます。

WebSocketのオーバーライドにより、ViteなどのHMR接続が壊れず、ホットリロードがそのまま動作します。また、X-Frame-OptionsやCSPヘッダーを削除することで、UIサーバーのiframe内に開発サーバーのページを埋め込めるようにしています。

inject-script: DOM要素のコンテキスト収集

注入スクリプトの主な役割は、クリックされたDOM要素のコンテキストを収集してiframeの親フレームにpostMessageで送ることです。

送信する情報は次の通りです。

const info = {
  selector: getCSSSelector(target),       // CSS Selector
  component: getReactComponentInfo(target), // Reactコンポーネント情報
  styles: getComputedStylesSummary(target), // 計算済みスタイル
  rect: { top, left, width, height },     // 要素の位置・サイズ
  tagName: target.tagName.toLowerCase(),
  textContent: target.textContent.slice(0, 100),
  ariaSnapshot: buildAriaSnapshot(target), // ARIAツリー
};

CSS Selector生成

ID優先で、なければタグ名・クラス名・:nth-child()を組み合わせた階層的なセレクタを構築します。

React Fiberからのコンポーネント情報

DOMノードにはReactが__reactFiber$xxxという内部プロパティを付与しています。これを辿ることでコンポーネント名・props・_debugSource(ソースファイルのパスと行番号)を取得できます。

// DOM要素からFiberを取得
const fiberKey = Object.keys(el).find(k => k.startsWith("__reactFiber$"));
let fiber = el[fiberKey];

// ネイティブ要素のFiberからコンポーネントFiberへ上に辿る
while (fiber && typeof fiber.type === "string") {
    fiber = fiber.return;
}
// fiber.type.displayName or fiber.type.name → コンポーネント名
// fiber._debugSource → { fileName, lineNumber, columnNumber }

この__reactFiber$を使ったアプローチは、ブラウザ上のReactアプリを解析する手法としてよく使われています。Claude Code DesktopのPreview機能も同様にReact Fiberからコンポーネント情報を取得しています。FigmaのClaude Code連携機能(From Claude Code to Figma)でも、ページにcapture.jsを注入して同様の処理をしています。

ARIAスナップショット

選択要素の周辺(ランドマーク境界まで最大5階層)のARIAツリーを文字列化します。テキストのみでページの構造的なコンテキストをLLMへ渡すためのアプローチです。スクリーンショットのようにピクセルではなく、セマンティクスを伝えます。

BunのトランスパイラでTypeScriptをブラウザに直接配信

inject-script.tsはTypeScriptで書かれていますが、サーバーサイドでのビルドステップは設けていません。代わりにBunの組み込みトランスパイラを使い、リクエスト時にその場でJavaScriptへ変換してブラウザに返しています。

// src/proxy/inject.ts
import injectScriptSource from "../ui/inject-script.ts" with { type: "file" };

export async function getInjectScript(): Promise<string> {
  const source = await Bun.file(injectScriptSource).text();
  const transpiler = new Bun.Transpiler({ loader: "ts" });
  return transpiler.transformSync(source); // 型を除去してJSを返す
}

with { type: "file" }というBun固有のimport attributeでファイルパスを取得し、Bun.Transpilerで変換します。esbuildやrollupは不要で、Bunランタイムだけで完結します。

Bunはランタイムとバンドラーが統合されているため、このようなツール開発に向いています。TypeScriptをそのまま実行でき、必要ならBun.Transpilerでブラウザ向けのJSを生成でき、PTYやHTTPサーバーも標準APIとして使えます。

同じ方向として、Electrobun v1もBunをベースにしたデスクトップアプリフレームワークです。ElectronのようにWebViewとバックエンドを統合しますが、Bunのエコシステムに乗ることで「外部ツールなしにTypeScriptとネイティブAPIを組み合わせられる」点が共通しています。こういうツールを作る際の選択肢になります(今回は使っていません)。

PTYとGhostty Webでブラウザにターミナルを統合

Claude CodeのプロセスはBunのPTY APIを使って起動します。Node.jsでPTYを扱うにはnode-ptyがありますが、ネイティブモジュールのためビルドが必要で、配布時に面倒が生じます。BunはPTYをBun.Terminalとしてランタイムに組み込んでいるため、追加のネイティブモジュールが不要です(ただし現時点ではWindows未対応)。Bun.spawnにterminalオプションを渡すだけでPTYにアタッチできます。

const terminal = new Bun.Terminal({
  cols, rows,
  data(_term, data) {
    // PTYの出力をWebSocket接続しているすべてのブラウザに配信
    for (const ws of connections) ws.send(data);
  },
});

Bun.spawn([shell, "-l", "-c", "claude"], { terminal, cwd });

ブラウザ側のターミナルエミュレータにはghostty-webを使っています。GhosttyはGPUレンダリングを使った高性能なターミナルアプリで、そのWebAssembly版(ghostty-web)をxterm.jsの代わりに採用しています。

再接続時のため、128KBのリングバッファでPTYの出力を保持しています。ブラウザがリロードされてもターミナルの表示内容を再生できます。

Design Mode: GUIの変更をコマンドにしてバッチで送る

Design Modeの設計上のポイントは、GUIでの変更をどうやってClaude Codeに伝えるかです。変更を構造化されたコマンドとして蓄積し、バッチでClaude Codeに送る方式を採用しています。

変更の種類はtext-edit(テキスト書き換え)とmove(要素の移動)の2つです。iframe内の注入スクリプトが変更を検出すると、postMessageで親フレームに通知します。

// テキスト編集の変更
{ type: "text-edit", selector: "h1.title", before: "旧タイトル", after: "新タイトル" }
// 要素の移動
{ type: "move", selector: ".card", oldIndex: 2, newIndex: 0 }

「Apply Changes」を押すと、蓄積された変更に正規化処理が走ります。同じ要素への複数回の編集は最初のbeforeと最後のafterに圧縮され、元の位置に戻った移動は削除されます。正規化後、変更をテキストにフォーマットしてClaude Codeに送信します。

[Design Mode Changes]
1. Text edited
   selector: h1.title (React: Header - src/components/Header.tsx:12)
   before: "旧タイトル"
   after: "新タイトル"
2. Element reordered
   selector: .card (React: ProductCard)
   moved from: .grid, index 2
   moved to: .grid, index 0

このテキストをユーザーの指示と一緒にClaude Codeへ渡すことで、変更内容を解釈してソースコードを書き換えます。

Claude Code DesktopのPreview機能との類似点と違い

2026年2月、Claude Code DesktopがPreview機能を追加しました。

Preview機能では、プレビュー画面で要素を選択するとスクリーンショット・React情報・DOMの情報がClaude Codeに渡されます。design-loopを公開した後にこのPreview機能が公開されましたが、DOM/React Fiberの情報を収集してClaude Codeに渡すというアーキテクチャはほぼ同じでした。同じ課題を解決しようとすると似たアーキテクチャになるのだと感じました。

設計思想の違いもあります。Claude Code DesktopのPreviewは「Claude Codeというプロンプト環境が主体で、プレビューはその補助ツール」として設計されており、プレビューのサイズ制限もあります。

design-loopは逆で「プレビューが主体で、Claude Codeのプロンプトは手段」という位置づけです。これはデザイナーが使うことを意識していたからです。

非エンジニアがAIツールを使っている様子を見ていると、複数のウィンドウやアプリを行き来すると混乱する人がとても多いです。SaaSの画面のように1つの画面で完結することに慣れていると、ファイラーやターミナルなど知らないものが同時に出てくると難しく感じます。プレビューとターミナルを1つの画面で見られること自体に価値があると、作っていて感じました。

Claude Code DesktopもCoworkやPreviewで同じ方向へ進んでいます。PencilLayrrのようなデザインとコード生成を統合するツールも登場しており、こうした統合ツールは今後増えていくのではないかと考えています。

作ってみて、Bun.Terminalとghostty-webの組み合わせでClaude CodeのUIをブラウザに持ってくるのは意外と簡単にできることがわかりました。PTYの出力をWebSocketで流してターミナルエミュレータに表示するだけなので、Claude Code向けのカスタムUIを作りたい場合の参考になれば幸いです。