Vibe Coding で UI/UX をリアルタイムに鍛え直した話 — チャット4ラウンドと見つかった潜在バグ
はじめに
機能要件はすべて満たしている。テストも green。だけど画面を実際に触ってみると、見た目がレガシー以前——縦一列のシンプルなメニューがあるだけで、タブもなければ、EC サイトらしい配色も、カートの中身が一目で分かる導線もない。
これは「AI にコードを書かせるとよく起きること」だと思っています。AI は指示された要件は忠実に実装しますが、「良い UI/UX にしてほしい」と書かれていない限り、それは要件に含まれないからです。機能要件・非機能要件を丁寧に積んでも、見た目の質はまた別の軸にある。
今回は、そのギャップを仕様書を書き直すのではなく、チャットで会話しながらその場で直してもらう——いわゆる Vibe Coding で埋めた記録です。以前 Claude Code と仕様駆動開発(SDD)で同じ EC デモを 1 日で作った話を書きましたが、今回はその対極にある進め方の記録として書いています。
環境は前回と同じ: Claude Code(AI コーディングエージェント)、対象はスーパーマーケット EC デモ(商品カタログ → カート → 注文)。今回のやり取りは事前の spec 化なしで、検証用ブランチ上でチャット → 実装 → 実機確認のサイクルだけを繰り返しました。
Vibe Coding のやり方 — 「都度コメントで記録する」というルールだけ決めた
事前設計はしない代わりに、1 つだけ運用ルールを決めました: やり取り(要望・確認事項・実装判断・発見した問題)を、都度 GitHub issue のコメントとして記録する。仕様書の代わりに、issue のコメント欄が唯一の記録場所になります。
進め方はシンプルです。
- ユーザーが「ここを直して」とチャットで要望を伝える
- 必要なら AI が設計判断を選択肢で確認する(今回は「タブと URL を同期するか」「一覧→詳細のドリルダウンで新規タブを開くか」の 2 点を選択式で確認した)
- AI が実装し、テスト・lint・型チェックを回す
- 一区切りごとに issue へコメントとして記録する
これを 4 ラウンド繰り返しました。
ラウンド1: 縦一列メニュー → サイドバー階層メニュー + タブ UI
最初の要望はシンプルでした。
UIがシンプルすぎます。メニューは左側の縦に階層で並べて、各機能を起動したときは、上部にタブとして開き、複数のタブで表示するように修正して
タブは「別途状態を持つ」のではなく URL から導出する設計にしました(syncFromRoute が route watcher から呼ばれ、タブの生成・アクティブ化を行う)。これで、ブックマーク・ブラウザの戻る/進む・直接 URL 遷移のいずれでもタブ状態が正しく整います。sessionStorage でセッション内は永続化。
実装の過程で、3 つの問題をその場で見つけて直しました。
- 設計バグ: サイドバーの「注文履歴」クリックが、注文詳細画面を見ていた場合に一覧へ遷移せず現在地に留まってしまう。「メニュー項目のクリックは常にその機能の既定パスへ」「タブバーでの再選択だけが記憶位置へ戻る」と役割を分離して解消
- WCAG 2.5.8 違反: タブを閉じる「×」ボタンのタップ領域が 24×24px 未満(axe が検出)
-
ARIA 構造違反:
role="tablist"/role="tab"を、矢印キー操作やタブパネル連携まで実装せずに使ったため構造違反(axe が検出)。本格的な ARIA Tabs パターンの実装コストを避け、<nav>+aria-current="page"のシンプルな構成に変更
lint・型チェック・vitest 99 件・Playwright E2E 15 件(axe 込み)まで green にして 1 ラウンド目終了。
ラウンド2: 「一般的な EC サイトっぽく」を配色で表現する
画面構成は、いい感じになりました。UIのデザインは、一般的なECサイトを参考に修正して、楽天市場を参考にして
ここで大事にしたのは、特定サイトの商標・ロゴ・厳密なブランドカラーを複製しないという一線です。「赤基調の CTA・価格を強調・カード型の商品一覧」という、日本の EC サイトに広く見られる視覚的傾向を取り入れる形で対応しました。
配色トークンを CSS 変数 1 箇所(--color-brand-*)で管理していたおかげで、青系から赤系への切り替えはその 1 箇所の変更でほぼ全画面に反映されました。設計時にトークン化しておいたことが、こういう「デザインをまるごと差し替える」チャットベースの改修と相性が良かったです。
ラウンド3: 右サイドに常時カート概要パネル
右側の縦にカートに入っている内容と合計金額を表示するように修正して
CartSummaryPanel.vue を新規追加し、サイドバー・メインコンテンツに続く第 3 カラムとして常時表示。カートはユーザー状態依存のため <ClientOnly> で包み、静的プリレンダー対象ページ(トップ・商品一覧)には影響しないようにしています。
このラウンドで踏んだ小さな落とし穴: 新パネルに「カートを見る」という文言を足したら、既存 E2E の緩いロケータ(/^カート/ の正規表現一致など)が新パネルの要素にも誤ってヒットするようになりました。ロケータをランドマークやテキスト完全一致で絞り込んで解消。画面に文言を1つ足すだけでも、既存のテストの前提を壊しうるという当たり前の教訓です。
ここまでを 1 つの PR にまとめてコミット。lint・vue-tsc・vitest 102 件・E2E 15 件、すべて green で人のマージ判断を仰ぎました。
ラウンド4: 商品詳細のタブ化、そして「セッションのハングアップ」というアクシデント
4 ラウンド目は、少し特殊な始まり方をしました。実装に着手したセッションが途中でハングアップし、チャット履歴が失われた状態で別セッションを再開することになったのです。
「やり取りは都度 issue にコメントとして記録する」というルールを敷いていたおかげで、セッションが生きている間の記録は残っていました。しかし、ハングアップの直前に指示された要望そのものは、記録されるより前に失われていました。運用ルールには、この「記録する前に途切れる」というケースの想定が抜けていたわけです。
とはいえ、作業ツリーには実装途中の差分がそのまま残っていました。新しいセッションでは、まず git status / git diff で状況を確認し、diff とテストから意図を復元することから再開しました。復元できた内容:
- 商品一覧(
/products)とは別に、商品詳細(/products/:id)を商品ごとに独立した動的タブとして開く(同じ商品を再度開けば既存タブへフォーカス、別商品なら新規タブ) - タブは開いた時点では商品名を知らないため汎用ラベル「商品詳細」で作成し、商品名の取得完了を検知してタブラベルを差し替える
ここまでは診断(テストは green)だけで済みましたが、activeTabId の参照箇所を洗い出す過程で、テストがカバーしていないリグレッションを見つけました。サイドバーの「商品一覧」がハイライトされる条件が完全一致(activeTabId === 'products')のままだったため、商品詳細タブを開いている間はメニューのハイライトが外れてしまっていたのです。修正して回帰テストを追加、これで再開分は完了としました。
実機で動かして初めて見つかったバグ
ここからが、今回いちばん書きたかったところです。ここまでの検証はすべて自動テスト(vitest・vue-tsc・eslint)が green というだけで、実際にブラウザでアプリを起動して操作したことは一度もありませんでした。
「動かしながら直したい」という一言で、ローカルに DB・バックエンド・フロントエンドを起動し、ヘッドレス Chromium で実際に商品を開いたりリロードしたりして確認したところ、ページを F5 でリロードすると、開いていたタブが現在のページ以外すべて消えるという、既存機能(ラウンド1で入れたセッション内タブ復元)のバグが見つかりました。
原因は App.vue の呼び出し順序でした。
const route = useRoute();
const tabsStore = useTabsStore();
watch(
() => route.fullPath,
(fullPath) => tabsStore.syncFromRoute(fullPath),
{ immediate: true }, // ← setup() 中に同期的に発火する
);
onMounted(() => {
tabsStore.hydrate(); // ← ここに来る頃には手遅れ
tabsStore.syncFromRoute(route.fullPath);
});
immediate: true の watcher は onMounted より先に、コンポーネントの setup() 実行中に同期的に発火します。この時点でストアはまだ空なので、syncFromRoute は「現在のページだけの単一タブ」を作り、その状態を sessionStorage に persist() してしまいます。その後 onMounted で本来の複数タブ状態を hydrate() しようとしても、読みに行った sessionStorage はすでに直前の書き込みで上書き済み——というオチです。
const persistedOnLoad = tabsStore.peekPersisted(); // watcher が動く前に読むだけ読んでおく
watch(
() => route.fullPath,
(fullPath) => tabsStore.syncFromRoute(fullPath),
{ immediate: true },
);
onMounted(() => {
tabsStore.hydrate(persistedOnLoad); // 事前に取った方を渡す
tabsStore.syncFromRoute(route.fullPath);
});
peekPersisted()(読むだけで state には反映しない)を追加し、watcher が動く前にスナップショットを取っておくだけで直りました。ストア側の「同期のたびに persist する」という既存の契約には一切手を入れていません。
このバグは、ラウンド1でこの機能を実装した瞬間から存在していました。単体テストは store 単体の hydrate() しか検証しておらず、App.vue 側の呼び出し順序というコンポーネントとストアの結合部分は誰もテストしていなかった——ここは自動テストだけでは絶対に見つからず、実際にブラウザでリロードして初めて発覚しました。
数字で見る 4 ラウンド
| ラウンド | 内容 | 見つかった問題 |
|---|---|---|
| 1 | サイドバー階層メニュー + タブ UI | 遷移設計バグ・WCAG タップ領域・ARIA 構造違反 |
| 2 | EC 風配色(楽天市場を参考) | なし |
| 3 | 右カート概要パネルの追加 | 既存 E2E ロケータの誤ヒット |
| 4 | 商品詳細の動的タブ化 | サイドバーハイライトのリグレッション |
| 4(実機) | リロード時のタブ復元バグ |
immediate watcher と hydrate() の呼び出し順序 |
4 ラウンドを通じて vitest は 99 件 → 110 件まで増え、すべて green を維持したまま進みました。
Vibe Coding をやってみて思ったこと
- チャットしながらの改修は、ほぼリアルタイムで進む。 「ここを直して」→ 数分後には画面に反映されている、というループの速さは、事前に仕様書を書いて設計レビューを挟むプロセスとは別物です。ただしこの速さゆえに、この進め方自体をどう開発手法として確立するか(記録の残し方、レビューの入れ方、いつ SDD に切り替えるべきか)は、まだ手探りだと感じています。今回「都度 issue にコメントで記録する」という最低限のルールだけは決めていましたが、それでもセッションのハングアップという想定外には対応できませんでした。
- 「実際に動かす」工程を挟むと、直した後の不具合や潜在的なバグの発見が早くなる。 今回のリロードバグは、ラウンド1の実装時点からずっと存在していたのに、自動テストのカバレッジの隙間(コンポーネントとストアの結合部分)にちょうど収まっていて誰も気づいていませんでした。チャットで細かく改修するたびに実機で触るサイクルを挟んだことで、「テストは green だが実際には壊れている」状態を早期に検出できました。
- 設計の一部(トークン化・単一情報源の原則)は、Vibe Coding とも相性が良い。 ラウンド2の配色変更が 1 箇所の CSS 変数変更で済んだのも、ラウンド1で「タブは URL から導出する」と決めていたのも、SDD で作り込んだ設計上の判断です。事前設計をゼロにしても、良い設計判断の恩恵はそのまま活きます。
おわりに
仕様駆動開発で骨格を作り、Vibe Coding で仕上げの UI/UX を磨く——今回はその組み合わせになりました。機能要件を満たすことと、触って気持ちの良い画面にすることは別の仕事で、後者は「動かしながら会話する」進め方のほうが向いていると感じています。一方で、この速さに見合うだけの「記録」と「検証」の仕組みをどう用意するかは、まだ改善の余地があります。
同じ環境(Claude Code)は公開されているので、興味があれば試してみてください。


