
CSS
イベント

マガジン
技術ブログ
はじめに 楽楽シリーズUI統一プロジェクトとは フェーズ1: AIに任せられない領域 フェーズ2: 規模がもたらした新たな課題 AI活用の勘所: 実装ルールをAIに翻訳する なぜ「セルフチェック」だったか Cursor Rulesという仕組み 運用してみての手応え 振り返って見えたパターン 作業の性質とAI活用の相性 AI活用は、人間の作業との連携で成立する おわりに はじめに こんにちは。楽楽販売の開発を担当しているn-chocolatteです。 「AIを活用しよう」とはよく言われますが、いざ自分の現場に当てはめようとすると、「結局、どの作業に使えばいいのか」で手が止まってしまう。そんな経験のある開発者の方は、少なくないのではないでしょうか。 先にお伝えしておくと、今回私たちがAIを使ったのは 実装そのものではなく、実装後のセルフチェック工程 でした。 なぜそこに使ったのか——その判断の過程を、日々の開発にAIをどう取り込むか模索している方のヒントになればと思い、共有します。 本記事でご紹介するのは、ラクスが掲げる「AIネイティブな開発組織」という方針のもと、楽楽販売開発チームが「楽楽シリーズUI統一プロジェクト」の中で実践したAI活用の一例です。 テーマは「 作業の性質を見極めて、AIの勘所を押さえる 」。 AIに任せられる作業と、人間が責任を持つべき作業を見極め、適切な領域にAIを投入する。 今回のプロジェクトは、そうした判断の積み重ねの記録でした。 楽楽シリーズUI統一プロジェクトとは 「楽楽シリーズUI統一プロジェクト」は、楽楽シリーズの全商材でデザインを統一する、シリーズ横断の大規模な取り組みです。 きっかけはお客様の体験でした。 楽楽精算など他商材をお使いのお客様が新たに楽楽販売を導入された際、見た目や操作感が全く違うと、それだけで戸惑いの原因になります。 同じシリーズなのにデザインがばらついていて使いづらい――この見えない障壁を取り払うのが目的です。 実装としては、各商材が共通のデザインシステムに準拠する形でUIを再構築していきました。 楽楽販売では、特有のコンポーネントが必要な場面はデザイナーと相談しながら詰めていきました。 プロジェクトはチームで分担し、複数のフェーズに分けて進めました。 フェーズ1とフェーズ2の二段階に分かれており、それぞれ性質の異なる画面群を対象にしています。 フェーズ1: AIに任せられない領域 フェーズ1の対象は一般ユーザ向け画面です。 お客様が日常的に操作する、楽楽販売の中心的な画面群です。 このフェーズでは、56画面を約1年かけて対応しました。 想定以上に時間がかかったのは、 対象の画面の作りが非常に複雑だった からです。 長年運用されてきた楽楽販売のコア画面は、既存のデザインやJavaScriptが複雑に絡み合っており、一つ手を入れると別の部分が壊れる(いわゆるデグレが発生する)リスクが常にありました。 UI統一とはいえ、見た目だけを揃えれば良いわけではなく、既存の挙動を維持しながら慎重に改修を進める必要があったのです。 このフェーズで、私たちはAIによる自動実装をほぼ使いませんでした。 理由は単純で、 自動化に任せると既存実装を壊すリスクが大きすぎたから です。 画面ごとに個々の微妙な事情があり、汎用的なルールで一律に変換できる作業ではありませんでした。 人間が一つひとつのコードを丁寧に読み解き、責任を持って実装する。それがフェーズ1で必要なことでした。 このフェーズで得たのは、「 AIに任せられない領域が確かにある 」という現場の体感でした。 この感覚が、次のフェーズでの判断に繋がっていきます。 フェーズ2: 規模がもたらした新たな課題 フェーズ2の対象は設定系画面です。 管理者設定やDB設定など、お客様の日常操作からは少し離れた位置にある画面群です。 フェーズ1とフェーズ2の実績を、下の表で対比してみます。 フェーズ1(一般ユーザ向け画面) フェーズ2(設定系画面) 画面数 56 350 開発期間 約1年 約半年 AI活用 なし Cursor Rulesによるセルフチェック 画面数が約6倍に増えたにもかかわらず、開発期間は半分に収まっている ——この点にご注目ください。 ただ、これはAI活用だけによる結果ではありません。 対象画面の性質や実装ルールの明確さといった要因が大きく、後述するAIの役割はそのうちの品質担保を支えた一要素にすぎません。 両フェーズの作業の性質の違いが、この差に表れています。 フェーズ1が、複雑な画面ゆえに何度も手戻りを繰り返す作業だったのに対し、フェーズ2は、設定系画面の比較的シンプルな作りに加え、明確な実装ルールがあったことで、基本的に一画面の対応を一度で完結させられました。 フェーズ1ほど手戻りが発生せず、安定したペースで進められたのです。 フェーズ2では進め方も変えました。 350画面を半年で完遂するため、 チームで手分けして大量の画面を並列に実装していく 形に切り替えたのです。 ここで生まれたのが、新たな課題でした。 大量の並列実装を、どうやって品質を担保しながら捌くか 。 実装そのものは機械的な作業なので、進めること自体は可能です。問題はその後のレビューでした。 350画面分のレビュー依頼が次々と上がってくる中で、レビュアーが毎回「アイコンが置換されているか」「不要なクラスが消えているか」「パンくず構造が揃っているか」を目視で確認していくのは、現実的ではありません。 一方で、レビューで本当に確認したいのは、 UI崩れが起きていないか、特殊ケースで挙動が壊れないか、ユーザ体験として問題ないか といった、人間の判断が必要な部分です。 ルール遵守の機械的なチェックに時間を取られ、肝心の「人間の判断が必要な部分」に目がいかない。 これが、作業規模の拡大によって向き合うことになった課題でした。 なお、フェーズ2の実装を機械的に行えるようにするため、私たちは事前にチーム内で詳細な実装ルールを整理していました。 「z-indexの削除」「特定のCSSクラスから別のクラスへの置換」「 <i class="fa ..."> 形式のアイコンから <span class="material-symbols-rounded"> 形式への置換」など、変換パターンが網羅的にドキュメント化されていたのです。 このドキュメントの存在が、次に紹介するAI活用の前提条件になりました。 AI活用の勘所: 実装ルールをAIに翻訳する なぜ「セルフチェック」だったか 私たちがAIを投入した先は、 実装そのものではなく、実装ルールに沿っているかのセルフチェック でした。 実装そのものをAIに任せる選択肢もありました。 ただ、フェーズ1で痛感したように、AIに任せると壊れる領域があります。 フェーズ2の画面は比較的シンプルとはいえ、リスクがゼロというわけではありません。 だからこそ、実装は引き続き人間が責任を持って行い、AIにはチェックを支援してもらう。 この役割分担が、フェーズ2の作業の性質に合っていた のです。 セルフチェックを支援する仕組みがあれば、実装者自身が手元で「ルールに沿っているか」を確認できます。 レビュー依頼が来る段階では、ルール遵守の観点でのチェックが一通り済んでいる状態になる。 レビュアーは、 人間でなければ判断できない部分 にレビューの労力を集中させられるようになります。 Cursor Rulesという仕組み 当時、楽楽販売開発チームではAIコードエディタとしてCursorを採用していました。 Cursorには「Rules」という機能があり、プロジェクトのルートディレクトリに特定の形式でルールファイルを置いておくと、AIが回答を生成する際にそのルールを常に参照してくれます。 このRules機能に、先ほどの実装ルールを記述しました。日本語で書かれた実装ルールを、AIが扱える形のルールファイルに整形する作業です。 完成したファイルは、変換パターンが before / after の具体例つきで章立てされたものでした。下記のような構造です。 ## 不要なアイコン要素の削除 - 不要なアイコンは削除しなければならない - サンプル - before: (元のHTML) - after: (修正後のHTML) 観点のカテゴリとしては、以下のようなものがありました(具体的なクラス名やタグ名は伏せています)。 不要な装飾要素の削除ルール アイコン体系の置換ルール パンくず構造の修正ルール メッセージ表示パターンの置換ルール ページネーション構造の置換ルール パネル構造の追加・調整ルール JavaScript関数名の置換ルール 実装者は改修作業を終えた後、AIに「このルールに沿っているか確認してください」と依頼します。 すると、変更されたコードがルールに照らしてチェックされ、不足や誤りがある箇所を指摘してくれます。 これがフェーズ2の運用の中核でした。 ここで「決まったルールに沿っているかの確認なら、LinterやFormatterのような静的解析ツールで十分では?」と思われるかもしれません。 実際、不要なクラスの削除のような単純な置換であれば、その通りです。 ただ今回のルールには、パンくずやパネルの構造変更、メッセージ表示パターンの置換など、 HTMLの文脈や画面全体の構成を踏まえないと正しく判定できない観点 が多く含まれていました。 「どの位置にどの要素を足すべきか」は画面ごとに事情が異なり、機械的なパターンマッチだけでは拾いきれません。 変更内容の意味を汲んで柔軟に判断してもらえる点が、LLMにチェックを任せた理由でした。 ちなみに、現在ではチームのメインツールはVSCode + Claude Codeに移っています。 ツールは流動的に変わっていきますが、 プロジェクト固有のルールをAIに与えるという考え方は、ツールが変わっても通用する普遍的なアプローチ だと感じています。 運用してみての手応え 正直に申し上げますと、このAI活用によって「具体的に〇時間短縮された」「レビュー工数が〇パーセント減った」といった定量データは、私たちの手元にはありません。 それでも、現場での体感としていくつかの手応えがありました。 ひとつは、 人間の目視という不確定要素が減ったこと です。 実装ルールが大量にあると、人間がレビューで全てを抜け漏れなくチェックするのは難しい。 AIによる事前チェックが入ることで、この見落としリスクが大きく下がりました。 実際、フェーズ2全体を通じて、手戻りは少なかったという体感があります。 そしてもうひとつ、 人間でなければ気づけないところに力を割けたこと です。 ルール遵守のチェックをAIに任せられた分、レビュアーである私たちは「この画面の使い勝手はこれで本当に良いのか」「特殊なケースで挙動が崩れないか」といった、人間の判断が必要な観点に集中できるようになりました。 結果として、350画面の改修を約半年で完遂できました。 これはAI活用だけによるものではなく、対象画面の性質や並列開発しやすい構造、事前のルール整備など複数の要因が重なった結果です。 物量だけ見れば、フェーズ1のペースのまま単純計算すると何年もかかる規模です。 AIにセルフチェックを任せ、人間が本質的なレビューに集中できる体制を作れたことが、この規模を半年で進める支えになりました。 正直に言えば、大量の画面をさばき続ける作業は、地道でなかなか骨が折れるものでした。 それでも、無事に完遂できたときの安堵と達成感は大きかったです! 振り返って見えたパターン プロジェクトを終えてから振り返ると、いくつか一般化できそうな学びが見えてきました。 作業の性質とAI活用の相性 フェーズ1とフェーズ2の対比からは、 作業の性質によってAIの勘所が変わる ことがはっきり見えました。 複雑で固有性が高い作業は人間が向き合う方が結果的に効率が良く、機械的でルール化でき量も多い作業はAI支援の効果が出やすい。 ただ、この見極めは机上では難しく、 現場で手を動かして得られる感覚 だと思います。 フェーズ1で複雑な画面と格闘したからこそ、フェーズ2でAIを投入する判断ができたのです。 AI活用は、人間の作業との連携で成立する もう一つ、振り返って気づいたことがあります。 「AI活用」は単独のアクションではなく、人間の作業と組み合わさったワークフロー全体である 、ということです。 今回のプロジェクトを工程ごとに分解してみると、こうなります。 実装ルールを決める(人間の作業) ルールをAIが扱える形に翻訳する(人間の作業) AIによるセルフチェック実行(AIの作業) 人間でなければ気づけない部分に集中してレビュー(人間の作業) このうち、純粋に「AIの仕事」と呼べるのは3だけです。それ以外は全て人間が行っています。 それでも、3を組み込むことで全体の効率と品質が大きく変わる。 特に2の「翻訳作業」は肝になる工程でした。AIが解釈しやすい構造に整え、具体例を添え、抜け漏れなく記述する。 今回うまくいったのは、AIが優秀だったからではなく、AIが判断できる形までルールを整えたから だと感じています。 少なくとも今回のケースでは、成否を分けたのはモデルの性能そのものよりも、「AIに何を、どう渡すか」という設計の方でした。 渡す側でどれだけ作り込めるかは、これからのAI活用でも軽視できないポイントだと思います。 そう考えると、今回行ったことは「AIに仕事を任せた」というより、 AIが得意な領域へ仕事を再分配した 、と言うのが近い気がします。 大量のルールを機械的に適用していくところはAIへ。 ルールを定義すること、ルール化できない例外を判断すること、最終的な品質に責任を持つことは、引き続き人間へ。 私たちがAIに任せたのは「セルフチェック」だった——その一点に、この再分配の線引きが表れています。 おわりに 楽楽販売開発チームが楽楽シリーズUI統一プロジェクトで実践したAI活用について、なるべく等身大にお伝えしてきました。 派手な成果や劇的なBefore/Afterはない記事だったかもしれません。 それでも、こうした地に足のついた判断の積み重ねこそが、AIネイティブな開発組織の実像なのだと思っています。 ツールはこれからも変わっていくでしょうが、 AIをどこに使うかを考え続けること 、その姿勢は変わらないはずです。 本記事が、同じように現場でAIと向き合う開発者の方々の参考になれば嬉しいです。 最後までお読みいただき、ありがとうございました。
こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の7日目の記事です。 EGP Code は、ランディングページ(LP)を AI と作る社内向けのエディタです。作成背景については AI と作る HTML ベースの LP エディタ EGP Code を内製した理由 という記事で紹介しました。本記事では、その内部で動いている 4 つの仕組みを紹介します。 EGP Code が扱うのは、HTML と状態や動きを担う少数の <egp-*> (独自の Web Components)を混ぜた 1 枚のページです。 <section class="p-6 text-center"> <h1>春のキャンペーン</h1> <p>応募受付中</p> <egp-button>応募する</egp-button> </section> この HTML を AI エージェントとの対話やエディタで編集し、プレビューで確認して公開します。紹介する 4 つの仕組みは、(1) エージェントの再帰ループ、(2) Firestore を介したリアルタイム反映、(3) ブラウザだけで完結するテストランナー、(4) プレビューと HTML を結ぶ対応表です。 1 つの指示がユーザーから届いてプレビューに反映されるまでの流れと、それぞれの仕組みが効く場所は次のとおりです。 仕組み 1: 文脈とツールを束ねるエージェントの再帰ループ 「フォントサイズを 24px にして」「文字を太くして」のように特定の要素への簡単な指示なら、その要素を特定して CSS を更新したり文言を調整したりするだけなので、ほぼ 1 回の操作で終わります。一方で複数の要素にまとめて指示したり、API を使った画面やテストを作ったり、Lint・テストのエラーを直したりする場合は、推論とツール実行を何度か往復することになります。 エージェントは「推論 → ツール実行 → 結果を会話履歴に追加 → 再び推論」という再帰ループで動きます。ここでいうツールとは、AI が必要と判断したときに呼べる関数の定義と説明のことです。HTML を書き換える、ファイルを読む、Lint で検証する、テストを走らせる、といった操作をツールとして用意しておき、モデルがその中から必要なものを選んで呼び、戻り値を見て次の手を考えます。 1 回の入力に対して 4 つの情報をまとめて渡します。システムプロンプトで AI エージェントに役割やコード生成のルールといった前提を与え、それにユーザーの指示と選択要素と現在の HTML、これまでの会話履歴、そして使えるツールの一覧を教えます。このうち現在の HTML・仕様・テストといったページの状態は、種類ごとに XML タグで区切ってまとめます。 会話履歴の持ち方は、開発当初 OpenAI API 側に任せていました。しかし全社的に ZDR(Zero Data Retention) を適用することになり、プロバイダ側に会話を残せなくなったため、いまは履歴をすべて自前で記録し、毎回のリクエストに載せて送るステートレス方式にしています。履歴の肥大化を防ぐため、一定量を超えたら要約させてコンテキストを圧縮しています。 // 1 ラウンドの推論 const stream = await client.responses.stream({ ...args, input: sessionBuffer.getItems(), // 自前で管理している履歴全体を送る previous_response_id: undefined, store: false, // プロバイダ側に会話を保存させない }); ループの工夫をいくつか紹介します。 1 つ目は、ツールの失敗の扱いです。ここでいう失敗とは、 apply_patch の差分が当たらない、Lint がエラーを返す、テストが落ちるなど、ツールが期待どおりに完了しなかった状態を指します。こうした失敗ではループを止めず、エラーの内容をそのまま結果としてエージェントに返すことで自己修正させています。 2 つ目は、 find_skill ツールによる情報の出し分けです。社内 API の使い方などのドキュメントは、最初は ID と一行説明の一覧だけを見せておき、本文は必要になったタイミングで読み込みます。たとえば「商品一覧を表示したい」という指示が来ると、エージェントはまず一覧から関連しそうなものを探し、 find_skill でそのドキュメント本文を取得します。エージェントは取得したドキュメントを読み、正しい引数で API を呼びます。 この推論とツール実行の往復を繰り返し、最後にエージェントが apply_patch で HTML を差分更新すると 1 つの指示が編集として完成します。 ループの途中で方向を変える Real-time steering ここまでは、1 つの指示を最後まで処理してから次を受け取る前提でした。ですが実際には、処理の途中で「やっぱり色は青にして」と方針を変えたり、「ついでにフッターも直して」と指示を足したくなることがあります。完了を待たずに割り込みで指示を足し、走っているループに後から反映する仕組みを用意しています。こうした仕組みは Real-time steering とも呼ばれます。 ユーザーが処理中に指示を足したときの流れは、次のようになります。 エージェントが処理中(画面がローディング中)にユーザーがメッセージを送ると、クライアントはそれを通常の指示ではなく、割り込みメッセージとして送信します。サーバは受け取った割り込みメッセージを、 Firestore の通常の会話履歴とは別のサブコレクションに、いま走っているリクエストの ID を添えて書き込みます。エージェントは、自分が処理しているリクエストの ID に一致する割り込みだけを読み取ります。 // 自分のリクエスト宛の割り込みメッセージだけを読み、読んだら消す const consumeSteeringMessages = async (conversationId, requestId) => { const snapshot = await steeringRef .where('requestId', '==', requestId) // このリクエスト宛だけを対象にする .orderBy('timestamp', 'asc') .get(); const messages = snapshot.docs.map((doc) => doc.data()); await deleteDocs(snapshot.docs); // 読んだら消す return messages; }; ID で絞るので別のリクエスト宛の割り込みを拾うことはなく、読んだら消すので同じ指示が二重に効いたり取りこぼしたりすることもありません。処理のループに差し込むため、割り込みが来たときにエージェントが何をしようとしていたかで、対応が 2 つに分かれます。 1 つ目は、ツールを呼ぼうとしていた場合です。そのツールを実行せず、戻り値の代わりに「ツールは実行していません。処理中に新しい指示が届きました」という内容を返します。 // ツール実行の直前に割り込みを確認する const steering = await consumeSteeringMessages(conversationId, requestId); if (steering.length > 0 && toolCalls.length > 0) { for (const toolCall of toolCalls) { // ツールは実行せず、戻り値の代わりに割り込みを差し込む buffer.pushMessage({ role: 'tool', tool_call_id: toolCall.id, content: '[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...', }); } continue; // 計画を立て直すため、もう一度推論へ } 2 つ目は、ツールを呼ばずに、ユーザーへの返信メッセージを作り終えていた場合です。本来ならこれを見せてループが終わるところですが、割り込みが届いたので止めるべきツールがありません。この返信はまだ画面に出していないので破棄し、直前のユーザーの指示に割り込みメッセージを足して送り直すことで、続きの作業を依頼します。 // ツールがない場合は、画面に出していない返信を捨てて指示を足す if (steering.length > 0 && toolCalls.length === 0) { buffer.pop(); // まだ画面に出していない返信を捨てる const priorUserTurn = buffer.pop(); // 直前のユーザーの指示を取り出す buffer.pushMessage({ role: 'user', content: `${priorUserTurn.content}\\n[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...`, }); continue; } いずれの場合も、すでに実行した副作用を巻き戻すわけではなく、これから実行するはずだったツールを止めたり、まだ画面に出していない返信を捨てたりして、計画を組み直しています。 仕組み 2: Firestore を指示の受け渡し場所にしたリアルタイム反映 仕組み 1 で見たように、エージェントは複数のツールを往復させて指示に応えるため、編集には時間がかかることがあります。処理が終わるまで画面が何も更新されないと、利用者は反映されたかどうか分からないまま待つことになります。 そこで Firestore SDK を利用して、 変更をリアルタイムに受け取れる ようにしています。サーバ側は 1 つの操作が終わるたびにその内容を Firestore へ書き込み、ブラウザ側はそれをサブスクライブして即座に検知・反映します。これで自前で WebSocket を張らずに、編集の途中経過をそのままプレビューへ反映できます。 エージェントが何かを書き換えると、サーバは会話ごとのコレクションに、次のようなドキュメントを 1 件追加します。 { "status": "PENDING", "requests": [ { "action": "setHtmlSchema", "payload": "<body> ...更新後の HTML... </body>", "reason": "ユーザーの依頼を反映" } ] } ブラウザ側の JS は、Firestore の SDK( onSnapshot )でこのコレクションをサブスクライブしており、 PENDING のドキュメントが届くと requests の各操作をエディタの状態へ反映します。たとえば setHtmlSchema なら、エディタが表示している HTML を新しいものに置き換えて、プレビューを再描画します。 ブラウザが requests の操作を反映し終えると、その JS が status を COMPLETED に書き戻します。HTML 差し替えなどの「反映だけ」のアクションは、投げたら終わりで結果を待ちません。一方でテスト実行のように結果が必要なアクションでは、ドキュメントが COMPLETED になるまで一定間隔で読み直して、書き込まれた結果を取り出します。取り出した結果はツールの戻り値としてエージェントに返り、それを見て次のツール呼び出しを決めます。 仕組み 3: ブラウザだけで完結するテストランナー 応募ボタンを押したときの API 呼び出しやその結果に応じた表示の切り替え、リンクによる画面遷移といった LP の動作を手動で確認するのは手間がかかります。そこで EGP Code では、こうした動作をテストで確かめられるようにしています。 テストはブラウザ上のエディタで直接書いたり、AI に書かせたりできます。これらのテストは、サーバや CI ではなく、プレビューと同じブラウザの中で実行します。ただし Jest や Vitest は Node.js 上で動くツールなので、そのままブラウザには読み込めません。そこで test / it / describe / expect を提供する小さなランナーを自作しました。アサーションは単体で使える @vitest/expect 、DOM 操作は Testing Library をそのまま利用しています。 // 自作の test 関数でテストを定義する test('エントリーボタンで API が呼ばれる', async () => { // Testing Library でユーザーのクリックを再現する await userEvent.click(screen.getByText('エントリー')); // @vitest/expect で結果を検証する expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが社内 API を呼ぶこともありますが、本番に飛ばすわけにはいきません。そこで iframe 内で window.fetch を差し替え、リクエストはすべてモック関数に通します。モックしていない呼び出しはエラーになるので、本番へ漏れることはありません。 テストの実行は、この iframe を作るエディタのページ(ホスト)と iframe の postMessage のやり取りで進みます。 ホストは iframe を作ってテスト用 HTML を流し込み、iframe 側の初期化( window.fetch の差し替えなど)が済むのを待ってから実行を指示します。先に指示が届くと取りこぼすため、必ず「準備完了」を待つようにします。結果は仕組み 2 のアクションでエージェントへ戻ります。失敗していれば、仕組み 1 で触れた自己修正がここで働き、内容を読んで実装を直してもう一度走らせます。 仕組み 4: プレビューの要素と HTML の位置を結ぶ対応表 ここまではエージェント主導の編集を見てきましたが、人が直接手を入れる場面もあります。Code タブを開くと Monaco エディタで HTML を直接編集でき、プレビューでリアルタイムに確認できます。プレビューの要素をクリックしてエージェントへ指示を出したり、Monaco の対応行へジャンプするには、「プレビューの要素と HTML 上の位置」を対応づける仕組みが必要です。 HTML をパースして各要素へ data-egp-src-id (公開ページには残らない内部 id)を注入してプレビューを描画することで、これを実現しています。 <section data-egp-src-id="src-10"> <h1 data-egp-src-id="src-11">春のキャンペーン</h1> <egp-button data-egp-src-id="src-42">応募する</egp-button> </section> プレビュー上で「応募する」ボタンをクリックすると src-42 が取れます。クリック先が Web Components の内部要素などで id を持たない場合もあるため、 closest で祖先方向に遡って最寄りの data-egp-src-id を探します。 取得した id はエディタが各要素に振った内部 id で、対応表を引くキーになります。対応表はパース時に作る「id → その要素の HTML 上の位置」の Map で、id を引くと文字列オフセットやタグ名が取れます。 // 対応表(id → HTML 上の位置とタグ) Map { 'src-10' => { range: { startOffset: 0, endOffset: 130 }, tagName: 'section' }, 'src-42' => { range: { startOffset: 64, endOffset: 110 }, tagName: 'egp-button' }, // ... } クリックされるのは描画後の DOM 要素で位置を持つのはこの対応表です。要素に振った data-egp-src-id をキーにすることで、両者を突き合わせています。この対応表には 2 つの用途があります。1 つ目が Monaco エディタへのジャンプです。 // 対応表から位置を取得 const range = await mappingApi.findRangeById('src-42'); // オフセットを Monaco の行・列に変換 const pos = model.getPositionAt(range.startOffset); // その行をエディタの中央に表示してカーソルを移動 editor.revealPositionInCenter(pos); 取得した id を元に対応表から文字列オフセットを取り出して Monaco の行・列に変換します。その行をエディタ中央にスクロールしてカーソルを移動することでジャンプさせています。 もう 1 つが AI への指示です。 data-egp-src-id が付いているのはプレビュー用に描画した HTML だけで、エージェントが読み書きする公開用の HTML には付いていないため、id をそのまま渡しても対応する要素は見つかりません。そこで id は対応表を引くキーとしてのみ使い、エージェントには、そこから取り出した要素の HTML・タグ名・行番号をユーザーの指示文と一緒に XML へまとめて渡します。 <annotated_elements> <annotation> <tagName>egp-button</tagName> <snippet><egp-button class="..."></snippet> <lineNumber>10</lineNumber> <instruction>色を赤にして</instruction> </annotation> </annotated_elements> エージェントはこれらを手がかりに HTML の中から対象要素を見つけて指示に対応します。このように指示を XML タグで構造化する書き方は、 Anthropic のプロンプトのベストプラクティスの一つ としても紹介されています。 おわりに 一見シンプルに見える AI エディタですが、実際に体験を作ろうとすると、待ち時間が長い、途中経過が見えない、どこを編集したか分からないといった小さなつまずきが積み重なります。EGP Code では、そのつまずきを 1 つずつ潰すために、ここまで紹介した仕組みを裏側で積み上げてきました。AI を組み込むときほど、モデルの手前にある体験と安全性を設計することが重要だと感じています。本記事が、AI を組み込んだプロダクトづくりの参考になれば幸いです。 次の記事は@mikupoさんです。引き続きお楽しみください。
こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の 2 日目です。 私たちのチームは、マーケターや PM 向けの社内ツール群 Engagement Platform (EGP) を開発しています。ランディングページ (LP) の作成・公開もその一機能で、過去に WYSIWYG コンポーネントエディタ EGP Pages について同じチームから紹介記事を出しています。 今回はその後継としてゼロから作り直した EGP Code を紹介します。AI エージェントと対話しながら LP を作るための、HTML ベースの社内向け LP エディタです。見た目を生成するだけでなく、本番運用に必要なところまで踏み込んで同じ編集体験の中に組み込んでいるのが特徴で、すでに 10 件以上の本番 LP がこの仕組みで作られています。 v0、Gemini Canvas、Claude Design、Figma Make など、AI で UI を作れるツールはすでに数多くありますが、見た目は作れても本番 LP として運用するには、API 連携・品質保証・Native 連携といった社内固有の課題が残ります。EGP Code は、このギャップを埋めるために内製しました。 EGP Pages と AI 編集における課題 EGP Pages は、ブロックを選んで組み合わせるノーコードの WYSIWYG コンポーネントエディタです。ドラッグ&ドロップで Layout や Text といった 40 種類以上のコンポーネントから、ページを組み立てます。マーケターがエンジニアの手を借りずに LP を作れるという目的に対して非常によく機能しており、いまも多くの LP が EGP Pages で作られています。 転機になったのは、AI でページを編集したいというニーズが出てきたことです。EGP Pages は人がドラッグ&ドロップで組み立てる前提で設計されており、AI が扱う際にデータ構造が問題になります。例えば、ボタンを押すと数字が増えるページの JSON ツリーは次のようになります。 { "components": [{ "id": "root", "elements": [ { "id": "1", "tagName": "Context", "props": { "value": [ { "name": "count", "type": "code", "value": "0" }, { "name": "increment", "type": "code", "value": "(count) => count + 1" } ]}}, { "id": "2", "tagName": "Layout", "props": { "children": [":=element.3", ":=element.4"] }}, { "id": "3", "tagName": "Text", "props": { "value": "Count: ${context.count}" }}, { "id": "4", "tagName": "Action", "props": { "label": "+1", "onTriggerAction": [{ "type": "SET_CONTEXT", "payload": { "count": "${context.increment(context.count)}" }}]}} ] }] } 人がエディタ越しに触る分には、この構造でも問題ありませんが、LLM に直接編集を任せようとすると課題が見えてきます。 ツリー構造が独自 : ":=element.3" のような独自記法を AI に都度教える必要があり、プロンプトが長くなります。 ロジックが分散 : 状態・条件・動作・表示が Context / When / Action / Text に散らばり、挙動の把握にツリー全体を辿る必要があります。 JSON 文字列の中に JavaScript が埋まっている : テンプレートリテラルか eval される式かが描画コンポーネント次第で、正しい解釈が難しくなります。 さらにこの JSON ツリーは等価な HTML のおよそ 2 倍のトークンを消費し、編集のたびに API コストとコンテキスト消費が膨らみます。加えてテスト基盤がなく、AI の編集結果を公開前に機械的に検証する手段がありませんでした。これは EGP Pages の設計が悪かったわけではなく、ノーコード時代に最適化された正しい設計でした。ただ AI に編集させるという前提が加わったことで設計を問い直す必要が出てきたのです。 HTML ベースで作り直す 選択肢は2つありました。既存の JSON 表現を AI 向けに改善するか、AI 前提でゼロから作り直すかです。私たちは後者を選び、ページの表現を HTML ベースにしました。HTML は人にも LLM にも馴染みがあり、独自の JSON ツリーや参照記法を毎回プロンプトで教える必要がないからです。 先ほどのカウンターページは、EGP Code では次にようになります。 <body> <egp-script timing="page-loaded"> rx.count = 0; </egp-script> <egp-script> rx.increment = () => { rx.count = (rx.count ?? 0) + 1; }; </egp-script> <p><egp-text>Count: {{rx.count}}</egp-text></p> <egp-button :onclick="rx.increment">+1</egp-button> </body> 機能は同じですが、コード量もトークン消費もおおよそ半分です。ただし、素の HTML だけでは、状態管理や条件分岐といった動的な振る舞いは表現できません。かといって <script> で自由に JavaScript を書かせると、ページの挙動を追いづらくなります。 そこで、状態管理・条件分岐・繰り返しといった動的な部分だけを、少数の Web Components ( <egp-*> ) に閉じ込めました。「静的な部分は普通の HTML、動的な部分だけ Web Components」という切り分けによって、EGP Pages のように状態・条件・動作が散らばらない構造になっています。 見た目のスタイリングには Tailwind CSS を採用しています。Web 開発者に馴染みのある書き方に寄せることで、人も AI も独自の作法を覚えずに済みます。また、副次的な効果として、外部ライブラリへの依存が最小限になり、LP ごとに独自パッケージが混ざりません。ランタイムは中央で管理する少数のものだけで動くため、npm パッケージ起因のサプライチェーンリスクが問題になる中でも安全面で利点があります。 使い方 EGP Code では、ほとんどの操作を AI エージェントとのチャットで進めます。「LP を作りたい」のように要件が固まっていない依頼では、エージェントはいきなり作り始めるのではなく、文脈に応じた質問を返してくれます。対象デバイス、カラーテーマ、入れたいセクションといった項目を選択肢から答えていきます。 回答を送ると、エージェントが HTML やテストをまとめて生成し、たたき台となる LP ができあがります。 大まかな見た目ができたら、ボタンやテキストなどの要素を直接選んで仕上げます。対象をクリックして「文字サイズを大きくして」「文言を〜に変えて」のように頼めば、位置を説明しなくてもエージェントが直す箇所を正確に把握します。複数箇所にまとめてコメントを付けたり、参考にしたい画像を貼って渡すことも可能です。 実運用に必要な 3 つの仕組み LP と聞くと、文章と画像が並んだ静的なページを思い浮かべるかもしれませんが、実際には、エントリー状況で CTA ボタンを切り替えたり、お客さまの属性で見せ方が変わったりと動きを伴う LP も少なくありません。 そのため、見た目だけが整っていれば十分というわけではなく、社内 API との連携、タップ時の分析ログ送出、公開前に表示や挙動を検証する仕組み、アプリと Web の遷移差を吸収する仕組みなど、周辺の仕組みもあわせて必要になります。 EGP Code では、こうした仕組みを編集体験の中に組み込んでいます。以降では、この 3 つの仕組みを順に紹介します。 社内 API 連携と Logging LP からは、商品一覧の取得やエントリー状況の確認といった社内 API への呼び出しが頻繁に発生します。しかし、社内 API は AI ツールの学習データに含まれていません。EGP Code では、このギャップを「使い方をその場で AI に教える」仕組みで埋めています。 例えば「商品一覧を出す API は?」と聞くと、エージェントは候補の社内 API を用途つきで挙げてくれます。 このやり取りの裏側では、エージェントが「関連する API を探す → 使い方を理解する → 型付きで実装する」という流れで動いています。 この流れを実現するために、いくつかの工夫をしています。まず、社内 API の使い方を、API ごとに Markdown ファイルに記載しています。実際には、次のようなファイルが並んでいます。 api-searchExampleItems.md api-postExampleEntry.md api-getExampleSegment.md api-getExampleRecommendations.md runtime-event-log.md ... すべての API の説明をプロンプトに乗せてしまうと不要にトークンを消費してしまいます。そこで各ドキュメントのタイトルと用途だけを渡しておき、AI が必要と判断したときにだけその本文を読み込ませる形にしています。 次に、社内 API は型付きの薄いラッパー関数越しに呼び出します。LP から見えるのは関数呼び出しだけで、認証ヘッダ・サービス分岐・パスの違いはラッパーが吸収します。誤った使い方があれば Lint が検知します。 分析ログの送出も同じドキュメント参照のしくみで扱っています。 ログが必要になったタイミングで、エージェントが対応するドキュメントを読み込み、API 呼び出しと同じ流れでログ用のコードを生成します。このしくみによって、AI に「商品一覧を出して」と頼むだけで、社内 API を正しく呼んだ動的な LP が組み上がります。 エディタ内で完結するテストと品質保証 AI が生成した API 呼び出しが正しく動いているか、ボタンが期待どおりに反応するかを、変更のたびに人が目視で確認するのは現実的ではありません。そこで EGP Code は、エディタ内にテストの仕組みを内蔵しています。 エンジニア以外にとってテストは馴染みがなく、自分で作成するのはハードルの高い作業です。そこで、テストを直接書く代わりに、実現したい振る舞いを自然な言葉で書ける Spec タブを用意しています。ここに、LP の仕様を書き残していきます。 あとは AI とのチャットで「@SPEC.md を元にテストを書いて」と頼むと、文脈に応じてテストが自動で生成されます。テストはエディタ向けに自作した Jest 風の API で書いており、内蔵のモックサーバで fetch を差し替えられるので、本番 API がなくても動的ページの挙動をエディタ内で再現できます。 // ブラウザ上でそのまま実行される test('エントリーボタンで API が呼ばれる', async () => { render(html); await userEvent.click(screen.getByText('エントリー')); expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが失敗すると、その結果は AI にフィードバックされ、AI が自己修正します。 仕様を書く → AI が実装とテストを生成する → ブラウザで挙動を確かめる という流れがエディタ内で完結するため、静的な LP から API を使った動的な LP まで、同じ仕組みで品質を担保しながら作ることが可能です。 アプリと Web の差を吸収する Native 連携 キャンペーンページなどの LP は、Web ブラウザだけでなく、メルカリアプリ内の WebView でも開かれます。このとき通常のリンクのままだと、アプリ内では外部ブラウザが開いてしまい、アプリのネイティブ画面へ遷移できません。これを LP ごとに userAgent 判定や Native bridge の呼び出しで書くのは現実的ではありません。そこで、その差をプラットフォーム側で吸収し、LP を作る側は専用の Web Components を使うだけで済むようにしました。 <egp-link href="https://jp.mercari.com/search?keyword=camera"> カメラを探す </egp-link> このリンクをクリックすると環境を自動で判定して、アプリ内なら Native bridge 経由でネイティブ画面へ、Web なら通常のリンクとして開きます。 まとめ さまざまな AI ツールによって UI を作りやすくなっていますが、実運用の LP に乗せるには、API 連携・品質保証・Native 連携といった作り込みが必要になります。EGP Code は、そこをプラットフォーム側に組み込むことで、UI づくりから運用までを同じ編集体験の中でつなげようとしています。 実際に次のような新しい進め方が出てきています。 PM とフロントエンドエンジニアだけで、仕様策定から実装・リリースまでを完遂 バックエンドエンジニアが API から LP まで 1 人で構築 マーケターが静的な LP を 1 人で制作 テストや API 連携を含む動的な LP は、まだ非エンジニアだけで完結させるには難しい部分が残っています。それでも、誰がどこまで担えるかの境界は少しずつ動いていて、いずれはこうした動的な LP も非エンジニアだけで作れるようになると考えています。 次の記事は @sinmetalさんの「MySQLからSpannerに移行した時のQueryチューニング」です。引き続きお楽しみください。
動画
該当するコンテンツが見つかりませんでした













