mcpField
G
generate-program

generate-program

github.com/foldkit開発1.1k55 回インストール1 週間前に更新
リポジトリ

generate-program について

記述された要件から Foldkit プログラム全体を生成するスキルです。状態、操作、非同期処理、UI の要件を分析し、段階別のサンプルや本番コードを参照します。不明点があれば実装前に具体的な質問をします。

主な機能

  1. 01エンティティ、操作、非同期処理の分析
  2. 02ルーティング、UI、バリデーション要件の特定
  3. 03複雑さに応じたサンプルの確認
  4. 04本番コードの品質基準を参照
  5. 05実装前に振る舞いの不足を確認

活用シーン

  1. 01記述から Foldkit アプリを生成
  2. 02複数ページやリアルタイムアプリの構成検討
  3. 03状態遷移や境界条件の明確化

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/foldkit/foldkit --skill generate-program

インストール後、Claude Code で/generate-programを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

architecture.md
blindSpots.md
checklist.md
conventions.md
agents/
openai.yaml

詳細

この説明に基づいて、Foldkit プログラムを完全な形で生成してください。

$ARGUMENTS

フェーズ 1:説明を分析する

コードを書く前に、説明を分析し、次の項目を特定してください。

  1. ドメインエンティティ:Model のフィールドになる名詞(例:「todos」「user」「score」)
  2. ユーザーインタラクション:Messages になる動詞(例:「add」「delete」「filter」「submit」)
  3. 非同期処理:Commands になる外部データ(例:「fetch weather」「save to localStorage」)
  4. リアルタイム要件:Subscriptions になるストリーミングデータ(例:「live updates」「countdown」「WebSocket」)
  5. ページ/ナビゲーション:ルートになる URL 構造(例:「ホームページ」「詳細ページ」)
  6. UI コンポーネントの要件:Foldkit の UI コンポーネントに対応するインタラクティブなウィジェット(例:「dropdown」→ Menu、「modal」→ Dialog、「tabs」→ Tabs、「autocomplete」→ Combobox、「date picker」→ DatePicker、「file upload」→ FileDrop、「reorderable list」→ DragAndDrop、「toast/notification」→ Toast、「hover tooltip」→ Tooltip、「range/price filter」→ Slider、「long scrolling list」→ VirtualList)
  7. フォームのバリデーション要件:必須フィールド、形式チェック、非同期の一意性チェック → foldkit/fieldValidation モジュール(フェーズ 4 を参照)
  8. 日付の処理:誕生日、期限、スケジュール設定 → Calendar モジュールと、@foldkit/ui の DatePicker または Calendar
  9. ファイルの処理:アップロード、添付ファイル、画像 → File モジュールと、@foldkit/ui の FileDrop
  10. リモートデータ:取得、キャッシュ、更新、再検証するデータ全般 → AsyncData モジュール(フェーズ 4 を参照)。ローディング/エラーの Union 型を独自に実装しないでください
  11. 複数状態のフロー:複数の名前付きステップを進み、次のステップがルールで決まるプロセス(チェックアウト、オンボーディング、複数段階の承認、接続のライフサイクルなど) → Machine モジュール(foldkit/experimental)を検討してください。遷移をテーブルとして記述すると、unreachableStates() と deadTransitions() によって、initial および呼び出し元が指定する追加のルートから、宣言された Edges を通じた到達可能性を調べられます。分析では Machine の外部で行われる状態変更を検出できないため、その結果をアプリケーションの不具合と判断する前に、復元された状態、ディープリンクされた状態、ハイドレーションされた状態など、外部から入る状態を追加のルートとして渡してください。実験的な選択肢として提示し、利用するかどうかはユーザーに選んでもらってください。状態が 2 つか 3 つだけのフローでは、defineTaggedUnion と 1 つの match を使用してください。参考資料は repos/foldkit/examples/state-machine/ です
  12. ホストへの埋め込み:プログラムが別のアプリ内で実行される(「React アプリにウィジェットとして組み込む」「既存のページに埋め込む」「ホスト側から操作できるようにする」など) → Runtime.makeElement と Runtime.embed のライフサイクルハンドルを使用し、初期データには Flags、双方向の継続的な通信には Ports を使ってください。標準の参考資料は repos/foldkit/examples/embedding/ です。プレーンな TypeScript のホストから Foldkit ウィジェットを端から端まで操作する方法を確認できます

先にこの分析結果をユーザーに提示してください。

説明が具体的で曖昧さがなければ、分析結果を要約して確認を求めてから先に進んでください。ただし、不明点がある場合(状態遷移が不明確、UI の要件が曖昧、エラー処理の指定がない、エッジケースが不足している、ドメインの境界が曖昧、カウンターやリセットの動作が不明確など)は、先に確認したい点を絞って質問してください。「ほかに何かありますか?」のような自由回答形式の質問は避けてください。見つかった不明点について、具体的な質問をしてください。

UX/動作に関する不明点:

  • 「ToDo リストはページの再読み込み後も保持しますか(localStorage を使用)、それともセッションごとに新しく始めますか?」
  • 「API 呼び出しが失敗した場合、アプリにはインラインエラーとダイアログのどちらを表示しますか?」
  • 「『ユーザーが項目を編集できる』とありますが、インライン編集ですか、それとも別の編集ページを使いますか?」

ドメインロジックに関する不明点(見落としやすく、修正コストが高い項目):

  • 「ユーザーが間隔をスキップした場合、連続記録の『完了』としてカウントされますか?」
  • 「『完了』を追跡するカウンターは、成功したアクションのみをカウントしますか?それともスキップしたアクションもカウントしますか?」
  • 「ユーザーがフローの途中でリセットを実行した場合、カウンターもリセットされますか?それともリセット後も保持されますか?」
  • 「『N 回のイベント後に X を実行する』とありますが、これはイベントの合計が N 回という意味ですか?それとも前回 X を実行してから N 回という意味ですか?」
  • 「サイクルの N 回目のアクションでは、サイクルの最初のアクションと最後のアクションのどちらを実行しますか?」

ドメインロジックに関する質問をしておくと、コードに入る前にオフバイワンのバグが見つかることがよくあります。説明にカウンター、サイクル、連続記録、「N 回後」のような表現がある場合は、特に 0、1、N でのエッジケースについて質問してください。

目標は、早い段階で曖昧さを解消し、想定した動作ではなく、ユーザーが実際に望む動作に合ったコードを生成することです。

フェーズ 2:参考例を確認する

アーキテクチャと規約のガイドを読み、ルールを理解してください。

  • アーキテクチャガイド:TEA の構造、ファイル構成、型のパターン
  • 規約ガイド:命名、Effect-TS のパターン、アンチパターン
  • 検証チェックリスト:フェーズ 5 だけでなく、コード生成の基準でもあります。今のうちに品質基準セクションに目を通し、レビューで不合格となるコードではなく、最初から基準を満たすコードを生成してください。
  • 見落としやすい点:フェーズ 6 のレビュアーが評価する項目です。今読んでおけば、後で修正するより手間がかかりません。

これら 4 つのファイルは、変化し続けるコードベースのある時点を切り取ったものです。現行のソース(repos/foldkit/ または node_modules 内の .d.ts)と内容が異なる場合は、現行のソースが正しいものとして扱ってください。食い違いはこのスキルのバグとして扱い、最終報告でその旨を伝えてください。

context7 MCP ツールを利用できる場合、API に確信が持てないときは Effect-TS のドキュメントを調べてください。Effect は大規模なライブラリです。推測せず、関数のシグネチャを確認してください。

品質の模範例

生成するアプリの_品質基準_となるコードベースが 2 つあります。単に「真似するパターン」ではなく、「到達すべき完成度」です。

  • ${CLAUDE_SKILL_DIR}/../../packages/typing-game/client/src/:本番品質の複数ページアプリ。Submodels、OutMessage、update/view の分割、カリー化したハンドラーの抽出、サブスクリプションのパターン、ドメインモジュールを確認できます。
  • ${CLAUDE_SKILL_DIR}/../../packages/website/src/:本番品質の Foldkit ウェブサイト。ページ構成、共有ビューのプリミティブ、ルート駆動のレンダリング、慣用的なドメイン分割を確認できます。

生成を始める前に、それぞれから少なくとも 1 つのファイルを抜き出して確認してください(update.ts の構成、ハンドラーの抽出方法、ドメインファイルの構成など)。その完成度を自分の出力にも反映させてください。生成されたコードは、模範例の手書きコードと見分けがつかないレベルを目指してください。

次に、アプリの複雑さに合った階層別のサンプルファイルを読んでください。**必ず、階層別のサンプルを少なくとも 1 つ読んでください。**記憶だけを頼りに生成してはいけません。

複雑さのレベル

レベル 1:単一ページ、非同期処理なし、状態は最小限: ${CLAUDE_SKILL_DIR}/../../examples/counter/src/main.ts を読みます

レベル 2:タイマー、サブスクリプション、シンプルな状態を持つアプリ: ${CLAUDE_SKILL_DIR}/../../examples/stopwatch/src/main.ts(サブスクリプションによるタイマー、Duration フィールドのパターン)と ${CLAUDE_SKILL_DIR}/../../examples/todo/src/main.ts(Flags を介した localStorage での CRUD)を読みます

レベル 3:非同期処理、読み込み/エラー状態、API 呼び出し、フォーム検証: ${CLAUDE_SKILL_DIR}/../../examples/weather/src/main.ts(HttpClient による HTTP)と ${CLAUDE_SKILL_DIR}/../../examples/form/src/main.ts(foldkit/fieldValidation を使用。フェーズ 4 のフォーム検証のセクションを参照)を読みます

レベル 4:URL ルーティング、複数ページ、クエリパラメーター: ${CLAUDE_SKILL_DIR}/../../examples/routing/src/main.ts と ${CLAUDE_SKILL_DIR}/../../examples/query-sync/src/main.ts を読みます

レベル 5:複雑な状態、ネストされたドメインモデル、CRUD、ドラッグ&ドロップ: ${CLAUDE_SKILL_DIR}/../../examples/shopping-cart/src/main.ts(ネストされたドメインスキーマ、カートの状態)と ${CLAUDE_SKILL_DIR}/../../examples/kanban/src/main.ts(DragAndDrop による CRUD、localStorage から復元する Flags、サブスクリプション)を読みます

レベル 6:サブモデル、OutMessage、複数ステップのフォーム、認証フロー、複数モジュールのアプリ: ${CLAUDE_SKILL_DIR}/../../examples/auth/src/main.ts(Submodels、OutMessage、保護されたルートによるログイン/サインアップ)と ${CLAUDE_SKILL_DIR}/../../examples/job-application/src/main.ts(step/ 内に深くネストされた Submodels、DatePicker、FileDrop、Listbox、日付処理用の Calendar モジュールを使った複数ステップのフォーム)を読みます

レベル 7:リアルタイム、WebSocket、マネージドリソース、本番環境レベル: ${CLAUDE_SKILL_DIR}/../../packages/typing-game/client/src/update.ts を読み、その後 page/home/ と page/room/ ディレクトリを調べて、Submodel/OutMessage のパターン全体を確認します。

対象のレベルと、それより下のすべてのレベルの例を読みます。レベル 4 のアプリにも、レベル 1~3 のパターンを反映する必要があります。

フェーズ 2.5:Foldkit UI コンポーネントの活用機会を確認する

Foldkit には、キーボード操作、ARIA 属性、フォーカス管理を自動で処理する、アクセシブルな UI コンポーネントが用意されています。これらは別パッケージの @foldkit/ui にあり、名前を指定してインポートします。

import { Button, Dialog, Input } from '@foldkit/ui'

foldkit パッケージに Ui 名前空間はありません。Ui.Dialog.view ではなく、Dialog.view を使用してください。バレルをバンドルに含めたくない場合は、ディープインポート(@foldkit/ui/dialog)も使用できます。

生成を始める前に、アプリの各部分が組み込みコンポーネントに対応するか確認します。

ユーザーのニーズコンポーネント無料で利用できる機能
モーダル/ダイアログ/確認Dialogフォーカスのトラップ、Escape キーで閉じる、スクロールのロック、背景
タブ付きコンテンツTabs矢印キーによるナビゲーション、aria-selected、roving tabindex
ドロップダウンメニューMenu矢印キー、タイプアヘッド検索、aria-expanded、外側クリック
オートコンプリート/タグ入力Combobox絞り込み、矢印キーによる選択、aria-activedescendant
セレクトドロップダウンSelectキーボードによる選択、aria-selected、位置調整
選択肢からの単一選択RadioGroup矢印キーでの循環、aria-checked、読み取り専用でのナビゲーション
オン/オフ切り替えSwitchスペースキーで切り替え、aria-checked
真偽値オプションCheckboxスペースキーで切り替え、aria-checked、不確定状態
展開可能なセクションDisclosureEnter/スペースキーで切り替え、aria-expanded
ホバー/クリックで表示するフローティングコンテンツPopover位置調整、外側クリック、フォーカス管理
ホバーツールチップTooltip表示遅延、キーボードで閉じる、位置調整、aria-describedby
単一選択リストListbox矢印キー、タイプアヘッド、aria-selected
テキスト入力Input一貫したスタイル/動作のラッパー
複数行テキストTextarea自動リサイズ、一貫したスタイル
フォームグループFieldset無効状態の伝播、グループ化
スタイル付きボタンButton一貫したクリック/キーボード処理
インラインカレンダーグリッドCalendar月の移動、キーボードナビゲーション、aria-selected、日付の制約
日付入力+ポップオーバーDatePickerカレンダーポップオーバー、入力マスキング、キーボードナビゲーション、制約
ファイルアップロード領域FileDropドラッグ&ドロップ、クリックしてファイルを選択、受け入れフィルター、検証
並べ替え可能なリストDragAndDropポインター+キーボードによるドラッグ、ドロップ領域、アナウンス領域
一時的な通知Toast自動で閉じる、ホバー中は一時停止、積み重ね、role=status/alert
数値範囲/価格フィルターSlider矢印/Home/End キー、aria-valuenow、複数つまみの範囲
長いスクロールリストVirtualListウィンドウ化レンダリング、スクロールアンカリング、測定済み項目の高さ
サイト/セクションのナビゲーションNav現在のページのマーク、キーボード操作、ランドマークのセマンティクス

このパッケージは、単一の形式ではありません。

状態を持つサブモデルは、それぞれ独自の Model、Message、update、そして(ほとんどの場合)OutMessage を持ち、h.submodel を使って埋め込みます。対象は Menu、Listbox、Combobox、Calendar、DatePicker、Dialog、Popover、RadioGroup、Tabs、Tooltip、FileDrop、DragAndDrop、Slider、VirtualList です。また、Toast.make(PayloadSchema) を通じて構築した Toast も含まれます。

ステートレスなレンダリングヘルパーは Model を持ちません。ViewConfig と独自の h を指定して view を直接呼び出し、その値は独自の Model に保存します。対象は Button、Input、Textarea、Select、Fieldset、Checkbox、Switch、Disclosure、Nav です。 その分割を鵜呑みにしないでください。コンポーネントが分割をまたいで移動しているためです(Checkbox、Switch、Disclosure は制御付きレンダーヘルパーに、RadioGroup は Submodel に、Tabs と Slider は選択状態を親の Model に移しました)。コンポーネントの public.d.ts を確認してください。Model と update がエクスポートされていれば Submodel、view と ViewConfig / ViewInputs 型のみがエクスポートされていればレンダーヘルパーです。レンダーヘルパーには Got* Message は必要ありません。

状態を持つ Submodel を使用するには、次の手順に従います。

  1. Model にその Model を追加します:confirmDialog: Dialog.Model
  2. Got* Message を追加します:{ message: Dialog.Message } を持つ GotConfirmDialogMessage
  3. init で初期化します:confirmDialog: Dialog.init({ id: 'confirm-dialog' })
  4. update で委譲します:GotConfirmDialogMessage: ({ message }) => ...
  5. h.submodel を使って view に埋め込みます:h.submodel({ slotId: 'confirm-dialog', view: Dialog.view, model: model.confirmDialog, toParentMessage: message => Message.GotConfirmDialogMessage({ message }) })(view が viewInputs を受け取るコンポーネントには viewInputs を追加します)

インタラクティブなウィジェットを独自に実装するより、必ず Foldkit UI コンポーネントを優先してください。 アクセシビリティが後付けではなく、デフォルトで備わります。

**特にフォーム入力について:**フォーム内のすべてのテキスト入力、テキストエリア、ボタンは、必ず Input、Textarea、Button を経由する必要があります。view のビルダー h では生の input / textarea HTML 要素も使用できますが、これは任意ではありません。フォームの例(examples/form/src/main.ts)では、Input.view と Textarea.view をラベルおよびバリデーションフィードバックと組み合わせる inputFieldView と textareaFieldView ヘルパーを定義しています。このヘルパーパターンを使ってください。生の input / textarea は、検索フィールドやインラインエディターなど、意図的にコンポーネント層より下のレベルで作業するフォーム以外のケースで使用します。その場合でも、まずはコンポーネントを検討してください。

アプリで UI コンポーネントを使用する場合、コンポーネントの配線方法を理解するため、必ず最初に ui-showcase の例を読んでください。 Foldkit UI の統合パターンを確認するための標準リファレンスです。

  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/main.ts:ルートの配線、Got* の委譲、toParentMessage ヘルパー
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/message.ts:コンポーネントの Message の構造
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/model.ts:コンポーネント Model の組み合わせ方
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/update.ts:コンポーネントの更新の委譲方法
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/subscriptions.ts:親に Subscriptions を持ち上げる必要があるコンポーネント(DragAndDrop、Slider)
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/toast.ts:Toast を使用する場合に読んでください。Toast.make(PayloadSchema) でペイロードスキーマを受け取り、インポート可能な型付きモジュールを返すという点で、独特なコンポーネントです。

examples/ui-showcase/src/ 以下のディレクトリ名は、これまでに変更されています。これらのパスを鵜呑みにせず、ディレクトリの内容を一覧表示してください。

DatePicker、FileDrop、その他の最近追加されたコンポーネントを使用するアプリでは、job-application の例(下記の Tier 6 を参照)も読んでください。これらのコンポーネントを組み合わせた、最も包括的な実際の統合例です。

フェーズ 3:ファイル構成を決める

アプリの複雑さに合わせてファイル構成を決めます。アーキテクチャはどの規模でも同じで、変わるのはファイル構成だけです。

各ファイルに含める内容

以下の階層別レイアウトに加え、model.ts が肥大化するのを防ぐため、次の「スキーマ配置」ルールに従ってください。

  • model.ts には、Model スキーマと、Model のフィールドになるスキーマ(フォーム状態/送信状態のユニオンなど、フィールドに組み込まれるものを含む)のみを置きます。それ以外は置きません。
  • command.ts には、コマンドが外部システムに送信する、または外部システムから受け取るペイロードのスキーマを置きます。特に、saveState がシリアライズし、flags がデシリアライズする永続化スキーマが該当します。永続化スキーマはコマンド層の関心事であり、モデルの関心事ではありません。Model のサブセットのように見えることはよくありますが、Model の一部ではありません。
  • domain/*.ts には、ドメインエンティティのスキーマと、それらに対する純粋な操作を置きます。
  • message.ts には、メッセージのみを置きます。
  • route.ts には、ルートのバリアントとルーターのパイプラインを置きます。

よくある間違いは(kanban が SavedBoard を model.ts に置いているため)、永続化スキーマも「スキーマだから」という理由で model.ts に置くことです。これは Model 用ではなく、永続化レイヤー用のスキーマです。使用する場所に移してください。

単一ファイル(Tier 1-2、約300行未満):

src/main.ts          ← Model, Message, init, update, view
src/entry.ts         ← Runtime.makeApplication + Runtime.run

コマンドとメッセージを分離(Tier 3、非同期操作あり):

src/main.ts          ← Model, init, update, view
src/entry.ts         ← Runtime.makeApplication + Runtime.run
src/message.ts       ← Message definitions
src/command.ts       ← Command functions

重要なルール: command.ts を切り出す場合は、必ず message.ts も切り出してください。コマンドは、Effect の戻り値として Message のコンストラクター(例:Message.SucceededFetchWeather({...}))を参照します。Message が main.ts にあり、Command が command.ts にある場合、command.ts は main.ts からインポートし、さらに main.ts は command.ts の Command を使うため、循環インポートが発生します。まず Message を切り出し、その後、main.ts と command.ts の両方から message.ts の Message 名前空間をインポートしてください。

完全分割(Tier 4-5、複数の関心事):

src/main.ts          ← init, update, view
src/entry.ts         ← Runtime.makeApplication + Runtime.run
src/model.ts         ← Model schema
src/message.ts       ← Message definitions
src/command.ts       ← Command functions
src/route.ts         ← Route parser (if routing)
src/view.ts          ← View functions (if view is large)
src/domain/          ← Shared domain schemas (if multiple entities)

サブモデルのディレクトリ(Tier 6-7、独立したモジュール):

src/main.ts          ← Root init, update, view
src/entry.ts         ← Runtime.makeApplication + Runtime.run
src/model.ts         ← Root model (contains submodels)
src/message.ts       ← Root messages + Got* bridging
src/command.ts       ← Shared commands
src/route.ts         ← Route parser
src/domain/          ← Shared domain schemas
src/page/
  featureA/
    main.ts          ← Submodel init, update, view
    message.ts       ← Submodel messages + OutMessage
    command.ts       ← Submodel commands
  featureB/
    ...

フェーズ 3.3:アーキテクチャのスケッチ(Tier 4 以上のみ)

Tier 4 以上のアプリ(ルーティング、ドメインモジュール、複数のエンティティ、サブモデル)では、実装を生成する前に、簡潔なスケッチを作成してください。Tier 1-3 のアプリは一度に生成できるほど小規模ですが、Tier 4 以上のアプリでは、構造が誤っていると多くの労力が無駄になります。

スケッチは次の5つの部分で構成します。会話内にそのまま提示し、確認を得てから、その後でひな形を作成します。

  1. ファイルツリー:作成する正確なパス。フェーズ 3 の構成に合わせます。
  2. モデルの形状:トップレベルの Schema.Struct フィールドとその型。スキーマ全体ではなく、形状のみを示します。
  3. メッセージ一覧:定義するすべての Message を、カテゴリ(クリック、入力、コマンド、送出メッセージ)ごとにまとめます。
  4. ルート一覧:ルーティングがある場合、各 defineRouteUnion バリアントとそのパラメーター、対応するパスを示します。
  5. ドメイン操作:domain/ 内の各ファイルが公開する操作(Link.byNewest、Link.filterByTag など)を示します。

Tier 4 のリンク保存アプリの例:

### Sketch

Files:
  src/main.ts, entry.ts, model.ts, message.ts, command.ts, route.ts
  src/domain/link.ts, index.ts
  src/story.test.ts, scene.test.ts

Model:
  route: AppRoute
  links: ReadonlyArray<Link>
  newLinkForm: NewLinkForm (url: Field<string>, title/description/tagsInput: string, submitState)

Messages:
  Clicks: ClickedSaveLink, ClickedDeleteLink
  Inputs: UpdatedLinkUrl, UpdatedLinkTitle, UpdatedLinkDescription, UpdatedLinkTagsInput, BlurredLinkUrl
  Commands: SubmittedNewLinkForm, SucceededSaveLinks, FailedSaveLinks
  Routing: ClickedLink, ChangedUrl, CompletedNavigateInternal, CompletedLoadExternal
  Toggles: ToggledFavorite

Routes:
  AppRoute.Home       → /
  AppRoute.NewLink    → /new
  AppRoute.TagFilter  → /tag/:tag
  AppRoute.NotFound   → /* fallback

Domain:
  Link: schema + byNewest, filterByTag, toggleFavorite, remove, updateById

スケッチを提示したら、確認または修正をユーザーに求めてください。ユーザーが確認するまで、ひな形の作成やコード生成を始めないでください。ユーザーが明示的な返答なしに確認した場合(例:「よさそうです。続けてください」)、そのまま進めます。修正があった場合は、スケッチを調整してください。承認されていない版をもとにコードを書かないでください。

エージェントが「何をすべきかわかっている」ため、この手順を省きたくなることがよくあります。しかし省略すると、構造の変更が必要だと後から判明する、生成済みのアプリを作ってしまうことになります。これは、反復の中でもコストのかかる形です。スケッチなら、低コストで反復できます。

フェーズ 3.5:プロジェクトのひな形を作成する

コードを生成する前に、create-foldkit-app を使って実行可能なプロジェクトのひな形を作成します。

npx create-foldkit-app@latest

フラグを指定せずに実行して対話形式のプロンプトを表示し、ベースには最もシンプルなカウンターの例を、パッケージマネージャーにはユーザーの希望するものを選択してください。生成されるプロジェクトには、次のものが含まれます。

  • Foldkit と Effect の依存関係をすべて記述した package.json
  • Tailwind と Foldkit の Vite プラグインを含む vite.config.ts
  • 厳密な TypeScript 設定を含む tsconfig.json
  • ルートコンテナーを含む index.html
  • Tailwind のインポートを含む src/styles.css
  • Foldkit の規約を記載した FOLDKIT.md と、そこを参照する AGENTS.md のひな形
Foldkit のサブツリーを追加する

スキャフォールディングの後、Foldkit を git サブツリーとしてベンダーに取り込むことを提案します。今後の AI セッションで、ユーザーのプロジェクトからソース、サンプル、ドキュメント全体を直接参照できるようになります。サブツリーをマージするためのベースコミットが必要なので、まずスキャフォールドをコミットしてください。

git init          # if not already a git repo
git add .
git commit -m "chore: initial commit"
git subtree add --prefix=repos/foldkit https://github.com/foldkit/foldkit.git "foldkit@$(node -p "require('./node_modules/foldkit/package.json').version")" --squash

foldkit@<version> タグを指定すると、サブツリーはスキャフォールドでインストールしたリリースに固定されます。そのため、ベンダーに取り込んだソース、サンプル、ドキュメントは、アプリがコンパイル対象としている API と一致します。代わりに main をベンダーに取り込むと、アプリにまだインストールされていないリリースのサンプルや API が、今後のセッションに提示される可能性があります。カナリア版のインストール(x.y.z-canary.<commit>)にはタグがありません。バージョン名に含まれるコミットの完全なハッシュを指定してください。GitHub では、そのハッシュが https://github.com/foldkit/foldkit/commit/<commit> に展開されます。

これは任意ですが、強く推奨します。スキャフォールドで生成される AGENTS.md には subtree_prompted: false という行が含まれており、エージェントは今後のセッションでこの行を確認します。サブツリーがなく、このフラグが false の場合、エージェントは追加を提案します。ここで先に対応しておけば、ユーザーは次の AI セッションからすでにすべてのコンテキストを利用できます。ユーザーが辞退した場合は、再度尋ねないように、この行を subtree_prompted: true に更新してください。

Foldkit パッケージをアップグレードした後にサブツリーの固定先を変更するには、同じコマンドの add を pull に置き換えて実行してください。

スキャフォールドを置き換える

次に、src/main.ts のカウンターのサンプルコードを生成したアプリコードに置き換えます(必要に応じてソースファイルを追加します)。

フェーズ 3.7:Foldkit API の仕様を確認する

コードを書く前に、使用する Foldkit の各モジュールの型シグネチャをすべて読みます。シグネチャを推測すると、作業時間を浪費します。推測を間違えるたびに tsc エラーが発生し、読み直し、編集、再度の型チェックが必要になります。5 分かけて読めば、30 分の試行錯誤を防げます。

読むファイル

使用する Foldkit モジュールごとに、以下のパスにある .d.ts を読みます。公開 API を確認してください。内部実装を読む必要はありません。生成中に再確認しなくて済むよう、作業メモにシグネチャの簡単な一覧を書いてください。

# Every app
<project>/node_modules/foldkit/dist/index.d.ts          # top-level re-exports: the authoritative list of what `foldkit` exposes
<project>/node_modules/foldkit/dist/html/index.d.ts     # HtmlBuilder<Message>, element signatures, Attribute<Message>, inertHtml
<project>/node_modules/foldkit/dist/message/public.d.ts # defineMessageUnion()
<project>/node_modules/foldkit/dist/schema/public.d.ts  # defineTaggedUnion(), taggedStruct()
<project>/node_modules/foldkit/dist/struct/index.d.ts   # modifyFields(): check nested-update signature
<project>/node_modules/foldkit/dist/update/public.d.ts  # Update.Return, Update.ReturnWithOutMessage, Update.foldChildInit, Update.foldChildInits, Update.withOutMessage, Update.combine, Update.refresh
<project>/node_modules/foldkit/dist/runtime/runtime.d.ts # ApplicationInit, RoutingApplicationInit, makeApplication, makeElement

# If using routing
<project>/node_modules/foldkit/dist/route/public.d.ts   # defineRouteUnion, literal, slash, string, int, Route.root, Route.mapTo, Route.oneOf, Route.parseUrlWithFallback
<project>/node_modules/foldkit/dist/url/index.d.ts      # toString
<project>/node_modules/foldkit/dist/navigation/index.d.ts # pushUrl, load: all return Effect<void> (no Effect.ignore needed)

# If using async / side effects
<project>/node_modules/foldkit/dist/command/index.d.ts  # Command.define: config object with args/messages/interrupt/execute. Command.mapMessages for parent<-child mapping
<project>/node_modules/foldkit/dist/asyncData/public.d.ts # AsyncData: Idle/Loading/Refreshing/Failure/Stale/Success + Schema, match, isPending, hasData, revalidate
<project>/node_modules/foldkit/dist/http/public.d.ts     # Http.layer: provide it to Commands that use HttpClient
<project>/node_modules/foldkit/dist/dom/index.d.ts      # focus, advanceFocus, scrollIntoView, showDialog, closeDialog, clickElement, lockScroll, unlockScroll, inertOthers, restoreInert, detectElementMovement, waitForAnimationSettled. For time/random/delay use Effect's Clock, Random, Effect.sleep + Duration directly. For UUIDs use Crypto.Crypto's randomUUIDv4 with BrowserCrypto.layer.

# If using subscriptions
<project>/node_modules/foldkit/dist/subscription/index.d.ts # Subscription.make<Model, Message>, Subscription.lift, Subscription.aggregate

# If using mount / managed-resource / custom-element
<project>/node_modules/foldkit/dist/mount/public.d.ts            # Mount.define (one-shot) / Mount.defineStream (continuous): per-instance VNode lifecycle
<project>/node_modules/foldkit/dist/managedResource/public.d.ts  # ManagedResource.make / lift / aggregate + tag: for stateful runtime objects keyed on Model condition
<project>/node_modules/foldkit/dist/customElement/index.d.ts     # CustomElement.define: for typed bindings to native web components

# If the host application drives the program
<project>/node_modules/foldkit/dist/port/public.d.ts    # Port.inbound / outbound / emit / stream / subscription

# If using forms
<project>/node_modules/foldkit/dist/fieldValidation/public.d.ts # Field (tagged union), makeRules({required?, rules}), match, validate, allValid; rule constructors on the Rule namespace (Rule.url(options), Rule.email, Rule.minLength, Rule.pattern, Rule.fromSchema, ...)
# Rule.Rule is [Predicate, Rule.RuleMessage], NOT {test, message}. Field.Invalid has `errors: NonEmptyArray<string>`, not `error: string`.

# If using any UI component (SEPARATE PACKAGE)
<project>/node_modules/@foldkit/ui/dist/<component>/public.d.ts  # Model, Message, init, update, view (Submodel-shaped) or ViewConfig (render-helper-shaped), OutMessage when applicable
# Check: is it a Submodel (Menu/Listbox/Combobox/Calendar/DatePicker/Dialog/Popover/RadioGroup/Tabs/Tooltip/FileDrop/DragAndDrop/Slider/VirtualList/Toast) embedded via h.submodel, or a stateless render helper (Button/Input/Textarea/Select/Fieldset/Checkbox/Switch/Disclosure/Nav) called directly? Submodels carry their own Model/Message/update/OutMessage; render helpers don't. Check ViewInputs (for Submodels) or ViewConfig (for helpers) for the slot callbacks (toView, itemToConfig, etc.).

# If using dates
<project>/node_modules/foldkit/dist/calendar/index.d.ts # CalendarDate, today.local (returns Effect<CalendarDate>); for raw millis use Clock.currentTimeMillis

上記のパスが見つからない場合は、パッケージの dist/ を一覧表示して、該当するモジュールを見つけてください。**このファイルではなく、.d.ts が正式な仕様です。**両者に食い違いがある場合は .d.ts が正しく、このスキルに報告すべきバグです。

一覧に記録する内容

呼び出すシンボルごとに、1 行で記録します。

h: HtmlBuilder<Message> (view parameter, supplied by the runtime): { div, input (VOID), textarea (VALUE-ONLY), button, Class, Href, For, Id, Role, OnClick(Message), OnInput(value=>Message), OnBlur(Message), OnSubmit(Message), keyed, empty, submodel, ... }
Route.mapTo(schema)(parser): curried
pushUrl(path): Effect<void>  // NOT fallible, no Effect.ignore needed
urlToString(url: Url): string
Update.Return<Model, Message>: { model: Model; commands?: ReadonlyArray<Command.Command<Message>>; outMessage?: never }
Command.mapMessages(commands: ReadonlyArray<Command.Command<ChildMessage>> | undefined, toParentMessage): ReadonlyArray<Command.Command<ParentMessage>>
AsyncData.Schema(DataSchema, ErrorSchema): { schema, Idle(), Loading(), Success({data}), Failure({error}), ... }
AsyncData.match(value, { onIdle, onLoading, onRefreshing, onFailure, onStale, onSuccess })
  // handlers take BARE values, except onStale:
  //   onIdle: () => B          onLoading: () => B
  //   onRefreshing: (data) => B     onSuccess: (data) => B
  //   onFailure: (error) => B       onStale: ({ error, data }) => B
Command.define(name, { args, messages, execute }): every input is a named field.
  `execute` binds at DEFINITION and receives the decoded args object directly, so
  you destructure the fields themselves; the call site passes args:
  const Fetch = Command.define('Fetch', {
    args: { id: Schema.String },
    messages: [Ok, Err],
    execute: ({ id }) => ...,
  })
  update: [Fetch({ id })]     // NOT Fetch({ id })(effect)
Document: NOT generic, and `body` is a single Html, not an array
Input.view({ id, value, onInput, isInvalid?, type?, placeholder?, toView: (attrs) => Html }, h)
  // from '@foldkit/ui', NOT Ui.Input
  // attrs: { label: ReadonlyArray<Attribute<M>>, input: ..., description: ... }
Field (schema): NotValidated | Validating | Valid | Invalid(errors: NonEmpty<Rule Message>)
ジェネレーターで繰り返し発生する API の落とし穴

これらを一覧に記録し、コード生成中も見えるようにしておいてください。

  • input、brなどのvoid要素には属性のみを渡します:input([...])とし、input([...], [])にはしません。textareaにも属性のみを渡します。内容はh.Value(text)で設定し、子要素やh.InnerHTMLは使いません。buttonには子要素を渡します。
  • **子要素を受け取る要素では、子要素の引数は省略できます。**子要素がない場合は省略します:div([Class('divider')])とし、div([Class('divider')], [])にはしません。属性は必須なので、属性も子要素もない要素はdiv([])と記述します。keyedも同様です:h.keyed('li')(key, [attrs])とし、h.keyed('li')(key, [attrs], [])にはしません。子要素なしで使う場合は、h.keyed('textarea')(key, [h.Value(text)])とします。
  • **UrlRequestのタグはInternalとExternal**であり、InternalUrl/ExternalUrlではありません。
  • OnClickとOnSubmitにはMessageを直接渡します。() => Messageではありません。入力値が必要なため、(value) => Messageを受け取るのはOnInputだけです。
  • keyedとemptyは、ビューが最後の引数として受け取るビルダーhのプロパティです。foldkit/htmlのトップレベルエクスポートではありません。
  • 属性ヘルパーにはそれぞれ専用のものがあります:Value(...)、Type(...)、Placeholder(...)、Href(...)、Target(...)、Rel(...)、Rows(n)、Id(...)、For(...)、Role(...)、AriaLabel(...)です。汎用のAttr('...', '...')はありません。
  • **ApplicationInit<Model, Message, Flags>にはURL引数がありません。**ルーティングを使うアプリでは、RoutingApplicationInit<Model, Message, Flags>を使います。第2引数はurl: Urlです。
  • Route.mapToにはファクトリー関数ではなく、ルートスキーマを渡します。pipe(literal('new'), Route.mapTo(AppRoute.NewLink))とします。Route.mapTo(() => AppRoute.NewLink())ではありません。
  • Effect.ignoreは失敗する可能性のあるEffectにのみ使います。pushUrl(path).pipe(Effect.as(Message()))とします。pushUrlはEffect<void>を返すため、Effect.ignoreは使いません。
  • Command.defineにはmessages配列を含む設定オブジェクトを渡します:Command.define('Fetch', { messages: [Message.SucceededFetch, Message.FailedFetch], execute })です。messagesは必須で、Messageが1つだけの場合も常に配列にします:Command.define('ReadClock', { messages: [Message.RecordedTime], execute })です。
  • **makeRulesには{ required?: Rule.RuleMessage, rules: Array<Rule.Rule> }を渡します。ここでRule.Rule = [Predicate, Rule.RuleMessage]**です。これはタプルであり、{ test, message }ではありません。ルールのコンストラクターはRule名前空間にあります(Rule.url({ message })、Rule.email(message?)、Rule.minLength(n, message?)、Rule.pattern(regex, message?)、Rule.fromSchema(schema, message))。
  • **Field.Invalidにはerror: stringではなくerrors: NonEmptyArray<string>があります。**最初のメッセージを取得するにはArray.headNonEmpty(errors)を使い、ルールメッセージを最終的な文字列に解決するにはRule.resolveMessage(message, value)を使います。
  • ルートのバリアントはAppRouteに置き、重複するRouteの接尾辞は省きます。AppRoute.HomeとAppRoute.NewLinkと記述し、HomeRouteやNewLinkRouteという別個のバインディングは使いません。
  • ルーターはURLを生成するために呼び出せます:homeRouter()は'/'を返し、tagFilterRouter({ tag: 'foo' })は'/tag/foo'を返します。URLを手作業で組み立てないでください。
  • UIコンポーネントはfoldkitのUi名前空間ではなく、@foldkit/uiから取得します。import { Dialog, Input } from '@foldkit/ui'としてから、Dialog.view(...)を使います。foldkitパッケージにUiエクスポートはありません。
  • HttpClientとHttpClientRequestは@effect/platformではなく、effect/httpから取得します。foldkitからインポートしたHttpを使い、Effect.provide(effect, Http.layer)でクライアントをCommandのEffectに提供します。@effect/platform-browserは別のもので、BrowserKeyValueStoreとBrowserCryptoに使います。
  • **子の初期化結果または起動結果が1つの場合はUpdate.foldChildInitを使い、複数の子の結果を1つの親Modelにまとめる場合はUpdate.foldChildInitsを使います。**入力を受け取る子の更新にはUpdate.foldChildを、Modelのみを受け取る子ヘルパーにはUpdate.foldChildStepを使います。これらのfoldは、toParentMessageを介して子のCommandを持ち上げます。低レベルのヘルパーまたはルートで条件分岐する初期化に限り、Command.mapMessagesを直接使います。
  • **一度しか使わないupdateの戻り値の型はインラインで指定します。**マッチャーをそれだけで使う場合は、Message.match<Update.Return<Model, Message>>を使います。別のマッチャー、ヘルパー、またはエクスポートするシグネチャでもその型を再利用する場合は、UpdateReturnエイリアスを作成します。matchのジェネリック型がupdate全体を制約するため、重複する戻り値の注釈は省きます。ドメインのunionに対するmatchも、ハンドラー内で同様に、独自のmatchまたはmatchOrElseのジェネリック型を使って制約します(Submission.match<UpdateReturn>(submission, { ... }))。Match.withReturnType<UpdateReturn>()を使うのはEffectのMatchに限ります(Messageの部分一致、複数タグで共有するハンドラー、または独自のマッチャーを持たないunion)。
  • **通常の戻り値に対するOutMessageガードを維持します。**updateがOutMessageを出力できない場合はUpdate.Return<Model, Message>を使います。これにより、ModelとCommandsだけを残すコードにOutMessageを含む結果が渡るのを防ぎます。outMessageのない結果は、Update.ReturnWithOutMessage<Model, Message, OutMessage>が期待される場所でも引き続き使えます。フィールドがないことは、そのupdateがOutMessageを出力しなかったことを意味します。手書きの通常の戻り値の型でも、outMessage?: neverフィールドを維持してください。
  • **静的に空であるCommandのみ省略します。**Commandが静的に存在しないプロデューサーはcommandsを省略します。計算されたコレクションを返すプロデューサーは、空かどうかを確認せずにそのまま返します。commands: []とは絶対に書かないでください。
  • **updateのような結果はまとめて扱います。**それぞれの結果を操作名にちなんだ名前の値に束縛し、ドットアクセスを使います。操作名が関数と衝突する場合は、init_のように末尾にアンダースコアを付けます。model、commands、outMessageを分割代入したり、別の名前に変更したりしないでください。子のfoldのwriteパラメーターには、次の子Modelの名前(nextSettingsなど)を付けます。省略可能なCommandsはそのままCommand.mapMessagesに渡し、次の操作で具体的な配列が必要な場合に限りresult.commands ?? []を使います。ドットアクセスを使うと操作とその戻り値のフィールド全体を一緒に確認できますが、outMessageの無視を防ぐものではありません。
  • **updateコンビネーターは、それぞれが想定するパターン全体に使います。**後続のStepに先行するStepが生成したModelを渡す場合はUpdate.combineを使います。引数に取るStepは2つ以上です。Stepが1つだけの場合はUpdate.combineでラップせず、その操作を直接呼び出します。複数のStepを組み合わせる場合、インラインのStepパラメーター名はstepModelにします。新しい結果の構築時点でOutMessageが確定している場合は、それを直接含めます。既存の通常の戻り値、または型がOutMessage | undefinedの値にはUpdate.withOutMessageを使います。既存の戻り値はヘルパーにパイプで渡し、新しい結果リテラルは先頭の引数として渡します。子の初期化結果または起動結果が1つの場合はUpdate.foldChildInitを使い、複数の子の結果を1つの親Modelにまとめる場合はUpdate.foldChildInitsを使います。入力を受け取る子の更新にはUpdate.foldChildを、Modelのみを受け取る子ヘルパーにはUpdate.foldChildStepを使います。子のOutMessageのうち、少なくとも1つを現在のSubmodelの親に引き継ぐ場合に限り、toParentOutMessageを追加します。一部だけを転送する場合は、子のすべてのバリアントをマッチし、この場所で止めるバリアントにはundefinedを返します。すべてのバリアントがこの場所で止まる場合は、toParentOutMessageを省略します。foldOutMessageは、上位に引き継ぐバリアントも含め、各バリアントを引き続きローカルで処理します。toParentOutMessage: () => undefinedは絶対に追加しないでください。
  • Modelの配列を判定するには、述語ではなくArray.matchを使います。Array.isArrayEmptyとArray.isArrayNonEmpty(名前に注意してください。isEmptyArray/isNonEmptyArrayではありません)は可変のArray<A>を受け取るため、Schema.Array(...)がデコードするReadonlyArrayには使えず、コンパイルできません。Array.matchはReadonlyArrayを受け取り、実例でも使われています。
  • emptyとkeyedはhのプロパティなので、foldkit/htmlのインポート一覧には含めません。型をインポートし(import type { Document, Html, HtmlBuilder } from 'foldkit/html')、ビューのビルダーからh.empty/h.keyedを使います。

フェーズ 4:アプリを生成する

アーキテクチャと規約のガイドに厳密に従ってファイルを生成します。すべてのソースファイルを、スキャフォールド済みプロジェクトの src/ ディレクトリに書き込みます。各ファイルでは、次のルールに従います。

モデル
  • Effect Schema の型を使い、Schema.Struct として定義します
  • 状態には判別可能なユニオンを使います:Idle | Loading | Error | Ok。複数の値を取る状態にブール値を使ってはいけません
  • 存在しない場合があるフィールドには Option を使います。空文字列や null は使ってはいけません
  • Option 型のフィールドには maybeCurrentUser、maybeError のように maybe を接頭辞として付けます
  • リモートデータには、ユニオンを独自に定義せず、AsyncData モジュールを使います。AsyncData.Schema(WeatherData, Schema.String) は { schema, Idle, Loading, Refreshing, Failure, Stale, Success } を返します。schema を Model に含め、コンストラクターで値を作成します。Refreshing と Stale 状態が重要です。再読み込み中も現在のデータを画面に表示したままにし、再読み込みに失敗してもデータを破棄せずに保持できます。標準的な使い方は repos/foldkit/examples/weather/src/main.ts を参照してください。呼び出す前に AsyncData.match のシグネチャを確認してください。ハンドラーが受け取るのは値そのもので(onSuccess: data => ...、onFailure: error => ...)、onStale のみ { error, data } を受け取ります
  • リモートデータ以外の複数の値を取る状態(フォームのステップ、エディターのモード、接続フェーズ)には、ユニオン全体を defineTaggedUnion() で宣言します。conventions.md の「状態の判別可能なユニオン」を参照してください
  • 複数のモジュールで参照されるドメインエンティティが複数あるアプリでは、共通のスキーマを src/domain/ に切り出します(例:domain/product.ts、domain/session.ts)。このパターンについてはショッピングカートと認証の例を参照し、ドメインモジュールを構成するタイミングと方法については ${CLAUDE_SKILL_DIR}/../../packages/website/src/page/projectOrganization.ts をお読みください
メッセージ

Message ユニオンとその型をまとめて宣言します。

export const Message = defineMessageUnion({
  ClickedSubmit: {},
  UpdatedEmail: { value: Schema.String },
  SucceededLogin: { user: User },
  FailedLogin: { error: Schema.String },
  CompletedFocusInput: {},
})
export type Message = typeof Message.Type

defineMessageUnion() の宣言と type Message のエイリアスは隣接させます。Message.ClickedSubmit() や Message.UpdatedEmail({ value }) のように、名前空間を通じてバリアントを構築します。Message や OutMessage からコンストラクターを分割代入してはいけません。呼び出し箇所では常に所有元の名前空間が見えるようにします。

収まる場合は、各ケースのペイロードオブジェクトを 1 行に記述します。さらにスペースが必要なペイロードは Oxfmt に折り返しを任せ、宣言をバリアントごとに 1 行ずつ読みやすく保ちます。

メッセージはカテゴリに応じて命名します。

  • Clicked*:ボタンやリンクのクリック
  • Updated*:入力値の変更({ value: Schema.String } を伴うもの)およびサブスクリプションからの外部状態の更新(UpdatedRoom、UpdatedPlayerProgress)
  • Submitted*:フォームの送信
  • Succeeded* / Failed*:意味のある失敗が起こり得るコマンドのペア
  • Completed*:その他すべての Command の結果。Command の名前(動詞+目的語:CompletedFocusInput、CompletedGenerateCardId)に基づいて命名します
  • Got*:OutMessage パターンを介した子モジュールの結果
  • Pressed*:キーボード入力
  • Blurred*:フォーカスの喪失
  • Selected*:リストからの選択
  • Toggled*:二値状態の切り替え

すべてのメッセージに意味を持たせます。NoOp は使ってはいけません。

Flags(初期 Model に副作用が必要な場合)
  • 初期 Model が副作用から必要とするデータの Flags Schema を定義します
  • 値(localStorage の読み取り、現在時刻など)を計算する Effect<Flags> として flags を定義します
  • ブラウザーの新規起動時には、Runtime.run(application, { flags }) に flags を渡します。ハイドレーションされたアプリケーションでは Runtime.hydrate(application) を呼び出し、サーバーでエンコードされた Flags ペイロードのみを使います。@foldkit/vite-plugin は、クライアントとサーバーのビルドを連携させるため、共有のデプロイ ID を Foldkit にコンパイルします
  • 結果を init に渡します。モジュールレベルや init 内で副作用を実行してはいけません
  • 全体のパターンについては、architecture.md の Flags セクションを参照してください
初期化
  • Update.Return<Model, Message> レコードを返します。初期化で Commands を静的に作成しない場合は commands を省略し、計算された Commands コレクションを直接返します。
  • Flags を使用する場合は、最初のパラメーターとして受け取ります。例:(flags: Flags) => ({ model: initialModel(flags) }) または (flags: Flags, url: Url) => ({ model: initialModel(flags, url) })
  • 起動時の Commands(初回の取得、最初の入力欄へのフォーカスなど)を含めます。
  • 初期 Model には呼び出し可能な Schema コンストラクターを使用します:Model({ field: value })
更新
  • 戻り値の型がそのマッチャーでのみ現れる場合は、Message.match<Update.Return<Model, Message>>(message, {...}) を使用します。別のマッチャー、ヘルパー、またはエクスポートされたシグネチャがそれを再利用する場合は、UpdateReturn エイリアスを作成します。Update.ReturnWithOutMessage<Model, Message, OutMessage> は Submodel 側の対応物です。OutMessage は、それを所有する union の match を通じて網羅的にマッチさせます。ジェネリックな子が Schema に基づくペイロード型を絞り込む場合は、構造的に絞り込まれた OutMessage 型を 2 番目のジェネリックとして渡します。手書きの plain-return 型が等価になるのは、outMessage?: never を含む場合だけです。決して切り替えないでください。フォールバックを伴う部分マッチには、defineTaggedUnion または defineRouteUnion 名前空間の matchOrElse を使用します。Effect の Match は、部分的な Message のマッチ、複数のタグで共有されるハンドラー、独自のマッチャーを持たない union に使用します
  • update、init、boot、または component ヘルパーが静的に Commands を生成しない場合は、commands を省略します。空かどうかを確認せずに、計算された Commands コレクションを直接返します。リテラルの commands: [] は決して書かないでください
  • update、init、boot、または component ヘルパーの結果を合成する場合は、結果全体を操作にちなんだ名前の値に束縛し、ドットアクセスを使用します。名前が関数と衝突する場合は、init_ のような末尾のアンダースコアを使用します。model、commands、outMessage を分割代入したり名前を変更したりしないでください。子 fold の write パラメータには、次の子 Model にちなんだ名前を付けます。オプションの Commands は Command.mapMessages に直接渡し、result.commands ?? [] は次の操作が具体的な配列を必要とする場合にのみ使用します。ドットアクセスは操作とその返されるすべてのフィールドを一緒に見える状態に保ちますが、outMessage を無視することを防ぐものではありません。1 つの init または boot の結果には Update.foldChildInit を、複数の子の結果が 1 つの親 Model に入る場合は Update.foldChildInits を、入力を受け取る子の update には Update.foldChild を、Model のみを受け取る子ヘルパーには Update.foldChildStep を使用します
  • 後の Step が前の Step によって生成された Model を受け取るべき場合は、Update.combine を使用します。これは 2 つ以上の Step を取ります。1 つの Step を Update.combine で包まないでください。その操作を直接呼び出します。複数を合成する場合は、インラインの Step パラメータに stepModel という名前を付けます。新しい結果を構築する際に OutMessage がすでに判明している場合は、それを直接含めます。既存の plain return、または OutMessage | undefined 型の値には Update.withOutMessage を使用します。既存の return はヘルパーにパイプし、新しい結果リテラルは最初に渡します。toParentOutMessage を追加するのは、少なくとも 1 つの子 OutMessage が現在の Submodel の親へ継続すべき場合だけです。部分的な転送では、すべての子バリアントをマッチさせ、ここで停止するバリアントには undefined を返します。すべてのバリアントがここで停止する場合は toParentOutMessage を省略します。foldOutMessage は、上方向へ継続するバリアントも含め、各バリアントをローカルで処理します
  • イミュータブルな更新には modifyFields(model, { field: () => newValue }) を使用します
  • Succeeded* ハンドラーが複数のキャッシュを書き込み、再取得を開始しなければならない場合は、Update.combine(model, [step, step, ...]) でそれらを順序付けし、再取得ステップを Update.refresh({ read, revalidate, write, load }) で構築します。これは、実際にデータを保持している場合にのみキャッシュを再読み込みします。examples/route-transitions/src/main.ts に両方が示されています
  • modifyFields では、更新がそのフィールドの現在の値のみに依存する場合、ポイントフリーのフィールドトランスフォーマーを使用します: items: Array.map(updateItem)、count: Number.increment、priceSlider: Slider.reflectRange({ min: minPrice, max: maxPrice })。Messages、子の更新、Commands、または他の Model フィールドからの置換値には、() => value を使用します。
  • ケースが約 15 行を超える場合は、複雑なハンドラーを別の関数に抽出します
  • Submodel の場合は、Update.ReturnWithOutMessage<Model, Message, OutMessage> を返します。報告することが何もない場合は outMessage を省略します
  • OutMessage パターンについては architecture.md を参照してください。子モジュールは outMessage を通じて親にシグナルを送り、親は Update.foldChild、Update.foldChildInit、または Update.foldChildInits の foldOutMessage を通じてその値を処理します
コマンド
  • Command.define でコマンドの定義を作成します。第 2 引数には設定オブジェクトを渡します。args(省略可)で引数のスキーマを宣言し、messages にはコマンドが生成するすべてのメッセージを列挙し、execute にはエフェクトを指定します。引数がある場合の形式は Command.define('Fetch', { args: { id: Schema.String }, messages: [Message.SucceededFetch, Message.FailedFetch], execute: ({ id }) => Effect }) です。execute は定義時にバインドされ、引数を受け取ります。update は Fetch({ id }) を返します
  • コマンドを割り込み可能にするには、interrupt を追加します。interrupt: true を指定すると、各呼び出しはコマンド名でキー付けされます。interrupt: { keyFields, toKey } を指定すると、呼び出しを識別する引数を選択し、そのキーを導出することで、並行する呼び出しを個別にキャンセルできます。選択したフィールドは、Definition の Interrupt コンストラクターで必須の引数になります
  • 定義は必ず PascalCase の定数に割り当てます。パイプチェーン内に直接記述しないでください
  • 定義は生成される場所、つまり update 関数と同じ場所に配置します
  • 戻り値の型は TypeScript に推論させます。Command<typeof A> のような明示的な型注釈は付けません
  • 複数ステップの非同期処理には Effect.gen を使います
  • 失敗する可能性のある Effect には、必ず Effect.catch(() => Effect.succeed(Message.FailedX(...))) を使います。コマンドは例外をスローしません。例外: 型レベルで失敗しない Effect(Clock.currentTimeMillis、Random.nextIntBetween など)には、catch は不要で、Failed* メッセージも必要ありません。型に従ってください。エラーチャネルがなければ、捕捉するエラーもありません
  • サービスには Effect.provide を使います
  • ファクトリー関数にはアクションを表す名前を付けます。fetchWeatherCommand ではなく fetchWeather とします
  • 各コマンドの名前には、update がその結果を処理して後から起こす Model の遷移ではなく、execute の本体が実行するエフェクトを表す名前を付けます。update が閉じる処理を開始する前に待つだけのタイマーは、DismissAfter ではなく WaitBeforeDismissal です
  • 意味のある失敗が起こりえないコマンドは、そのコマンドにちなんだ名前の Completed* メッセージを返します。ペイロードを持つ場合も同様です。DetermineStartTime → DeterminedStartTime ではなく CompletedDetermineStartTime
  • DOM 操作には Foldkit の Dom モジュール(Dom.focus、Dom.scrollIntoView、Dom.showDialog、Dom.lockScroll など)を使い、それ以外には Effect 組み込み機能(Clock.currentTimeMillis、Random.nextIntBetween、Effect.sleep(Duration.millis(...)))を使います。UUID には、プラットフォームの Crypto レイヤーとともに Crypto.Crypto サービスの randomUUIDv4 を使います。architecture.md の「DOM and Effect Helpers」を参照してください
  • HTTP リクエストには effect/http の HttpClient と HttpClientRequest を使い、foldkit の Http を使って Effect.provide(effect, Http.layer) でクライアントを提供します。実装パターンについては examples/weather/src/main.ts を参照してください
  • 該当する場合は、Update.foldChildInit、Update.foldChildInits、Update.foldChild、または Update.foldChildStep によって、toParentMessage を通じて子 Submodel のコマンドを再タグ付けします。低レベルのヘルパーまたはルートで初期化を制限する場合に限り、Command.mapMessages を直接使います
フォームのバリデーション

アプリにバリデーションが必要なフォーム入力(必須項目、形式チェック、非同期の一意性チェック)がある場合は、foldkit/fieldValidationを使用してください。バリデーション状態を独自に実装しないでください。

import {
  Field,
  Invalid,
  NotValidated,
  Rule,
  Valid,
  Validating,
  allValid,
  makeRules,
  validate,
} from 'foldkit/fieldValidation'

const nameRules = makeRules({
  rules: [Rule.minLength(2, 'Name must be at least 2 characters')],
})

const emailRules = makeRules({
  required: 'Email is required',
  rules: [Rule.email('Please enter a valid email address')],
})

const Model = Schema.Struct({
  name: Field(Schema.String),
  email: Field(Schema.String),
  // ...
})

Field(valueSchema)はタグ付きユニオンNotValidated | Validating | Valid | Invalidを構築します。値のSchemaは、ユーザーが編集しているときにコントロールが実際に保持する値の型に合わせてください。パース後の型に合わせるのではありません。テキスト入力にはField(Schema.String)、複数選択にはField(Schema.Array(Schema.String))を使用します。チェックボックスのブール値は、バリデーションのライフサイクルが必要でない限り、通常はModel内でプレーンなSchema.Booleanのままにします。ルールはmakeRulesで個別に定義します。更新ハンドラーではvalidate(rules)(value)を使用してフィールドを遷移させ、allValid([[state, rules], ...])で送信を制御します。この関数は呼び出しごとに1つのフィールド値の型を制御するため、型をまたぐ場合は呼び出しを&&で結合してください。フィールドを任意にするには、makeRulesからrequiredを省略します。

参照実装:${CLAUDE_SKILL_DIR}/../../examples/form/src/main.ts(バージョンベースのキャンセルを用いた非同期のメールアドレス一意性チェック)および${CLAUDE_SKILL_DIR}/../../examples/job-application/src/step/(サブモデルにまたがる、バリデーション付きのマルチステップフォーム)。

日付とファイルのアップロード

日付を扱う場合(誕生日、締め切り、スケジュールなど):

  • Calendarモジュールを使用します:Calendar.CalendarDate、Calendar.today.local(ユーザーのタイムゾーンで今日の日付を返すEffect)、Calendar.make(year, month, day)、Calendar.addDaysなど。
  • UIには、@foldkit/uiのDatePicker(入力欄+ポップオーバーカレンダー)またはCalendar(インラインのグリッド)を使用します。
  • 必要に応じて、Flags経由で初期日付を設定します。FlagsのEffectでCalendar.today.localを使用するjob-applicationの例を参照してください。

ファイルをアップロードする場合(履歴書、画像、添付ファイルなど):

  • ファイルのプリミティブ型にはFileモジュールを使用します。
  • バリデーション付きのドラッグ&ドロップとクリックによるファイル選択領域には、@foldkit/uiのFileDropを使用します。
  • FileDrop.ReceivedFilesはfiles: NonEmptyArray<File>を保持するため、正常な処理経路で空のリストを扱う必要はありません。ファイルがない状態でドロップすると、別個のRejectedNonFiles OutMessageとして届きます。「ファイルのみ受け付けています」のようなメッセージを表示できるよう、これを処理してください。
  • 参照実装:${CLAUDE_SKILL_DIR}/../../examples/job-application/src/step/attachments/
View
  • 各ビューは、最後のパラメーターとして型付きの Html ビルダー h を受け取ります。ランタイムはルートビューに渡し、Submodel.defineView<Model, Message> は各 Submodel ビューに子自身のビルダーを渡します。アプリケーションコードでビルダーを生成することはありません。h.div、h.Class、h.OnClick のように、h から要素、属性、イベントハンドラーを使用します。子は自身の Message 型でディスパッチし、親は埋め込み箇所で toParentMessage を使ってラップ方法を宣言します。
  • 抽出したビューのヘルパーは、最後のパラメーターとして h: HtmlBuilder<Message> を受け取ります。呼び出し側はこれを引き回します。ビュー内では、必ずビュー自身の h を使用してください。ビルダーがスコープ内にない場合(通常はモジュールスコープ)に限り、foldkit/html の inertHtml を ih としてエイリアスして使用します(型は HtmlBuilder<never> のため、これを使ってハンドラーを作成することはできません)。
  • Tailwind クラスには h.Class(...) を使用します
  • 条件に応じたクラスの合成には、clsx パッケージの clsx を使用します。例:h.Class(clsx('base-classes', { 'active-class': isActive, 'bg-blue-500': variant === 'Primary' }))。クラスがモデルの状態、真偽値フラグ、または判別可能なユニオンタグに依存する場合は、必ず clsx を使用してください。文字列連結、テンプレートリテラル、&& 式は使用しないでください。
  • ユニオンを通じてモデルの状態をパターンマッチします:State.match(model.state, {...})
  • Option フィールドに基づく条件付きレンダリングには Option.match を使用します
  • 分岐にキーを付けないでください。ビュー関数は差分計算器のアイデンティティ境界です。vite プラグインは各関数の戻り値にブランドを付けるため、異なるビュー関数を通じて描画する分岐に切り替えると、サブツリーが置き換えられます。切り替え時に DOM の状態をリセットする必要がある場合は、同じタグを使うインライン三項演算子を名前付きビュー関数に切り出してください
  • 複雑なセクションは、抽出したビュー関数に委譲します
  • イベントを Messages に結び付けます:h.OnClick(Message.ClickedSubmit())(コールバックではなく、Message を直接指定)、h.OnInput(value => Message.UpdatedEmail({ value }))(値を Message に変換するコールバック)
  • インタラクションに合う場合は Foldkit UI コンポーネントを使用します(モーダルには Dialog、タブ付きコンテンツには Tabs など)
Runtime Wiring
  • ページを所有するアプリには Runtime.makeApplication を使用します。URL ルーティングを行うアプリには routing: { onUrlRequest, onUrlChange } を追加します。view は Document({ title, lang?, dir?, canonical?, ogUrl?, body })を返します。canonical はアドレスバーではなく、Model 内の型付きルートから導出します。ランタイムは title、lang、dir を適用します。canonical と ogUrl については、後の更新で値を省略すると、最初のクライアント書き込み前に記録された値に戻るか、ランタイムが作成した要素が削除されます。ogUrl を省略した場合は、明示的な canonical が使用されます
  • 所有していないページに埋め込むウィジェットには Runtime.makeElement を使用します。view は Html を返し、ランタイムはドキュメントの <head> や <html> 要素には触れません。routing 設定はありません
  • 全体的なパターンについては、architecture.md の「With and Without URL Routing」セクションを参照してください
  • ルーティングを行うプログラムには ClickedLink と ChangedUrl の Messages を含め、update 内で UrlRequest.Internal / UrlRequest.External を適切に処理します
  • ページを所有するアプリでは、必ず最後に Runtime.run(application) を呼び出します。ホストアプリケーションがプログラムのライフサイクルを制御する場合は、代わりに最後に Runtime.embed(element) を呼び出し、返されたハンドルをホストに渡します。ホスト側には repos/foldkit/examples/embedding/src/host.ts、ウィジェット側にはその main.ts を参考にしてください
  • makeApplication の結果を保持する変数名は application、makeElement の結果を保持する変数名は element にします
ルート (マルチページの場合)
  • 双方向パーサーを使用します: defineRouteUnion()、string()、int()、literal()、slash()、Route.mapTo()、Route.oneOf()
  • すべてのルートを defineRouteUnion({ Home: {}, NewLink: {}, NotFound: { path: Schema.String } }) で宣言し、AppRoute と名付けます。各バリアントはその名前空間に保ちます: AppRoute.Home。バリアントを分割代入したり、以前の HomeRoute サフィックスを繰り返したりしないでください。
  • Model または Schema が AppRoute の一部のみを受け入れる場合は、AppRoute.subset(['Home', 'Person']) を使用します。呼び出しで名前を指定したタグのみが含まれます。omit は存在しません。除外リストは、後から追加されたすべての Route を暗黙的に受け入れてしまうためです。モジュールが1つのバリアントの型を必要とする場合は、ユニオンのかたわらで type PersonRoute = typeof AppRoute.Person.Type をエクスポートします。
  • 各ルートは Router として構築します: const homeRouter = pipe(Route.root, Route.mapTo(AppRoute.Home))。Router は呼び出し可能です: homeRouter() は '/' を返し、tagFilterRouter({ tag: 'foo' }) は '/tag/foo' を返します。これが双方向パーサーの出力側です。
  • テンプレート文字列でパスを手作りしないでください。 Href('/') ではなく Href(homeRouter())。pushUrl('/new') ではなく pushUrl(newLinkRouter())。Href(`/tag/${encodeURIComponent(tagName)}`) ではなく Href(tagFilterRouter({ tag: tagName }))。Router がエンコーディングを処理し、URL の形を1か所に保つため、リファクタリングはすべての呼び出し箇所ではなく1つのファイルを変更するだけで済みます。
  • 各ルートは専用の view 関数を通してレンダリングします。切り替えは identity が処理するため、ルート分岐がキーになることはありません。1つの共有 view 関数がルートパラメーターをまたいで異なるエンティティをレンダリングする場合 (スラッグをまたぐ詳細ページ) にのみ、エンティティ id でキーを付けます。
  • プログラムによるナビゲーションには、Commands 内で foldkit/navigation の pushUrl を使用します。ClickedLink ハンドラーの Internal ケースでは、foldkit/url の urlToString(url) を使用します。url.pathname + search + hash から URL を手動で再構築しないでください。そのパスは ? プレフィックスとハッシュを暗黙的に落としてしまいます。
  • ClickedLink ハンドラーでは、model.route を事前更新しないでください。ランタイムは pushUrl の解決後に ChangedUrl を発火し、それによってルートが更新されます。事前更新は二重書き込みを引き起こします。
サブスクリプション (リアルタイムの場合)
  • Subscription.make<Model, Message>()(entry => ({ key: entry(fields, callbacks) })) で定義します。ビルダーコールバックは entry(fields, callbacks) ヘルパーを受け取ります。fields は素のフィールドマップ (Schema.Struct でラップしないもの) で、callbacks は modelToDependencies、dependenciesToStream、および省略可能な equivalence を持ちます
  • modelToDependencies は Model から Subscription のパラメーターを抽出します
  • dependenciesToStream は依存関係から Stream<Message> を構築します
  • サブスクリプションは Model の状態に基づいて自動的に開始/停止します。手動で管理されることはありません
  • Model の依存関係がない (常にアクティブな) サブスクリプションの場合、entry の fields 引数に {} を渡し、modelToDependencies から {} を返します
  • 子サブスクリプションを埋め込むには、Subscription.lift(childRecord)<Parent, Parent>({ read, toParentMessage }) を使用します。その read は Option<ChildModel> を返し、Update.foldChild および ManagedResource.lift と一致します。None は子の依存関係を読むことなく、すべての子 Stream を停止します。常に存在する子には Option.some を使用します。リフトされた各エントリは、その依存関係を GatedDependencies でラップします。子が見ることのできない親の事実 (ページ Submodel が背後に位置するルート) でゲートするには、親の lift 呼び出しに when を追加します。ゲートは親が所有し、親 Model を読み取り、閉じたゲートはそのエントリの Stream を破棄します。when: parentModel => boolean はすべてのエントリをゲートし、when: { entryName: parentModel => boolean } は名前を指定したエントリにのみ条件を追加します。子の不在は依然としてすべてのエントリを停止するため、子が親のゲーティングに合わせて自身のレコードを分割することはありません。複数のレコードを結合するには、Subscription.aggregate(...records) を使用します。これはレコードから Model、Message、および任意の Effect サービスを読み取ります
管理対象リソース (ステートフルランタイムが処理する場合)
  • ManagedResource.make<Model, Message>()(entry => ({ key: entry(requirementsSchema, config) })) で定義します。requirementsSchema は位置引数の第1引数 (通常は Schema.Option(...)) です。config は resource タグ、modelToMaybeRequirements、acquire/release Effect、および onAcquired/onReleased/onAcquireError Message を保持します
  • modelToMaybeRequirements は、取得する (またはパラメータが変更されたときに再取得する) 場合は Option.some(params) を返し、解放する場合は Option.none() を返します。リソースは Subscription と同様に、Model の状態に応じて自動的に取得/解放されます
  • パラメータのないリソースには Schema.Option(Schema.Null) を使用し、Option.some(null) を返します
  • サービスの union は ManagedResource.ServicesOf<typeof managedResources> で読み取ります
  • 子 Submodel のリソースを組み込むには、ManagedResource.lift(childRecord)<Parent, Parent>({ read, toParentMessage }) を使用します (その read は Option<ChildModel> を返すため、リフトされる requirements は Schema.Option でラップする必要があります)。レコードは ManagedResource.aggregate(...records) で結合します。これはレコードから Model と Message を読み取ります
  • アプリのライフタイムにわたるハンドルは resources に置き、ここには置きません。persistent は存在しません

Phase 4.5: 検証前のセルフチェック

tsc を実行したりブラウザを開いたりする前に、生成されたファイルに対して簡単な機械的チェックを行います。Phase 6 のレビュアーはこれらを検出しますが、書き込み時に検出するほうが、レビューラウンドを一巡した後に検出するよりも安上がりです。これを省略すると、回避可能な項目で Round 1 のレビューノイズを膨らませることになります。

まず lint スクリプトを実行し、次に checklist.md の「Mechanical scans」ブロックを src/ に対して実行します。スキャフォールドは @foldkit/oxlint-plugin を組み込んでおり、そのルールが構造的チェックです: keyed 行、ルートの printing、空オブジェクトの tagged 呼び出し、外部リンクの Rel、modifyFields 内の spread、Got* のラップ、Command の命名。すべての foldkit(...) 診断をブロッカーとして扱ってください。出力ではルールがそのようにラベル付けされています。その後 grep が、どのルールも触れない領域 (API ドリフト、@foldkit/ui の採用、Effect のイディオム、対になっていないラベル、非 Option に対する maybe*、span([]) プレースホルダー、冗長な Effect.ignore、フォーカスアウトラインのリセット) に加えて、範囲の狭いルールの意図的なエッジケース (名前空間を介した空オブジェクト呼び出し、spread 属性配列内の _blank、.id 以外をキーにしたマップされた行、modifyFields の updates オブジェクト自体の spread、コンストラクタの戻り値への as キャスト — これはスキャフォールドの oxlint 設定だけが拒否します) をカバーします。lint がグリーンでも、これらはクリアされません。各ヒットは修正するか、// NOTE: による正当化のいずれかです。

次に、書いた各ファイルを目視で確認します:

  • 各ファイルのインポート: 実際にすべてのシンボルを使用していますか? (Lint がこれを検出しますが、ここで検出すれば後での lint ラウンドを回避できます。)
  • Union 内のすべての Message: update に対応するケースがありますか? view がそれを dispatch していますか?
  • すべての状態バリアント: それは実際に遷移されることがありますか? アクティブなときに view は何か異なるものをレンダリングしますか?
  • すべての Command: それはテストされていますか? その Succeeded* または Completed* Message に update でのハンドラーがありますか?

これはファイルあたり約2分の読解です。未解決の項目あたり約15分のレビューループを節約できます。

Phase 5: 検証とテスト

ゲート:フェーズ 5 の完了を宣言する前に、4 つのコマンドすべてを成功させてください

フェーズ 6 に進む前に、以下の 4 つをすべて実行し、検出された問題をすべて修正してください。1 つでも 3 つでもなく、4 つすべてです。

npm run format      # run FIRST: rewrites files
npm run lint
npm run typecheck
npm run test

プロジェクトのスキャフォールディングに使用されたパッケージマネージャーを使ってください:npm run、pnpm run、yarn、または bun run。スクリプトがない場合は、そのパッケージマネージャーの exec 経由でプロジェクトローカルのバイナリを実行してください(pnpm exec oxfmt、npm exec --no-install oxfmt、yarn exec oxfmt、bun x --no-install oxfmt)。バイナリがローカルにインストールされていない場合に、レジストリからパッケージを取得して実行する素の npx は避けてください。

ファイルを書き換えるため、最初にフォーマットを実行してください。最後に実行すると、未フォーマットのコードに対して tsc/テストが成功した後、コミット前フックによってコードが再フォーマットされ、ユーザーが整理しなければならない差分が生じます。最初に実行すれば、lint/型チェック/テストでコミットされるコードそのものを検証できます。

それぞれ異なる種類の問題を検出します。

  • フォーマットは、空白、インデント、末尾のカンマ、行の折り返しをプロジェクトのスタイルに合わせて書き換えます。「チェック」ではなく、正規化の処理です。生成されたコードが偶然 Oxfmt の厳密なフォーマットに一致することはほとんどありません。この手順を省略すると、git commit のたびにフォーマットだけの差分が大量に発生します。
  • Lintは、未使用のインポート、未使用の変数、スタイルルール違反を検出します。生成されたコードでは「念のため」インポートしたシンボルが実際には参照されないことがあり(例:NotValidated、Invalid を fieldValidation からインポートしているが、Match.tag キー内の文字列リテラルとしてしか使っていない場合)、見落としやすい問題です。tsc では検出されません。
  • 型チェックは、API の誤用、パラメーターの形状の誤り、必須 props の不足、構造上の型エラーを検出します。未使用のインポートは検出しません。
  • テストは、動作上のリグレッションを検出します。上記のどちらの問題も検出しません。

プロジェクトに format/lint スクリプトがない場合は、package.json を確認し、パッケージマネージャーの exec 経由でバイナリを直接実行してください(pnpm exec oxfmt / pnpm exec oxlint src、または npm/yarn/bun の同等のコマンド)。「スクリプトがない」ことを理由に、どちらも省略しないでください。スキャフォールディングされた create-foldkit-app プロジェクトには、常に両方が設定済みで含まれています。

フェーズ 5 の完了を宣言する前に、4 つすべての出力をすべて修正してください。「型チェックは問題なし、テストも成功」だけでは不十分です。未使用のインポートが残った未フォーマットのコードは、合格水準に達していません。

型エラーを最初に修正する

続いて、foldkit/test を使ってテストを生成します。テストのスタイルは 2 種類あります。テスト対象のコードの隣に、それぞれのスタイルに合わせた名前でテストファイルを作成します。Story テスト(状態マシンを対象に update を実行するテスト)には story.test.ts、Scene テスト(レンダリングされたビューを対象にするテスト)には scene.test.ts を使います。名前が表すのはテストの動作であってソースファイルではないため、update と view が main.ts にある場合も、それぞれ別のファイルにある場合も変わりません。1 つのフォルダーに同じ種類のテストが複数ある場合(兄弟ページ、コンポーネントのバリエーションなど)は、対象を先頭に付けます。例:login.story.test.ts。Scene はどの階層でも実行できます。Submodel.defineView は通常の (model, h) => Html を生成するため、ページのビューを変更せずに scene に渡せます。また、ViewInputs を宣言する Submodel は第 2 引数を取ります。この引数はテストから withViewInputs(view, defaults) で渡し、その戻り値のファクトリーで、toView 以外のすべてに対するテストごとの上書きを指定します。ページレベルの Scene では、expectOutMessage / expectNoOutMessage で Submodel が出力する OutMessage を検証し、ビュー以外のライフサイクル要因で発生する Messages は、原因名を付けたステップで実行します。Subscription.emit(Message に DOM から操作する手段がない場合のみ。操作手段がある場合は実際のボタンをクリックします)、ManagedResource.acquire / release / failAcquire(エントリーの modelToMaybeRequirements によって制御されるため、先に Model の遷移を実行します)、および CustomElement.emit(spec の event Schemas で型付けされます)。ハンドラーが Option.none() を返すインタラクションでは、イベントがそのまま通過します。この動作を必ず明示してください。expectIgnored() を続けて記述しないと、Scene は次のインタラクション時またはシーンの終了時に失敗します。イベントが処理されるべき場合は expectHandled() を使います。これは、キーが消費されてブラウザーのデフォルト動作が抑止されることの検証にもなります。ページが所有する動作にはページフォルダーに scene.test.ts を置き、ページをまたぐフローにはルートレベルの scene.test.ts を使います。後者では、親が OutMessage をどのように処理するか、親が持ち上げる Command、ルート変更、および親が計算するビュー入力をカバーします。

ステップは foldkit/story または foldkit/scene から名前付きインポートとして読み込みます。import { Command, given, message, model, story } from 'foldkit/story'。テストファイルで必要なのはどちらか一方のモジュールだけなので、呼び出し箇所を簡潔に保てます。1 つのファイルで Story と Scene の両方をテストするまれなケースでは、代わりに名前空間をインポートし、import { Scene, Story } from 'foldkit' のようにします。これにより、Story.given と Scene.given を区別できます。

Story テスト(story.test.ts)では、update 関数を直接テストします。Messages を送信し、Model と Commands を検証します。次の例を参考にしてください。

  • ${CLAUDE_SKILL_DIR}/../../examples/weather/src/story.test.ts:シンプルな Command の解決(成功パス+エラーパス)
  • ${CLAUDE_SKILL_DIR}/../../examples/auth/src/page/loggedOut/page/login.story.test.ts:OutMessage の検証とフィールドの検証を行う Submodel
  • ${CLAUDE_SKILL_DIR}/../../packages/website/src/search/story.test.ts:複数ステップのインタラクション(矢印キーによる循環、古い結果の処理)

次の項目を網羅する story パイプラインを作成してください。

  • 成功パス:開始から終了までの主要なユーザーフロー
  • エラーパス:失敗する可能性のあるすべての Command を、それぞれの Failed* Message で解決する
  • 複数ステップのインタラクション:複数の Messages と Command の解決を連鎖させるテストを少なくとも 1 つ
  • エッジケース:空の状態、境界条件、無視される入力(古い結果、重複送信など)

リゾルバーを作成する前に、@foldkit/ui コンポーネントの test 名前空間を確認してください。たとえば、新たに表示され、自動的に閉じるトーストには Toast.test.drainEntry({ entryId }) を使います。

Scene テスト(scene.test.ts)では、レンダリングされたビューを通してテストします。アクセシブルなロケーター(role、label、text)で要素を操作し、ユーザーから見える内容を検証します。Tier 3 以上のアプリでは scene.test.ts が必須です。レビューのループでは、ファイルがないことを QUALITY 項目ではなく BLOCKER として扱います。例外はありません。「後回し」にしないでください。次の例を参考にしてください。

  • ${CLAUDE_SKILL_DIR}/../../examples/weather/src/scene.test.ts: フォーム操作と Command 解決を伴う基本的な Scene フロー
  • ${CLAUDE_SKILL_DIR}/../../examples/auth/src/scene.test.ts: マルチページアプリのルートレベルの Scene が、ルートビューを通じてログインフローを駆動する
  • ${CLAUDE_SKILL_DIR}/../../examples/auth/src/page/loggedOut/page/login.scene.test.ts: 同じアプリ内のページスコープの Scene で、ルートのペアではなくページ自身の update/view を駆動する
  • ${CLAUDE_SKILL_DIR}/../../examples/kanban/src/scene.test.ts: within によるスコープ付きクエリ、toHaveValue、明示的なテストデータ

以下を網羅する scene パイプラインを書いてください:

  • ビューのレンダリング: 初期ビューに期待される要素(見出し、入力欄、ボタン)がある
  • ユーザー操作: click、type、submit が目に見える変化を生む
  • ローディング状態: 送信するとローディングインジケーターが表示される
  • エラー状態: 失敗した Command がビューにエラーメッセージを表示する
  • スコープ付きクエリ: within(parent, child) を使って単一のスコープ付き Locator を構成します(単発のスコープ付きアサーションや再利用可能な名前付き Locator に適しています)。inside(parent, ...steps) を使ってステップのブロック全体を同じ親にスコープします。ネストされたステップが参照するすべての Locator は、親のサブツリー内で解決されます。2 つ以上のステップがスコープを共有する場合は inside を、単一用途のスコープには within を使ってください。
  • アクセシブルな Locator を優先する: placeholder(...) や CSS セレクターよりも label(...)、role(...)、text(...)

プロジェクトのテストスクリプト(npm run test、またはお使いのパッケージマネージャーの同等のコマンド)を実行して、テストが通ることを確認してください。

次に verification checklist を確認して、構造上の不足を洗い出してください。先に進む前に、残っている問題を修正してください。

Phase 5.5: ビジュアルと a11y の検証

コードレビューと自動テストでは、レンダリングのバグやアクセシビリティの不足は捉えられません。Phase 6 の前にやるべきことが 2 つあります:

ビジュアルチェック

開発サーバー(npm run dev)を起動し、ブラウザでアプリを開いてください。すべてのルートをクリックして回ってください。すべてのフォームを操作してください。以下に注意してください:

  • 背景が欠けている入力欄(Tailwind preflight はブラウザデフォルトの白を除去するため、bg-white はすべての生の input または textarea に明示する必要があります。つまり、@foldkit/ui の Input や Textarea を経由しないものすべてです)
  • 暗すぎたり小さすぎたりして読めないテキスト
  • 要素の重なり、崩れた余白、レイアウトシフト
  • しっくりこないカーソル/ホバー状態
  • インタラクティブな要素で見えない、または欠けているフォーカスリング

多くのビジュアルバグは、型チェック、テスト、コードレビューでは見えません。それらを捉えられる唯一の手段は、(1) レンダリング結果を実際に見ること、(2) すでに適切なデフォルトが組み込まれている @foldkit/ui コンポーネントを使うことです。

アプリに UI がある場合、アプリを読み込んでクリックして回るまで Phase 5 が完了したと主張しないでください。 環境でブラウザを実行できない場合は、チェックを黙ってスキップするのではなく、最終レポートでその旨を明示してください。

A11y チェック

checklist.md の Accessibility と Foldkit UI のセクションを確認してください。どちらにも機械的な grep コマンドがあります。それらを src/ に対して実行してください。ここに再掲しないでください。チェックリストがこれらの grep の正となる参照であり、複数のファイルに重複させるとずれが生じやすくなります。

A11y の項目は「あると嬉しい」ものではありません。視覚に頼らないユーザーにとっての正しさです。

Phase 6: サブエージェントレビューループ

セルフレビューは、新鮮な目によるレビューより弱いものです。Phase 5 が通ったら、生成されたコードを品質基準に照らしてレビューするサブエージェントを立ち上げ、承認が得られるまで反復してください。

ループの仕組み

最大 3 ラウンド 実行します。各ラウンドでは:

  1. Agent ツールを subagent_type: general-purpose で使用して、レビュー用サブエージェントを起動します。
  2. サブエージェントの応答を読み取ります。
  3. 応答が PASS の場合、ループを終了してフェーズ 7 に進みます。
  4. 応答に BLOCKERS または QUALITY の項目が含まれている場合は、それぞれを修正してからループします。
  5. NICE-TO-HAVE の項目は、ラウンドの予算を使い切った場合、フェーズ 7 の今後の作業リストに先送りできます。

ラウンド 3 の後も問題が残っている場合は、ループを終了し、未解決の項目を「既知の改善領域」としてフェーズ 7 に引き継ぎます。指摘されたコードを黙って出荷しないでください。何がまだ未解決なのかを明示してください。

レビュー用サブエージェントのプロンプト

おおよそ次のような形のプロンプトを使用します。ファイルリストとプロジェクトパスのみを調整してください。ルーブリック、出力形式、盲点チェックリスト、基準設定の指示はそのまま維持します。

You are reviewing a freshly generated Foldkit program. The bar is:
this code should be indistinguishable in quality from hand-written
code in `packages/typing-game/client/src/` or `packages/website/src/`.
Not "works." Not "structurally valid." Typing-game quality.

Read FIRST, in this order:
1. The generated source files, project-relative: list every one
   (src/main.ts, src/update.ts, ...).
2. ${CLAUDE_SKILL_DIR}/architecture.md
3. ${CLAUDE_SKILL_DIR}/conventions.md
4. ${CLAUDE_SKILL_DIR}/checklist.md
5. ${CLAUDE_SKILL_DIR}/blindSpots.md
6. At least one exemplar file matching the generated app's complexity,
   from the foldkit repo (vendored at repos/foldkit/ when the project
   has the subtree):
   - Tier 1-2: packages/foldkit/src/runtime/runtime.ts (quality calibration)
   - Tier 3-4: examples/weather/src/main.ts OR examples/form/src/main.ts
   - Tier 5: examples/kanban/src/update.ts AND examples/kanban/src/domain/
   - Tier 6: examples/job-application/src/update.ts AND examples/auth/src/page/
   - Tier 7: packages/typing-game/client/src/page/room/

Every path above is either project-relative or resolved through
${CLAUDE_SKILL_DIR}, the same way the rest of this skill refers to its
own files. Do not rewrite them as absolute paths. An absolute path is
machine-specific, and hand-pasting one is what made an earlier version
of this prompt unusable anywhere but the machine it was written on.

Then walk the entire checklist.md against the generated code. Every item.
Then walk every entry in blindSpots.md and report one line per entry:
`<slug>: clean | flagged at <file:line>: <issue>`. Silence is not a pass.
Also read at least one of the generated files side-by-side with the
exemplar you chose. Ask: does this look like it was written by the
same hand?

Output format. Exactly this structure:

## BLOCKERS
Items that are structurally wrong, logically buggy, or violate
conventions. Must fix.
Each item: `path/to/file.ts:line: what's wrong; what to fix`.
If none: write `None.`

## QUALITY
Items that work but fall short of the bar: generic naming, inline
handlers that should be extracted, missing domain/ directory,
native methods instead of Effect modules in pipes, views that
should be decomposed, etc. These should be fixed.
Each item: `path/to/file.ts:line: the gap; the idiomatic version`.
Cite the exemplar when possible: "typing-game does this as X at
page/home/update/handleKeyPressed.ts:33-40".
If none: write `None.`

## NICE-TO-HAVE
Polish items that would push quality further but aren't required:
additional tests, slightly better names, minor refactors.
If none: write `None.`

## VERDICT
One of:
- `PASS`: the code is at the bar. Ship it.
- `NEEDS-WORK`: there are BLOCKERS or QUALITY items to address.

Do NOT write code. Review only. Be specific, be brutal, don't grade on
a curve. If you're unsure whether something is at the bar, compare it
to the exemplar. If the exemplar wouldn't write it that way, flag it.

Before finishing, confirm the generator ran all four gates (format,
lint, typecheck, test) and showed the output of each. If it claims
Phase 5 complete but a gate's output wasn't shown, flag it as a
BLOCKER naming the gate: "Run `npm run lint`: unverified." Lint
catches unused imports that tsc does not, and those leak into review
as noise.
ループの後
  • いずれかのラウンドで判定が PASS の場合 → ただし書きなしでフェーズ 7 に進みます。
  • ラウンド 3 の後に判定が NEEDS-WORK の場合 → フェーズ 7 に進みますが、未解決の BLOCKERS と QUALITY の項目を Explain 出力の「既知の改善領域」に記載します。ユーザーがそれらを確認できるようにする必要があります。
ラウンド間: acknowledgment だけでなく実際に修正する

ラウンド間では、ジェネレーターはレビュアーが指摘したすべての BLOCKER とすべての QUALITY 項目に対して実際に修正を適用しなければなりません (MUST)。ラウンドの予算を使い切っていない限り、「これはメモしておきます」や「先送りします」といった形で項目を持ち越さないでください。レビュアーがラウンド N で指摘した QUALITY 項目がラウンド N+1 でもまだ存在しているのは、プロセスの失敗です。それは、ジェネレーターがレビューを読んだ後に行動しなかったことを意味します。

よくある失敗パターン: レビュアーが .length > 0 チェックを QUALITY として指摘し、修正として Array.match/String.isNonEmpty を挙げます。ジェネレーターは他の修正に移り、ラウンド 2 のレビューで同じことが再び指摘されますが、「ラウンド 2 は BLOCKERS に関してクリーンなので問題ない」として何も起こりません。それは問題ありません。トリアージされていない QUALITY 項目は、黙って出荷される腐敗になります。

ラウンド N+1 を実行する前に、「ラウンド N が指摘した内容」と「私が変更した内容」の短い差分を書面で作成してください。リストが一致しない場合は、ラウンド N+1 を実行する前に戻ってギャップを修正してください。

フェーズ 7: Explain

プログラムを生成した後 (およびレビューに合格した後)、構築した内容をユーザーに説明します:

  1. 生成されたファイル: 各ファイルを、その内容と、なぜ別ファイルとして存在するのか (またはなぜすべてが 1 つのファイルに入っているのか) の 1 行の説明とともに列挙します
  2. アーキテクチャ上の決定: 主要なモデリング上の選択を説明します。たとえば、どの判別共用体が使われたかとその理由、どの Foldkit UI コンポーネントが統合されたか、なぜ Flags が必要だったか (または必要なかったか)、ドメイン抽出の決定など
  3. レビュー結果: 何ラウンドのレビューを実行し、最終的な判定はどうだったかを述べます。PASS の場合はそう言います。ラウンド 3 の後に NEEDS-WORK の場合は、未解決の項目を「既知の改善領域」にそのまま列挙し、レビュアーが指摘したが修正されなかったものをユーザーが把握できるようにします。
  4. 実行方法: 開発サーバーを起動するようユーザーに促し、何が見えるべきかを伝えます
  5. 拡張方法: 具体的な次のステップを示します: 「ブックマーク編集を追加するには、ClickedEditBookmark と UpdatedEditTitle のメッセージを定義し、Model に Editing バリアントを追加し、update で両方を処理します」
  6. 再構築のタイミング: プログラムが現在のファイル構成を超えて成長したことを示すシグナルに言及します (例: 「update が約 20 ケースを超えたら、サブモデルの抽出を検討してください」)

コメント