0 views

Storybookで画面操作説明の画像をAI Agentに作らせる

storybook-ai-agent-screenshot

プロダクトの画面操作説明ドキュメント(マニュアル、要件定義書、オンボーディング資料)は、画面が変わるたびに書き直しを強いられる。文章だけの差分なら軽い。番号や矢印を重ねた説明画像は別で、変更のたびに画像編集ソフトで置き直しになる。

この作業をAI Agentに投げたくなる。ところがAI Agentに「画像」そのものを渡すと、成果物はバイナリになる。行差分にならないし、部分修正の指示も曖昧なままになる。

原因は、AI Agentに「画像」そのものを渡しているところにある。渡す対象をStorybookのstoryファイルとテキストの識別子だけに絞れば、出力はコードdiffになる。運用はレビューできてロールバックできる、コード変更で再生成できる形に置き換わる。

Storybook、storycap、MSW、説明画像用のdecoratorがあれば、画面操作説明のスクショはAI Agentへの1回の依頼で追加できる。

手作業で説明画像を更新する3工程

説明画像の更新作業は、画面再現、番号の置き直し、本文との対応確認に分かれる。

  1. 画面を目的の状態まで再現してからスクショを撮り直す
  2. 画像編集ソフトやFigmaに持ち込んで番号や矢印や枠を置き直す
  3. 番号と説明本文の対応を突き合わせて丸番号の順に本文を直す

工程1ではログインやフィルタやモーダル開閉など「状態を作る作業」から始まる。工程2では前バージョンの画像が再利用できず、対象要素の座標をその都度取り直す。工程3は文中の「(1)を押すと〜」の(1)が画像内のどの丸番号かを目視で対応させる。

この3工程は「画面ごと × 変更ごと」に発生する。1つの画面が5枚の説明画像を持てば5回、リリースが10回あれば50回だ。実運用ではこの工数の重さから、「今回は説明画像の更新は見送る」判断が入りやすい。画像は実装より1世代や2世代古いまま残ることが多く、「更新されていない」というより「古い画面が正しいものとして載っている」状態になりがちだ。読み手が「これはいつの画面か」を疑い始めるのは、たいていこの状態だ。

狙いは、この3工程をAI Agentへの依頼文とstoryファイルのdiffに移すことだ。まずAI Agentが触るインタフェースをStorybookのどこに置くかから始める。

storyをAI Agentと画像出力の共通インタフェースにする

画像編集APIを叩かせると、出力はバイナリになり、行差分にならない。座標のリストを吐かせると、レイアウトが8px動くだけで全番号が破綻する。Figmaのノードを組ませると、コードとの対応を人間が保たされて二重管理になる。AI Agentが画像そのものか画像の断片を触っているうちは、出力はコードdiffにならない。

AI Agentにはspec storyファイル、識別子集約module、MSW handlerだけを触らせる。

  • 画面ごとに1本のspec storyファイル(title / parameters / decoratorsを宣言する)
  • 番号が指す対象のDOMを拾うための識別子集約module(テキストのidをstringで並べる)
  • APIを叩く画面向けにMSW handlerで通信を止めてstoryに添える

例えば、次のように並ぶ。

src/
├── Counter.tsx                     # 画面本体(識別子を付ける対象)
├── anchors.ts                      # 識別子集約 module
└── spec-stories/
    ├── withSpecCallouts.tsx        # decorator(全画面共通)
    └── counter.spec.stories.tsx    # 画面ごとに1本(parameters に番号と handler を宣言)

spec storyファイルは、story名の末尾に決まった拡張子を付けた1本ずつのTypeScriptモジュールだ。storyのparametersに番号を付ける対象を配列で書く。識別子集約moduleは、DOMを拾うためのkey集。テキストのidをstringで並べただけの純粋なmoduleを、storyから参照する。API mockingが必要な画面では、parameters.msw.handlersにライブラリMSWのhandlerを渡す。3種類とも純粋なTypeScriptの差分としてレビューできる。

story 1本は次のように書ける。

// src/spec-stories/counter.spec.stories.tsx (抜粋)
import type { Meta, StoryObj } from "@storybook/react-vite";
import { delay, http, HttpResponse } from "msw";
import { Counter } from "../Counter";
import { anchors } from "../anchors";
import { SpecCallout, withSpecCallouts } from "./withSpecCallouts"; // decorator の実体は後述

// バッジ番号ごとに、どの要素を指すかだけ宣言する。座標は書かない
const callouts: SpecCallout[] = [
  { n: 1, anchor: { by: "testId", value: anchors.counter.increment } }, // Increment ボタン
  { n: 2, anchor: { by: "role", role: "button", name: "Decrement" } },   // Decrement ボタン (role で拾う)
  { n: 3, anchor: { by: "testId", value: anchors.counter.users } },      // Active users セクション
];

const meta: Meta<typeof Counter> = {
  title: "spec/counter", // 画面IDを名前空間として使う。撮影対象の絞り込みキーになる
  component: Counter,
  parameters: {
    // interval 再計測が落ち着いてから storycap がシャッターを切る
    screenshot: { delay: 1800 },
    specCallouts: callouts,
    // MSW handler で画面が叩く API を止め、表示状態を story ごとに固定する
    msw: {
      handlers: [
        http.get("/api/users", async () => {
          await delay(500); // 非同期ロードの実運用を模す
          return HttpResponse.json([
            { id: 1, name: "Ada Lovelace" },
            { id: 2, name: "Alan Turing" },
          ]);
        }),
      ],
    },
  },
  // decorator が specCallouts を読んで、描画後に番号バッジを重ねる
  decorators: [withSpecCallouts],
};

export default meta;
type Story = StoryObj<typeof Counter>;

// この story の状態を Default という名前で公開する
export const Default: Story = {};

titleは画面IDを示し、parameters.specCalloutsは番号と対象識別子を並べる。parameters.msw.handlersは表示状態を宣言し、decoratorsは実測とバッジ描画を持ち込む。AI Agentが触るのはこのファイル1本と、識別子moduleに追記する1〜数行だけになる。

生成した画像はstorycapがpipelineで自動出力する。画像そのものは「pipelineの出力」であって、人間が眺めて確認する「レビュー対象」ではない。人間はstoryのdiffだけを見る。撮り直しはstoryのコード変更をトリガーに自動発火する。

Storybookを挟むもう1つの利点は、同じstoryを複数の入口として兼用できることだ。ドキュメント表示用のスクショだけでなく、開発中のプレビュー、E2Eテストの起点、VRTのbaselineにも同じstoryを使える。ここで扱うのはスクショだけだが、storyが画面のスナップショットを取るsingle source of truthになる。

安定したDOM識別子で位置を実測するアノテーションdecorator

storyのparametersに番号と対象を宣言したら、番号バッジをどこに描画するかを決める必要がある。この位置決めを「座標」で持たせると即座に壊れる。ボタン1つが8px動いただけで全番号を打ち直しになる。かわりに対象要素のDOM識別子だけを持ち、描画後にgetBoundingClientRect()で実測する。その結果へ番号バッジを重ねる。番号バッジはcreatePortaldocument.bodyの直下に置き、画面本体のレイアウトを触らない。

識別子と言っても、何を選ぶかで結果が変わる。識別子は、再レイアウトで消えず、名前から意味を辿れるものに限る。

  • 再レイアウトやスタイル変更で消えない属性であること
  • 名前から拾えて意味を持つ文字列であること

CSSクラス名や.container > div:nth-child(3)のような構造依存パスは、スタイル調整のたびに切れるので採用しない。名前は、AI Agentが「Incrementボタンに丸を付けて」と言われたときに、既存moduleから辿れる意味を含んでいる必要がある。

この2要件を満たす選択肢は複数あり、混在させて構わない。

  • data-testid属性(Testing Libraryの慣習でフレームワーク中立)
  • data-calloutdata-anchorのような用途専用のdata-*属性
  • ARIA roleとaccessible name(Testing LibraryのgetByRoleと同じ考え方)
  • 意味のあるid

data-testidはテスト資産を持つプロジェクトならすでに付いていることが多い。専用のdata-*はテスト用のdata-testidと分けたいときに使う。ARIA rolebuttonlinkheadingのようなnative elementの暗黙roleが使える。既存DOMに追加属性を足さずに済む場面がある。idはフォーム要素で<label htmlFor>のためにすでに付いていることが多い。

識別子でDOM要素を拾い、実測して、番号バッジをdocument.body直下にportalする。

識別子から実測して番号バッジをportalで重ねる

decoratorの中核を示す。要素の取得を「識別子の種類」で分岐させ、getBoundingClientRect()の結果に対して番号バッジを1つずつportalする。

// src/spec-stories/withSpecCallouts.tsx (抜粋)
import type { Decorator } from "@storybook/react-vite";
import { ReactElement, useEffect, useState } from "react";
import { createPortal } from "react-dom";

// 対象要素の指し方は 2 種類。どちらも「壊れにくい識別子」の要件を満たす
export type CalloutAnchor =
  | { by: "testId"; value: string } // data-testid 属性の値で拾う
  | { by: "role"; role: string; name: string }; // ARIA role と accessible name で拾う

// 1 個のコールアウトは「番号 + どの要素を指すか」の組。offsetX/offsetY は微調整用
export interface SpecCallout {
  n: number;
  anchor: CalloutAnchor;
  offsetX?: number;
  offsetY?: number;
}

// role 名から native element の暗黙 role を含めて拾える CSS selector を作る
const IMPLICIT_ROLE_SELECTORS: Record<string, string> = {
  button: "button, [role='button']",
  link: "a[href], [role='link']",
  heading: "h1, h2, h3, h4, h5, h6, [role='heading']",
  textbox: "input:not([type='button']):not([type='submit']), textarea, [role='textbox']",
};

// 識別子から対象 DOM 要素を 1 つ探す
function findElement(anchor: CalloutAnchor): Element | null {
  if (anchor.by === "testId") {
    // testId は data-testid 属性の値で一意に取れる
    return document.querySelector(`[data-testid="${anchor.value}"]`);
  }
  // role の場合は候補を全部拾い、accessible name が一致するものに絞る
  const selector =
    IMPLICIT_ROLE_SELECTORS[anchor.role] ?? `[role="${anchor.role}"]`;
  const candidates = Array.from(document.querySelectorAll(selector));
  for (const el of candidates) {
    const name = (
      el.getAttribute("aria-label") ??
      el.textContent ??
      ""
    ).trim();
    if (name === anchor.name) return el;
  }
  return null;
}

findElementが返した要素でgetBoundingClientRect()topleftを読む。番号バッジをtop - 13, left - 13の位置に置く。バッジは26×26pxで、対象の左上に半分被って重なる。座標をコードに直書きしない。バッジ径から決まる-13と、SpecCallout.offsetX/offsetYの微調整だけが固定値だ。

decoratorの外皮はこう書く。Storybookはstory描画時にこの関数を呼ぶ。

// src/spec-stories/withSpecCallouts.tsx (抜粋)
import type { Decorator } from "@storybook/react-vite";

// Storybook が各 story を描くたびに Story と context を渡してくる
export const withSpecCallouts: Decorator = (Story, context): ReactElement => {
  // story parameters.specCallouts に宣言された配列を取り出す
  const raw: unknown = context.parameters.specCallouts;
  const callouts: SpecCallout[] = Array.isArray(raw)
    ? (raw as SpecCallout[])
    : [];

  return (
    <>
      {/* まず本来の画面を描く */}
      <Story />
      {/* その上に番号バッジをかぶせる。CalloutOverlay の中身は後述 (H2-5) */}
      {callouts.length > 0 && <CalloutOverlay callouts={callouts} />}
    </>
  );
};

story側に残る作業は、番号と識別子の宣言だけだ。

// src/spec-stories/counter.spec.stories.tsx (抜粋)
import { anchors } from "../anchors";
import { SpecCallout, withSpecCallouts } from "./withSpecCallouts";

// バッジ番号ごとに、どの要素を指すかだけ宣言する。座標は書かない
const callouts: SpecCallout[] = [
  { n: 1, anchor: { by: "testId", value: anchors.counter.increment } }, // Increment ボタン
  { n: 2, anchor: { by: "role", role: "button", name: "Decrement" } },   // Decrement ボタン (role で拾う)
  { n: 3, anchor: { by: "testId", value: anchors.counter.users } },      // Active users セクション
];

number 1data-testidで拾い、number 2role + accessible nameで拾う。2種類の識別子が同じstoryの中で同居できる。testIdがある画面も、ARIA名だけの画面も、同じstoryで扱える。

画面IDをStorybookのtitle名前空間にする

撮影対象はStorybookのtitle名前空間で切る。Storybookにはユニットテスト用のstoryも、UI componentの見本用のstoryも、ドキュメント用のspec storyも同居する。全部撮ると時間がかかる。そもそも撮る必要がない。

Storybookのtitleはスラッシュ区切りの階層で、名前空間として使える。ドキュメント用のスクショだけを撮りたいなら、対象storyのtitleをspec/<画面ID>で始めるルールに揃える。あとはstorycap側から--include 'spec/**' globで絞ればよい。

// counter.spec.stories.tsx の meta
const meta: Meta<typeof Counter> = {
  title: "spec/counter", // ← 撮影対象を絞り込むためのキー
  component: Counter,
  parameters: { specCallouts: callouts, msw: { handlers } },
  decorators: [withSpecCallouts],
};
# 撮影対象を spec/** のみに絞る
storycap --serverCmd "npx http-server storybook-static -p 6006 --silent" \
  http://127.0.0.1:6006 \
  --include 'spec/**' \
  --viewport '1280x720' \
  --serverTimeout 90000 \
  --captureTimeout 30000 \
  -o artifacts/screenshots
# --include: title 名前空間で絞る。他の story は撮らない
# --viewport: 撮影時の window サイズを固定する
# --serverTimeout / --captureTimeout: build 済み Storybook が重いときの猶予
# -o: PNG の出力先。title に対応するパスへ書き出される

こう決めておくと、storycapの出力パスは画面IDに対応する。spec/counter titleでDefault storyを書けば、PNGはartifacts/screenshots/spec/counter/Default.pngに固定される。counterという1語は、次の5箇所で同じキーとして現れる。

  • spec storyのファイル名として登場する
  • Storybook上のtitleに書かれる
  • 生成されるPNGパスに反映される
  • ドキュメント側の画像参照パスに現れる
  • 識別子集約moduleの第1階層にも同じ名前が入る

AI Agentは画面IDを起点に、story名、PNGパス、識別子moduleを検索できる。人間もどこか1箇所で名前が変わっても、検索や置換で追える。

「命名規約を決めるだけ」の話に見える。しかしこの1本が通っていないと、後でAI Agentがどこかで画面IDを推論できず「別名」を作ってしまう。1種類のstringを貫通させると最初に決めておくと、後から別名を潰す作業が減る。

非同期ロードとviewport変更に追従する測定タイミング

mount時の1回計測では、非同期ロードとviewport変更で座標がずれる。

APIから遅れてくるリストが描画されるまで、mount直後のgetBoundingClientRect()は空の位置を返す。番号バッジは初期位置のまま居残る。

storycapは撮影の直前にwindow幅を--viewport指定に変える。mount時に撮った座標は、変更後の実測位置と一致しない。

非同期ロードとviewport変更の両方に対応するため、再計測、resize、delayを同時に入れる。

  • intervalで250 ms間隔の再計測を最大3秒続ける(非同期ロードでDOMが確定するまで追う)
  • resizeイベントで再計測する(storycapのviewport変更にも直後に追従させる)
  • screenshot.delayで撮影を遅らせる(他の再計測が落ち着いてからshutterを切る)

タイムラインで見ると次のような順序で動く。

sequenceDiagram participant Story as story participant Deco as decorator participant SC as storycap Story->>Deco: mount Deco->>Deco: 初回 measure loop 250ms 間隔 for 3秒 Deco->>Deco: 再計測 end Note over Deco: MSW 500ms delay の response 到達 Deco->>Deco: 再計測 DOM 確定後 SC->>Story: window.resize to --viewport Note over Deco: resize event 発火 Deco->>Deco: 再計測 Note over SC: screenshot.delay 1800ms 待機 SC->>Story: capture PNG

遅延は「intervalが止まる3秒よりは短く」「非同期ロードの想定時間より長く」の間に置く。decorator側の実装は次のとおりだ。

// src/spec-stories/withSpecCallouts.tsx (抜粋)
function CalloutOverlay({ callouts }: { callouts: SpecCallout[] }) {
  // rects: 番号ごとの実測 (top, left) を保持する
  const [rects, setRects] = useState<CalloutRect[]>([]);

  useEffect(() => {
    // 対象要素を全部拾い、getBoundingClientRect() の結果を rects に書く
    const update = () => setRects(measure(callouts));
    update(); // 初回計測

    // 非同期ロードで DOM が確定するまで、250ms 間隔で再計測を繰り返す
    const interval = window.setInterval(update, 250);
    // 最大 3 秒経ったら interval を止める
    const stop = window.setTimeout(
      () => window.clearInterval(interval),
      3000,
    );
    // storycap が撮影直前に viewport を変えても追従できるように resize を拾う
    window.addEventListener("resize", update);

    // 片付け
    return () => {
      window.clearInterval(interval);
      window.clearTimeout(stop);
      window.removeEventListener("resize", update);
    };
  }, [callouts]);

  // rects から番号バッジを作り、document.body 直下に portal する。詳細は後述
  return createPortal(/* バッジ描画 */, document.body);
}

story側はdelayを宣言するだけで済む。

// counter.spec.stories.tsx (抜粋)
parameters: {
  // 上の interval 再計測が落ち着いてから storycap がシャッターを切る
  screenshot: { delay: 1800 },
  specCallouts: callouts, // 前節の callouts 配列
  msw: {
    handlers: [
      http.get("/api/users", async () => {
        await delay(500); // 実運用の非同期を模して 500ms 待たせる
        return HttpResponse.json([...]);
      }),
    ],
  },
},

この3点セットが揃えば、画面がLoading users...の状態でも、リストが100件出た状態でも、番号バッジは再計測時点のrectに合わせて再配置される。撮り直しのたびに数字を書き直す作業は要らない。

AI Agentに何をどう命令するか

AI Agentが触るインタフェースは、TypeScriptのstoryファイル1本と識別子集約moduleに絞れた。ここまでの規約が乗っていれば、依頼文は雑に書いても伝わる。

一覧画面をローディング中で撮って。
検索バーと結果テーブルに番号を付ける。

AI Agent側は次の暗黙知だけ知っていれば、このプロンプトからstoryファイルを作れる。

  • storyは画面IDと同名でsrc/spec-stories/の下に1本ずつ作る
  • titlespec/<画面ID>の名前空間で書く
  • 番号と対象をparameters.specCalloutsに配列で並べる / anchordata-testidrole+accessible nameから選ぶ
  • 状態はparameters.msw.handlersにMSW handlerで書く / 返らない状態はnew Promiseを返させる
  • 識別子が足りなければsrc/anchors.tsに追記する

この暗黙知はAI Agent向けのルールとして、リポジトリのAGENTS.mdやCLAUDE.md、Cursor ruleのような場所に固定しておく。プロンプト側に詳細を書かなくても、AI Agentは規約からstoryの形を再構成できる。

規約が整うまでは詳細を明示する必要が残る。最初の何回かは、anchor.byの選び方やtitle命名、触ってよいファイルの範囲を依頼文に添える。運用が回り始めたら少しずつ規約側へ寄せていく。

出力はstoryファイル1個のdiffだ。人間はそのdiffだけをレビューする。スクショはpipelineが出すので、撮り直しはstoryのコード変更が引き金になる。storyの差分とPNG生成結果は常に同じpipelineで対応する。

カウンタ画面で作ってみる

最小構成のファイルを順に並べる。画面はIncrementとDecrementのボタン、カウンタ表示、非同期でロードするユーザ一覧を持つCounterコンポーネントだ。依存はReactとStorybook、storycap、MSWだけで、UIライブラリやrouterは使わない。

まずpackage.json。dependencies、devDependencies、scriptsを載せる。

{
  "type": "module",
  "dependencies": {
    "react": "18.3.1",
    "react-dom": "18.3.1"
  },
  "devDependencies": {
    "@storybook/react": "8.6.14",
    "@storybook/react-vite": "8.6.14",
    "@types/react": "18.3.12",
    "@types/react-dom": "18.3.1",
    "@vitejs/plugin-react": "4.3.3",
    "http-server": "14.1.1",
    "msw": "2.6.6",
    "msw-storybook-addon": "2.0.4",
    "storybook": "8.6.14",
    "storycap": "5.0.1",
    "typescript": "5.6.3",
    "vite": "5.4.11"
  },
  "scripts": {
    "storybook": "storybook dev -p 6006 --no-open",
    "build-storybook": "storybook build --output-dir storybook-static",
    "screenshot": "storycap --serverCmd \"npx http-server storybook-static -p 6006 --silent\" http://127.0.0.1:6006 --include 'spec/**' --viewport '1280x720' --serverTimeout 90000 --captureTimeout 30000 -o artifacts/screenshots",
    "msw-init": "msw init public --no-save"
  },
  "msw": { "workerDirectory": ["public"] }
}

Storybookの設定は2ファイル。

// .storybook/main.ts
import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  stories: ["../src/**/*.stories.@(ts|tsx)"],
  framework: { name: "@storybook/react-vite", options: {} },
  // MSW の service worker (public/mockServiceWorker.js) を配信する
  staticDirs: ["../public"],
  addons: [],
};
export default config;
// .storybook/preview.tsx
import type { Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";

// MSW を browser 環境で立ち上げる。未定義エンドポイントは実 fetch にそのまま通す
initialize({ onUnhandledRequest: "bypass" });

const preview: Preview = {
  parameters: {},
  // 各 story の parameters.msw.handlers を読み取って mock を差し込む
  loaders: [mswLoader],
};
export default preview;

decoratorは前節のコード片をそのままsrc/spec-stories/withSpecCallouts.tsxに置く。findElementwithSpecCalloutsCalloutOverlayの3パーツで構成する。

続けて識別子集約module。storyはここのキーを参照する。

// src/anchors.ts
// 画面ごとに、番号が指しうる要素の識別子を集約する
export const anchors = {
  counter: {
    decrement: "counter-decrement", // data-testid の値と 1:1 対応
    count: "counter-count",
    increment: "counter-increment",
    users: "counter-users",
  },
} as const;

画面コンポーネントには対応するdata-testidを付ける。useEffect/api/usersを叩き、応答が来るまではLoading users...を表示する構造にしてある。

// src/Counter.tsx (抜粋)
import { useEffect, useState } from "react";
import { anchors } from "./anchors";

type User = { id: number; name: string };

export function Counter(): JSX.Element {
  const [count, setCount] = useState(0);
  // users は null が「未取得」を表す。story 側では MSW handler が response を返すまで null のままになる
  const [users, setUsers] = useState<User[] | null>(null);

  // マウント時に /api/users を叩く。story 側で MSW が intercept する
  useEffect(() => {
    fetch("/api/users")
      .then((r) => r.json() as Promise<User[]>)
      .then(setUsers);
  }, []);

  return (
    <main>
      <h1>Counter</h1>
      {/* data-testid の値は anchors.ts から引く。story はここを識別子として拾う */}
      <button data-testid={anchors.counter.decrement}>Decrement</button>
      <span data-testid={anchors.counter.count}>{count}</span>
      <button data-testid={anchors.counter.increment}>Increment</button>
      <section data-testid={anchors.counter.users}>
        {/* users が null の間は Loading 表示。MSW handler の delay で観測できる */}
        {users === null ? <p>Loading users...</p> : <ul>{/* ... */}</ul>}
      </section>
    </main>
  );
}

spec storyはこう書く。番号ごとに識別子の種類が違って構わない。

// src/spec-stories/counter.spec.stories.tsx
import type { Meta, StoryObj } from "@storybook/react-vite";
import { delay, http, HttpResponse } from "msw";
import { Counter } from "../Counter";
import { anchors } from "../anchors";
import { SpecCallout, withSpecCallouts } from "./withSpecCallouts";

const callouts: SpecCallout[] = [
  { n: 1, anchor: { by: "testId", value: anchors.counter.increment } },
  { n: 2, anchor: { by: "role", role: "button", name: "Decrement" } },
  { n: 3, anchor: { by: "testId", value: anchors.counter.users } },
];

const meta: Meta<typeof Counter> = {
  title: "spec/counter",
  component: Counter,
  parameters: {
    screenshot: { delay: 1800 },
    specCallouts: callouts,
    msw: {
      handlers: [
        http.get("/api/users", async () => {
          await delay(500);
          return HttpResponse.json([
            { id: 1, name: "Ada Lovelace" },
            { id: 2, name: "Alan Turing" },
          ]);
        }),
      ],
    },
  },
  decorators: [withSpecCallouts],
};

export default meta;
type Story = StoryObj<typeof Counter>;
export const Default: Story = {};

これで揃った。pnpm msw-initpublic/mockServiceWorker.jsを生成し、pnpm storybookで画面が開く。撮るときはpnpm build-storybook && pnpm screenshotを回す。artifacts/screenshots/spec/counter/Default.pngに次の画像が書き出される。Incrementボタンの上に1、Decrementボタンの上に2、Active usersセクションの上に3の丸番号が重なる。

storycapが書き出したCounter画面の説明画像 (①Incrementボタン、②Decrementボタン、③Active usersセクション)

実際に使ったときの使用感

仕組みが乗ったあと、新しい画面のスクショを1枚増やすと、作業後に残るのは依頼文、story diff、自動生成PNGの3つに分かれる。

  • 前節のテンプレートを埋めたAI Agentへの依頼文1通(使い捨て)
  • 新規追加や既存分岐を含むstoryファイル1個のdiff(人間のレビュー対象)
  • pipelineが自動生成したスクショPNG 1枚 / pipelineの出力なのでレビュー対象外

人間がレビューするのは真ん中のdiffだけで、依頼文は使い捨て、PNGはpipeline出力として扱う。画像を目で確認する必要が本当に出るのはUI完成度のチェックだけだ。文字が枠からはみ出ていないか、色が意図と違わないか、それも普段の開発中にStorybookのpreview画面で見ているものと同じになる。

手作業運用で回していた3工程は、次のように置き換わる。

  • スクショ撮り直し → storycapがpipelineで自動出力する
  • 番号や矢印や枠の置き直し → decoratorが識別子から実測して置き直す
  • 番号と説明本文の対応維持 → 画面IDと識別子を同じキーに寄せる

画像スクショに番号を振る作業は正直やりたくない。実装より軽く見えるうえに、更新のたびに手を止められる。放置したくなる仕事だ。この仕組みなら、AI Agentに1回依頼すれば済む。人間はstory diffだけ見て、スクショはpipelineが出す。それでもドキュメントは古びない状態を保てる。

まとめ

画面操作説明のスクショが手作業で腐りやすいのは、AI Agentや人間に「画像」を触らせているからだ。storyファイルと安定したDOM識別子、decoratorによる実測を間に挟めば、レビュー対象はPNGではなく、storyと識別子のdiffになる。座標を持たず、識別子と画面IDだけで再生成できる形にできれば、story追加はコードレビューに乗せられる。

扱わなかった領域も並べておく。

  • Visual Regression Testingはスクショ生成の上に載る別レイヤー / 同じstorycap出力を再利用できる
  • 最終ドキュメントを組み立てるdoc pipeline / 画面IDとmanifestの橋渡しで別記事の分量が要る
  • 多言語対応/i18nではstoryをlocale分だけ増やすかStorybookのglobalsで切り替えるかを選ぶ

Storybookとstorycap、MSW、getBoundingClientRectの組み合わせで完結する。フレームワークやUIライブラリには依存しない。スクショ更新が遅れがちなチームなら、この最小構成から始められる。