前編では、AI生成UIが「違和感」を感じさせる根本原因が 判断の不在 にあることを明らかにしました。本編では、その判断をいかに実装レベルで組織的に埋め込むか、より深く、より実践的に掘り下げます。
個人開発の現場から見えた、ドキュメントに書かれていない勘所
ここから本題に入る前に、AI 生成 UI を実プロダクトに組み込もうとして痛い目を見たポイントから共有させてください。2014年から個人運用してきた壁紙系・癒し系アプリ群(累計5,000万DL)で、Antigravity を含む AI コードジェネレータを UI 生成に試した際、机上では気づけなかった具体的な落とし穴がいくつもありました。
1. 「いい感じの UI」と AdMob 収益は両立しない場合がある
Antigravity に「シンプルなホーム画面を作って」と頼むと、確かに見栄えの良い UI を返してきます。ところが、私の壁紙アプリで実際に AB テストしたところ、AI 任せのテンプレ的なレイアウトに差し替えた版は、AdMob 上部のバナー視認性が下がり eCPM が約23%落ちました。「広告と本体 UI の境界をどう演出するか」は AI には判断材料がない領域で、ここはトークンと型で人間の判断を埋め込まないと、収益が直接削られていきます。
実体験から推奨できる調整は次の通りです。
- バナーと本体 UI の間に最低でも
spacing.md(16px)の余白トークンを必ず噛ませる
- セーフエリアとバナー上端の距離は
spacing.lg(24px)以上を確保する。AI 出力は 8px 程度に詰めてくることが多く、ここを必ず検査する
- スプラッシュ直後の最初の画面でバナーを出すと App Store のレビューで2回リジェクトを食らった経験があり、初回起動は無広告で開始するパターンに固めている
2. WCAG 2.5.5 の 44px 規定は「壁紙アプリですら」効く
最初は「壁紙を眺めるアプリなら、ボタンサイズは雰囲気優先でいいだろう」と考えていました。ところが「ダウンロード」ボタンを 36px から 44px に上げただけで、リテンション D1 が 41.2% → 47.8% に上昇したケースが2本ありました。指先の安定しないユーザー(私の母を含む高齢層)が誤タップでホーム画面に戻ってしまう離脱が、想像以上に多かったのです。
つまり Button コンポーネントの size: 'md' を 44px に固定するのは、アクセシビリティ仕様の遵守というよりも、ファネル上の実利が見える経済的判断 です。AI 生成 UI でここを縮められると、リテンションが目に見えて削られます。
3. oklch 移行で実機展開時に気づいた地雷
oklch は理論的には完璧ですが、実機で 2024 年以前の Android(Capacitor の WebView 中心アプリ)に展開した際、CSS の color: oklch(...) が無効になる端末が一定数残っていました。Crashlytics のクラッシュではなく「色が表示されない」サイレント故障で、レビュー☆1が立て続けに着弾します。
私の現場では @supports (color: oklch(0 0 0)) でフォールバック分岐させ、未対応端末向けには RGB ハードコード値を併走させています。新規プロジェクトでも oklch を採用するなら、ビルド時にデュアル値を吐き出す Plugin を Tailwind に噛ませるのが安全策です。
4. Antigravity に「判断の理由」を出させるとレビューが3倍楽になる
Antigravity のメインエージェントに「ボタンの padding をなぜそうしたか」を必ずコメントで残させると、生成コードのレビュー時間が 1ファイルあたり 8〜10分 → 約3分に短縮されました。タイポと型エラーだけ自分が直して、設計判断の採否はコメントを読むだけで決められるからです。
実体験から推奨するプロンプト末尾の定型は次の通りです。
出力する全ての className 連結・条件分岐に、隣接行コメントで「なぜこの値か」を1行で添えてください。
ARIA 属性は state の値に基づいて条件付きで付与し、その判断根拠も同様に記録してください。
このコメント要求が無いと、AI は判断を残さず「動くコード」だけ出してきます。後から「これ何でこうしたんだろう」と探す時間が積もるのが、一番のコスト超過要因でした。
5. デザイントークンを更新するときに私が回している8ステップ
トークン変更は影響範囲が広く、安易に行うと運用中アプリで色化けが起きます。実体験から運用している手順は次の8段階です。
- Figma の Tokens プラグインで変更を提案 → デザイナーレビュー
tokens.json を書き出し、Pull Request の差分でレビュー
tailwind.config.ts を更新(自動生成スクリプトを通す)
- Storybook の全ストーリーを Chromatic でスナップショット比較
- iOS シミュレーター(iPhone SE / iPhone 15 Pro Max)と Android エミュレーター(Pixel 3a)で実機目視
- WebView 経由の旧端末で
@supports フォールバックが効くか確認
- AdMob テスト広告と並べて視認性を確認
- AB 配信は最低でも 5,000 セッション集めてからフル展開
このチェックリストから2工程を飛ばしてリリースしたとき、当日に「ボタンが見えない」というレビューが17件着弾しました。判断を埋め込んだトークンほど、運用ルールも同等に丁寧に詰める必要があります。
デザイントークンの設計哲学
デザインシステムの骨格は、トークンにあります。トークンとは単なる「色や余白の数値」ではなく、ブランド哲学とUIの判断を記号化したもの です。
oklch 色空間での戦略的色設計
多くのプロジェクトは RGB や HSL で色定義しますが、人間の視覚に最も近い色空間は oklch(Oklab色空間の円柱座標表現) です。
// 旧い実装: RGB → Web安全色
export const colors = {
primary: '#4f46e5', // インディゴ
secondary: '#ec4899', // ピンク
error: '#dc2626', // 赤
};
// 新しい実装: oklch → 知覚均等な色定義
export const colors = {
// oklch(lightness saturation hue)
// lightness: 0~1 (明度)
// saturation: 0~0.4 (彩度)
// hue: 0~360 (色相)
primary: 'oklch(0.55 0.2 260)', // ブランドプライマリ
secondary: 'oklch(0.65 0.15 260)', // セカンダリ(より明るく、より淡い)
tertiary: 'oklch(0.75 0.1 260)', // テーシャリ(淡い)
success: 'oklch(0.58 0.15 142)', // グリーン
warning: 'oklch(0.68 0.15 65)', // イエロー
error: 'oklch(0.55 0.2 25)', // レッド
// グレースケール: 中立的で、背景になじむ
surface: 'oklch(0.98 0.002 0)', // ほぼ白(わずかに温い)
'surface-alt': 'oklch(0.94 0.002 0)',
'text-primary': 'oklch(0.15 0.01 0)', // ほぼ黒
'text-secondary': 'oklch(0.40 0.01 0)', // グレー
};
oklch の利点:
- 知覚均等性:
lightness: 0.5 と lightness: 0.6 の差は、画面のどこに置いても同じ「明るさの差」に見える。RGB では大ウソ。
- スケーラビリティ: lightness を増加させると、彩度を自動的に落とした色を簡単に生成できる(明るい色は淡くなる)
- ブランド精度: oklch は印刷業界の CIELAB に基づいているため、デザイナーの意図をより正確に実装できる
余白スケールの根拠を持った設計
余白スケール(spacing scale)は、単なる「好きな数字」ではなく、人間の知覚と認知プロセスに基づいている べきです。
// フィボナッチ比率ベースの余白スケール
// 1, 1, 2, 3, 5, 8, 13, 21, 34 ...
// これは自然界の黄金比に基づき、人間の目に心地よい間隔
export const spacing = {
'xs': '0.25rem', // 4px - テキスト内余白、密集アイコン間
'sm': '0.5rem', // 8px - 関連要素の間隔(ボタン+ラベル)
'md': '1rem', // 16px - 標準間隔(カード内のセクション間)
'lg': '1.5rem', // 24px - セクション間隔(記事の見出し下)
'xl': '2.5rem', // 40px - メジャーセクション間隔
'xxl': '4rem', // 64px - ページレベルの余白
// 高さ用途別の補助スケール
'touch-target': '2.75rem', // 44px(最小タッチターゲット)
'control-height': '2.5rem', // 40px(フォーム制御要素)
};
// 組み込みの「理由」
const DESIGN_RATIONALE = {
xs: '視覚的に密集した要素間(例: アイコン + テキスト)',
sm: '関連性の強い要素間(例: ボタン + ラベル)',
md: 'コンポーネント内のセクション境界',
lg: '視覚的に分離すべきセクション間',
xl: 'ページの主要な領域分割',
xxl: 'ビューポートとコンテンツの関係',
};
タイポグラフィスケール: 読みやすさの層状化
フォントサイズは、単独で定義するべきではなく、必ず line-height とセットで定義する必要があります。
export const typography = {
// Tuple: [font-size, { line-height, letter-spacing (optional) }]
'xs': ['0.75rem', { lineHeight: '1.5', letterSpacing: '0.01em' }],
// 12px / 18px line-height = 1.5 = コンパクト(キャプション用)
'sm': ['0.875rem', { lineHeight: '1.5' }],
// 14px / 21px = 1.5 = やや稠密
'base': ['1rem', { lineHeight: '1.6' }],
// 16px / 25.6px ≈ 1.6 = 本文用。広めの行間で読みやすい
'lg': ['1.125rem', { lineHeight: '1.6' }],
// 18px / 28.8px = 1.6 = 本文より大きい
'h3': ['1.375rem', { lineHeight: '1.5', letterSpacing: '-0.01em' }],
// 22px / 33px = 1.5 = 見出しは行間を詰める(視覚的重量)
'h2': ['1.75rem', { lineHeight: '1.3', letterSpacing: '-0.01em' }],
// 28px / 36.4px = 1.3 = さらに詰める
'h1': ['2.25rem', { lineHeight: '1.2', letterSpacing: '-0.02em' }],
// 36px / 43.2px = 1.2 = 最も詰めて、視覚的インパクトを高める
// 「なぜ」を自分たちのコメントで記録
// 見出しは行間を詰める理由: 大きいテキストは自然な行間が広すぎて、
// 文字間の連関性が損なわれる。詰めることで、見出しとしての
// まとまりと視覚的な重みを表現する。
};
TypeScriptの型システムによるUI意図の表現
ここからが、本の中核です。TypeScriptの型は、単なる「値の検証ツール」ではなく、UI設計の判断を言語化する最高の手段 です。
ボタンコンポーネント: variant × size × state の組み合わせ設計
// まず、型で「許可される組み合わせ」を定義する
type ButtonVariant =
| 'primary' // メインアクション(最も視覚的に目立つ)
| 'secondary' // 補助的なアクション
| 'destructive' // 削除・キャンセルなどの危険操作
| 'ghost' // 目立たない補助アクション
| 'link'; // テキストリンク的なボタン
type ButtonSize =
| 'sm' // 34px - 密集レイアウト用(ただし実際は min-h-[34px] で調整)
| 'md' // 44px - タッチターゲット標準(モバイル・タブレット対応)
| 'lg'; // 54px - CTA用(最も目立たせる場合)
type ButtonState =
| 'default' // 通常状態
| 'loading' // 処理中(cursor-wait, disabled)
| 'disabled' // 操作不可
| 'success' // 成功状態
| 'error'; // エラー状態
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
state?: ButtonState;
children: React.ReactNode;
onClick?: () => void | Promise<void>;
ariaLabel?: string; // アクセシビリティ
}
export function Button({
variant = 'primary',
size = 'md',
state = 'default',
children,
onClick,
ariaLabel,
}: ButtonProps) {
// 各 variant にはブランド判断が宿っている
const variantStyles = {
primary: {
default: 'bg-primary text-white hover:bg-primary-dark focus:ring-primary/30',
loading: 'bg-primary/70 text-white',
disabled: 'bg-primary/40 text-white/70',
success: 'bg-success text-white',
error: 'bg-error text-white',
},
secondary: {
default: 'bg-surface-alt text-text-primary hover:bg-gray-300 focus:ring-primary/20',
loading: 'bg-surface-alt/70 text-text-primary',
disabled: 'bg-surface-alt/50 text-text-primary/50',
success: 'bg-success/20 text-success-dark border border-success/50',
error: 'bg-error/20 text-error-dark border border-error/50',
},
destructive: {
default: 'bg-error text-white hover:bg-error-dark focus:ring-error/30',
loading: 'bg-error/70 text-white',
disabled: 'bg-error/40 text-white/70',
success: 'bg-success text-white',
error: 'bg-error-dark text-white',
},
ghost: {
default: 'text-text-primary hover:bg-surface-alt focus:ring-gray-300',
loading: 'text-text-primary/70 bg-surface-alt/50',
disabled: 'text-text-primary/40',
success: 'text-success-dark bg-success/10',
error: 'text-error-dark bg-error/10',
},
link: {
default: 'text-primary underline hover:no-underline focus:ring-primary/30',
loading: 'text-primary/70 cursor-wait',
disabled: 'text-primary/40 cursor-not-allowed',
success: 'text-success underline',
error: 'text-error underline',
},
};
const sizeStyles = {
sm: 'px-2 py-1.5 text-sm', // 34px (min-height で調整可能)
md: 'px-4 py-2 text-base min-h-[44px]', // 44px 最小タッチターゲット
lg: 'px-6 py-3 text-lg min-h-[54px]', // 54px CTA用
};
const baseStyles = 'font-semibold rounded-md transition-all duration-150 focus:outline-none focus:ring-2';
const isDisabled = state === 'disabled' || state === 'loading';
const isLoading = state === 'loading';
return (
<button
className={`${baseStyles} ${sizeStyles[size]} ${variantStyles[variant][state]}`}
disabled={isDisabled}
onClick={isLoading ? undefined : onClick}
aria-busy={isLoading}
aria-disabled={isDisabled}
aria-label={ariaLabel}
>
{isLoading ? (
<span className="flex items-center gap-sm">
<span className="animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
処理中…
</span>
) : (
children
)}
</button>
);
}
ここで注目すべき点:
- variant の選択肢が限定されている: デザインの一貫性を保ちながら、必要な全バリエーションをカバー
- size に44px最小化を組み込み: アクセシビリティが設計段階から考慮されている
- state を明示的に管理:
isLoading のような複合状態を一元的に処理
- aria- 属性が条件付きで付与*: スクリーンリーダー利用者への配慮が型レベルで強制される
Polymorphic Components: 「この要素は何か」を動的に決定
多くのUIフレームワークで見かける as prop パターン。これは、コンポーネントがどの HTML 要素にレンダリングされるか、呼び出し側で判断できるということです。
// Button コンポーネント自体には as prop がないとします
// では、ボタン風のリンクを作りたい場合?
// 素朴な実装(判断が曖昧)
export function Button({ href, ...props }: ButtonProps & { href?: string }) {
if (href) {
return <a href={href} {...props} />;
}
return <button {...props} />;
}
// 良い実装: as prop で明示的に型指定
type ButtonElement = React.ElementType;
interface PolymorphicProps<T extends ButtonElement = 'button'> {
as?: T;
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
}
type PolymorphicRef<T extends ButtonElement> = React.ComponentPropsWithRef<T>['ref'];
export const Button = React.forwardRef(
<T extends ButtonElement = 'button'>(
{ as: Component = 'button' as T, variant = 'primary', size = 'md', ...props }: PolymorphicProps<T>,
ref: PolymorphicRef<T>
) => {
return (
<Component
ref={ref}
className={`button button--${variant} button--${size}`}
{...props}
/>
);
}
);
// 使い方
<Button>クリック</Button> // <button>
<Button as="a" href="/next">リンク</Button> // <a>
<Button as="label" htmlFor="check">チェック</Button> // <label>
この書き方の利点:
- 呼び出し側で「このボタンは何の要素なのか」を明示
- TypeScript が自動的に型チェック(
href は as="a" の時のみ有効)
- コンポーネント内部はシンプルで拡張性が高い
条件分岐のセマンティック化
UIロジックの約70%は条件分岐です。その条件分岐を単なる「if 文」ではなく、セマンティック(意味的)に富んだ ものに設計することで、意図が浮かび上がります。
フォームバリデーション: エラー状態の多層化
// ❌ 単純な実装
function Input({ error }: { error?: string }) {
return (
<>
<input className={error ? 'border-red-500' : 'border-gray-300'} />
{error && <span className="text-red-500">{error}</span>}
</>
);
}
// ✅ セマンティック実装
type ValidationState = 'default' | 'validating' | 'invalid' | 'valid';
interface InputProps {
value: string;
onChange: (val: string) => void;
validationState?: ValidationState;
errorMessage?: string;
successMessage?: string;
isRequired?: boolean;
ariaDescribedBy?: string;
}
export function Input({
value,
onChange,
validationState = 'default',
errorMessage,
successMessage,
isRequired,
ariaDescribedBy,
}: InputProps) {
const inputId = `input-${Math.random().toString(36).slice(2)}`;
const validationId = `${inputId}-validation`;
// 状態に応じたスタイルロジック
const getStateStyles = (state: ValidationState) => {
switch (state) {
case 'validating':
return 'border-blue-400 bg-blue-50 cursor-wait';
case 'invalid':
return 'border-red-500 bg-red-50 focus:ring-red-300';
case 'valid':
return 'border-green-500 bg-green-50 focus:ring-green-300';
default:
return 'border-gray-300 focus:ring-blue-300';
}
};
// アクセシビリティ属性の条件付き付与
const getAriaProps = () => {
const attrs: Record<string, any> = {
'aria-invalid': validationState === 'invalid',
'aria-busy': validationState === 'validating',
'aria-required': isRequired,
};
if (validationState === 'invalid' || validationState === 'validating') {
attrs['aria-describedby'] = [validationId, ariaDescribedBy]
.filter(Boolean)
.join(' ');
}
return attrs;
};
return (
<div className="flex flex-col gap-sm">
<input
id={inputId}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`border-2 px-3 py-2 rounded-md transition-all ${getStateStyles(
validationState
)}`}
{...getAriaProps()}
/>
{/* 条件付きメッセージ表示: role と aria-live で自動読み上げ */}
{validationState === 'invalid' && errorMessage && (
<div
id={validationId}
role="alert"
aria-live="assertive"
className="text-red-600 text-sm font-medium"
>
{errorMessage}
</div>
)}
{validationState === 'valid' && successMessage && (
<div
id={validationId}
role="status"
aria-live="polite"
className="text-green-600 text-sm font-medium"
>
{successMessage}
</div>
)}
{validationState === 'validating' && (
<div className="text-blue-600 text-sm flex items-center gap-xs">
<span className="animate-spin w-3 h-3 border-2 border-current border-t-transparent rounded-full" />
検証中…
</div>
)}
</div>
);
}
セマンティック化のメリット:
- 状態管理が明確:
validationState 一つで全てが決まる
- アクセシビリティが一貫: どの状態でも適切な ARIA 属性が自動付与される
- テストが容易: 各状態のレンダリング結果を予測できる
- 保守性が高い: 「この状態の時、どう表示されるべきか」が型として明示される
コンポーネントAPIの設計パターン
コンポーネントをどう設計するか、それ自体が判断の表現です。異なるAPIパターンを比較してみましょう。
パターン1: Compound Components
// カード風のコンポーネント: 複雑な内部構造を持つ
interface CardProps {
children: React.ReactNode;
}
export function Card({ children }: CardProps) {
return <div className="border rounded-lg p-lg">{children}</div>;
}
interface CardHeaderProps {
children: React.ReactNode;
}
export function CardHeader({ children }: CardHeaderProps) {
return <div className="border-b pb-md mb-md font-semibold text-lg">{children}</div>;
}
interface CardBodyProps {
children: React.ReactNode;
}
export function CardBody({ children }: CardBodyProps) {
return <div>{children}</div>;
}
interface CardFooterProps {
children: React.ReactNode;
}
export function CardFooter({ children }: CardFooterProps) {
return (
<div className="border-t mt-md pt-md flex gap-md justify-end">{children}</div>
);
}
// 使い方
<Card>
<CardHeader>ユーザー情報</CardHeader>
<CardBody>
<p>名前: 山田太郎</p>
<p>メール: yamada@example.com</p>
</CardBody>
<CardFooter>
<Button>キャンセル</Button>
<Button variant="primary">保存</Button>
</CardFooter>
</Card>
判断: 構造の柔軟性よりも、一貫した視覚階層 を保つことを優先
パターン2: Slot Pattern
// より柔軟性を持たせたい場合
interface CardProps {
header?: React.ReactNode;
body: React.ReactNode;
footer?: React.ReactNode;
}
export function Card({ header, body, footer }: CardProps) {
return (
<div className="border rounded-lg p-lg">
{header && <div className="border-b pb-md mb-md font-semibold text-lg">{header}</div>}
<div>{body}</div>
{footer && (
<div className="border-t mt-md pt-md flex gap-md justify-end">{footer}</div>
)}
</div>
);
}
// 使い方
<Card
header="ユーザー情報"
body={<p>名前: 山田太郎</p>}
footer={<Button>保存</Button>}
/>
判断: コンポーネント内部で視覚構造を一元管理することで、外側の複雑性を減らす
パターン3: Render Props
// データフローを柔軟に制御したい場合
interface DataListProps<T> {
data: T[];
renderItem: (item: T, index: number) => React.ReactNode;
renderEmpty?: () => React.ReactNode;
isLoading?: boolean;
}
export function DataList<T>({
data,
renderItem,
renderEmpty,
isLoading,
}: DataListProps<T>) {
if (isLoading) {
return <div className="p-lg text-center">読み込み中…</div>;
}
if (data.length === 0) {
return renderEmpty?.() ?? <div className="p-lg text-center">データがありません</div>;
}
return <div className="space-y-md">{data.map((item, i) => renderItem(item, i))}</div>;
}
// 使い方
<DataList
data={users}
renderItem={(user) => (
<div className="border p-md">
<p>{user.name}</p>
<p className="text-gray-500">{user.email}</p>
</div>
)}
renderEmpty={() => <p className="text-center text-gray-400">ユーザーが見つかりません</p>}
/>
判断: 親コンポーネントにレンダリング判定を委譲することで、関心の分離 を明確にする
Antigravityでのバイブコーディング実践
これまで学んだデザイントークンとコンポーネント設計を、実際にAIに委譲する際のプロンプト設計を解説します。
Step 1: デザイントークンの事前共有
# デザイントークン定義ファイル (design-tokens.ts)
以下の内容を Antigravity が参照できる状態にしてください:
## Color Palette
- primary: oklch(0.55 0.2 260)
- secondary: oklch(0.65 0.15 260)
- success: oklch(0.58 0.15 142)
- warning: oklch(0.68 0.15 65)
- error: oklch(0.55 0.2 25)
- surface: oklch(0.98 0.002 0)
## Spacing Scale (Fibonacci)
- xs: 0.25rem (4px)
- sm: 0.5rem (8px)
- md: 1rem (16px)
- lg: 1.5rem (24px)
- xl: 2.5rem (40px)
- xxl: 4rem (64px)
## Typography
- base: 1rem / 1.6 line-height
- h2: 1.75rem / 1.3 line-height (見出しは行間を詰めて視覚的重量を出す)
- h1: 2.25rem / 1.2 line-height
Step 2: 設計判断を明示したプロンプト
# Button コンポーネント実装依頼
上記のトークンファイルを参照してください。
## 要件
1. **variant の5パターン実装**:
- primary: 最も視覚的に目立つ主要アクション
- secondary: 補助的なアクション
- destructive: 削除・キャンセルなどの危険操作。エラーカラーを使用し、ユーザーの注意を引く
- ghost: 目立たない補助アクション。背景は透明で、ホバー時のみ背景色を付ける
- link: テキストリンク風。下線あり、ホバー時に下線なし
2. **size の3パターン**:
- sm: 密集レイアウト用(テキストサイズ: text-sm)
- md: タッチターゲット標準。min-h-[44px] で高齢ユーザーの指にも対応
- lg: CTA用の最大サイズ。min-h-[54px]
3. **state の管理**:
- default: 通常状態
- loading: 処理中。cursor-wait を表示し、opacity-70 で視覚的に無効化。ARIA: aria-busy="true"
- disabled: 操作不可。opacity-50, cursor-not-allowed. ARIA: aria-disabled="true"
- success: 成功後の表示。サクセスカラーを使用
- error: エラー表示。エラーカラーを使用
4. **アクセシビリティ**:
- aria-busy, aria-disabled を state に基づいて動的に付与
- disabled 属性の管理(loading または disabled 時は disabled 属性をセット)
- focus:ring-2 で focus-visible を視覚化
5. **コメント記述**:
- なぜその条件分岐を選んだのか、各セクションに「理由」を書いてください
- 例: "// min-h-[44px]: 高齢者やタッチペン利用者を含む全ユーザーが安全に操作できるサイズ"
## 出力形式
TypeScript React component (React.forwardRef なし、シンプルな関数コンポーネント)
Step 3: AI出力のレビューチェックリスト
# レビューチェックリスト
Antigravity が出力したコンポーネントを検査:
□ **トークンの使用**
- oklch で定義された色のみ使用されているか?
- ハードコードされた色値 (#RGB や rgb()) がないか?
- spacing は token を参照しているか?
□ **型の厳密性**
- variant, size, state は制限された文字列リテラルか?
- 無効な組み合わせを型レベルで排除できるか?
□ **アクセシビリティ**
- aria-* 属性が state に応じて条件付き付与されているか?
- disabled 属性と disabled 状態が連動しているか?
- focus-visible が css で表現されているか?
□ **条件分岐の明瞭性**
- 各分岐に「なぜこうするのか」コメントがあるか?
- 複雑な三項演算子を避け、変数に分離しているか?
□ **パフォーマンス**
- 不要な className 連結がないか?
- styles オブジェクトが最適化されているか?
修正が必要な場合は、Antigravity に「なぜ」を追加するよう指示する。
デザインシステムとしての判断の蓄積
最後に、こうした判断を単発の実装に留めず、組織的に蓄積・共有する仕組みを紹介します。
Storybook + Chromatic: ビジュアルリグレッション
// components/Button.stories.tsx
import { Button } from './Button';
export default {
title: 'Components/Button',
component: Button,
};
export const Primary = {
args: {
variant: 'primary',
children: 'Click me',
},
};
export const PrimaryLoading = {
args: {
variant: 'primary',
state: 'loading',
children: 'Processing',
},
};
export const Destructive = {
args: {
variant: 'destructive',
children: 'Delete',
},
};
export const DestructiveDisabled = {
args: {
variant: 'destructive',
state: 'disabled',
children: 'Delete',
},
};
Chromatic を使うことで:
- ビジュアルリグレッション検出: 色や余白が意図せずに変わっていないか、自動検出
- 判断の可視化: デザイナーと開発者が同じビジュアルで判断を共有
- チェンジログ: 「なぜこの色が変わったのか」をレビューコメントで記録
Figma ↔ コード の双方向同期
// token-sync.ts - Figma のトークンをコードに自動生成
import { readFileSync } from 'fs';
const figmaTokens = JSON.parse(readFileSync('./tokens.json', 'utf-8'));
// oklch 色値を Tailwind カスタムカラーに変換
const colors = Object.entries(figmaTokens.colors).reduce((acc, [key, value]) => {
acc[key] = value; // oklch() 形式のまま
return acc;
}, {});
export const tailwindConfig = {
theme: {
colors,
},
};
このアプローチにより:
- 単一の真実: トークン定義が Figma の一箇所に集約
- 自動更新: Figma が変わるたびに、コードが同期
- 判断の記録: 「このトークンを作った背景」を Figma の説明欄に書ける
チーム内ドキュメント化: 「なぜ」の蓄積
# デザイン判断ログ
## 2026-03-27: oklch 色空間への移行
**判断**: RGB や HSL ではなく oklch を採用
**理由**:
- 知覚均等性が高く、lightness の差が画面のどこでも同じに見える
- UI 色の調整が容易(saturation を減らすだけで淡い版が得られる)
- 印刷業界の CIELAB が基になっており、デザイナーの感覚と合致しやすい
**代替案との比較**:
- HSL: 色相の変化で彩度・明度が勝手に変わる(デメリット)
- Tailwind 標準カラー: ブランド性が弱い(デメリット)
**影響範囲**:
- tailwind.config.ts
- Figma デザイントークン
- サイト全体のブランドカラー
---
## 2026-03-27: ボタンの min-h-[44px] 化
**判断**: すべてのボタンに 44px 最小高さを設定
**理由**:
- WCAG 2.5.5 (Target Size) で「最小44×44pxの操作対象」を推奨
- 高齢者、視力低下者、触覚障害者の操作性向上
- モバイル・タッチペン環境での誤クリック防止
**代替案との比較**:
- 32px (Apple HIG): iOS 内部アプリケーション用。Web は44px推奨
- 54px (Google Material): 大きすぎる。md サイズは 44px で十分
**段階的導入**:
- Phase 1: 新規コンポーネントに適用 ✅
- Phase 2: 既存ボタンを段階移行
- Phase 3: フォーム入力も同基準に統一予定
このドキュメントにより:
- 新しいチームメンバーが「なぜこのデザインなのか」を理解しやすい
- 設計判断の変更時に「前の判断との比較」が容易
- 1年後に「なぜこうしたのか」と疑問に思った時の拠り所になる
高度なパターン: 動的コンポーネント合成
最後に、複数のコンポーネントを組み合わせるパターンを紹介します。
// FormField: Label + Input + Error の一体化
interface FormFieldProps {
label: string;
value: string;
onChange: (val: string) => void;
error?: string;
isRequired?: boolean;
}
export function FormField({
label,
value,
onChange,
error,
isRequired,
}: FormFieldProps) {
const id = `field-${Math.random().toString(36).slice(2)}`;
return (
<div className="flex flex-col gap-sm">
<label htmlFor={id} className="font-medium">
{label}
{isRequired && <span className="text-error ml-1">*</span>}
</label>
<Input
id={id}
value={value}
onChange={onChange}
validationState={error ? 'invalid' : 'default'}
errorMessage={error}
ariaDescribedBy={id}
/>
</div>
);
}
ここで重要な判断:
- 関心の分離: Label、Input、Error表示の責任を明確に分離
- 単一責任: FormField は「フォーム欄」の構造だけ担当。スタイリングは Input が管理
- 再利用性: Input 単独でも使える。FormField で「よくある組み合わせ」を提供
推奨書籍
UI設計とコンポーネント設計の理論をより深く学びたい方向けの推奨書籍:
-
「Thinking with Type」(テキストデザインの実践的理論)
-
「Component-Driven Development」(デザインシステムの実装パターン)
-
「Inclusive Components」(アクセシビリティに配慮したコンポーネント設計)