Communitygithub.com

create-canvas-kit:キャンバスアプリ作成スキル

AIコーディングエージェント(GitHub Copilot、Claude、Codex)向けのクロスエージェントスキルプラグイン。skills CLI(`npx skills add`)でインストール可能。Copilotキャンバス拡張機能を構築するための「create-canvas-kit」を含みます。

create-canvas-kit:キャンバスアプリ作成スキル とは?

このスキルは、AIコーディングエージェント(GitHub Copilot、Claude、Codex)で利用可能なクロスエージェントスキル「jongio/skills」の一部である、create-canvas-kitを提供します。create-canvas-kitは、Copilot Appのキャンバス拡張機能を構築するための「バッテリー同梱」の方法です。従来の手作業によるキャンバス実装でよくある問題(innerHTMLの再描画ループによるキー入力の損失、状態が一つのパネルにしか存在しない、アドホックなスタイリングがホストテーマを無視する、アイコンが一貫しない)を解決します。Preact + htmをベースに、DOMの差分更新によるフォーカスや入力維持、エージェントとUIで共有されるアクションハンドラ、自動ライブ更新(SSE)、GitHub公式のLucideアイコンセット、Primerテーマトークンに基づくテーマシステムを提供します。このスキルは`npx skills add jongio/skills`で簡単にインストールでき、どの対応エージェントでも使用できます。

対応✓Claude Code✓Codex CLI~Cursor
npx skills add jongio/skills

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

Create Canvas App

Copilot App キャンバス拡張機能を構築するための、バッテリー同梱型の方法です。 手作業でキャンバスを作成すると、常に同じ壁にぶつかります。innerHTML による再描画ループでキー入力が失われる、状態が一つのパネルにしか存在しない、アドホックなスタイリングがホストテーマを無視する、アイコンが一貫しない、といった問題です。このキットはこれら四つすべてを修正します。

キャンバスとは(そして、いつ構築するか)

キャンバスとは、エージェントが open_canvas でサイドパネルに開くインタラクティブなサーフェスです。エージェントとユーザーの両方が、同じアクションハンドラを通じて同じ状態に対して操作します。チャットテキストや差分だけでは不十分な場合にキャンバスを活用してください。ライブダッシュボード、エディタ、スプレッドシート、トラッカー、かんばん/ボードビュー、ドキュメントプレビュー、ツール固有のワークフローなどです。

ユーザーが非視覚的なエージェントツールだけを必要とする場合は、代わりに通常の拡張ツールを構築してください。キャンバスではありません。

レンダリング階層 — 最初に決定する

階層使用する場合方法
静的HTML文字列読み取り専用またはほぼ静的なコンテンツ。保護する入力がない場合ランタイムのバニラスキャフォールド(extensions_manage scaffold kind:canvas)
Preact + htm + このキットインタラクティブなものすべて:入力、ライブ更新、リスト、フォーム、共有状態このキット。本番キャンバスでのデフォルトの選択肢。

このキットを使用する最も重要な理由:Preact がDOMを差分更新するため、エージェントからのライブ状態プッシュがフォーカス、キャレット位置、または入力中に半分入力されたテキストを上書きしません。初期のキャンバスのほとんどが使用する innerHTML = ... パターンは、ツリー全体を再描画し、プッシュのたびにキー入力を失います。そうしないでください。

モデル(コーディング前にこれを読むこと)

extension.mjs   ── Copilot SDK をインポートする**唯一の**ファイル(薄いアダプター)
canvas.mjs      ── あなたのキャンバス:id、スキーマ、状態のロード/セーブ、アクションハンドラ(SDK不要)
canvas-kit/     ── キット(そのままコピー、編集しない)
web/index.html  ── シェル:/kit/theme.css と ./app.mjs をロード
web/app.mjs     ── あなたのPreactビュー
  • 状態は共有され、永続化されます。 状態はインスタンスIDではなく、オープン入力から解決されたドメインID(resolveDomainId)によってキー設定されます。同じドメインを二つのパネルで開くと、同じデータが表示されます。永続化は userStore(extName, file) → $COPILOT_HOME/extensions/<name>/artifacts/<domain>.json を通じて行われます。
  • エージェントとUIはハンドラを共有します。 エージェントによって呼び出されたアクションと、ボタンから呼び出された同じアクションは、同一のハンドラを実行し、同一の状態変更を生成します。ロジックは一度だけ canvas.mjs に記述してください。
  • ライブ更新は自動です。 キットは GET /state、GET /events(SSE)、POST /action を提供します。mountCanvas がそれらを配線し、すべての状態変更はすべてのオープンパネルにファンアウトされます。
  • server.mjs はSDK不要であるため、ランタイム全体を通常のNode HTTPでテストできます(test/http.test.mjs を参照)。SDK呼び出しは extension.mjs のみに保持してください。

ビュー内の二種類の状態 — 分離して保持する

  • 共有ドメイン状態はSSE経由で到着し、プッシュのたびに再レンダリングされます。ビュー内では読み取り専用として扱い、invoke(action, input) を呼び出すことによってのみ変更してください。
  • ローカルUI状態(ドラフト入力値、アクティブタブ/フィルター)は useState に保持し、ユーザーがコミットするまでサーバーにプッシュしてはいけません。

アクションの結果とエラー — 失敗の表面化方法

呼び出し元(エージェントとUIの両方)は同じハンドラにアクセスし、ランタイムはすべての呼び出しを同じエンベロープでラップするため、意図的に障害をモデル化します。

  • 成功の場合は、小さなプレーンオブジェクト({ id, status }、{ count })を返します。POST /action は { ok: true, result } で応答します。
  • 深刻な障害(不正な入力、IDの欠落)の場合はスローします。ランタイムは { ok: false, code, message } で応答します。既知のキットエラー(例:不明なアクション)の場合はHTTP 400、ハンドラの throw の場合は500 です。そして mountCanvas の invoke は CanvasActionError を拒否し、その code/message を伝えるため、ボタンハンドラは try/catch で処理できます。エージェント側では、extension.mjs が同じエラーを CanvasError にマッピングします。スローは表面化され、決してクラッシュではありません。
  • 予期される一時的な障害(バックグラウンドポーリングでの信頼性の低い上流フェッチ)の場合は、スローせず、state.error にキャプチャして返します。これにより、ポーリングが静かに行われている間、エラーカードが表示されます(データテンプレートの refresh を参照)。

アイコン — 常に公式GitHub Lucide

キットはgithub-appが出荷するものとまったく同じLucideセット([email protected]、バイト単位で同一)をベンダー提供します。すべてのアイコンにこれを使用し、手書きのSVGを書いたりCDNをプルしたりしないでください。

import { Icon } from "/kit/client.mjs";
html`<${Icon} name="circle-check" size=${16} />`

契約:名前はケバブケース(circle-check、trash-2、list-todo)です。viewBox は固定で 0 0 24 24、stroke-width は 2 です — 決して strokeWidth を渡さないでください。サイズは 12 · 14 · 16 · 20 · 24 · 32 を使用します。明らかなマッピングがある場合は、github-app自身のグリフ選択に合わせてください(例:open=circle-dot、done=circle-check、bookmark=bookmark)。検証が必要な場合は iconNames() / hasIcon(name) が利用可能です。

テーマ — Primerトークン、フォールバック付き

/kit/theme.css をリンクし、その ck-* クラス(ck-card、ck-btn、ck-btn-primary、ck-input、ck-badge、ck-tabs、ck-status、ck-empty、…)の上に構築します。これらはランタイムがキャンバスドキュメントに注入するPrimerトークンにマッピングされ、それぞれにハードコードされたGitHubダークフォールバックがあるため、トークンが欠落していてもキャンバスは正しく表示されます。その上にキャンバス固有のCSSを追加します。プリミティブのスタイルを変更しないでください。

バッジは汎用的です。 意味論的バリアント ck-badge-success、ck-badge-accent、ck-badge-muted、ck-badge-danger、ck-badge-attention を使用し、あなたのステータスをビューでそれらにマッピングします(リファレンスは open → success, decided → accent, parked → muted を行います)。アプリ固有のステータスクラス名を共有テーマに追加しないでください。

ローディングとエラーのプリミティブ(これらは手作業で作成しないでください):

  • ck-spinner — スピンアニメーション。Lucideローダーと組み合わせます。例:html\<${Icon} name="loader-circle" class="ck-spinner" />``。
  • ck-skeleton — まだロード中のコンテンツのための、きらめくプレースホルダーブロック。
  • ck-callout / ck-callout ck-error — フェッチ失敗のためのインラインノーティス/エラーサーフェス。例:html\<${Icon} name="circle-x" size=${16} />${state.error}``。

モーションの安全性はグローバルです。 theme.css はすでに @media (prefers-reduced-motion: reduce) ガードを出荷しており、すべてのアニメーションとトランジション(キットのスピナー/スケルトンや追加したもの)を無効化します。キャンバスごとに繰り返す必要はありません — ただし、モーションは装飾的に保ち、決して唯一のシグナルにしないでください。

ビューAPI — フック、ヘルパー、フラグメント

/kit/client.mjs は、useState だけでなく、ベンダー提供のPreactフックセット全体を再エクスポートします:useEffect、useRef、useMemo、useCallback、useReducer、useContext、useLayoutEffect、useImperativeHandle。フックはコンポーネントの先頭で無条件に呼び出してください — 早期の return(例:フックの後の if (!state) return … ローディングブランチ)の前に。

また、フォーマットとIDヘルパーをエクスポートしているので、それらを再発明する必要はありません。

import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid();                       // "lq3k9f2a" — 短いユニークID
relativeTime(iso);           // "5分前" / "3時間前" / "2日前" / 日付
compactNumber(2_300_000);    // "2.3M"
percent(1.25);               // "+1.25%"

nid はサーバーサイドの canvas.mjs でも import { nid } from "./canvas-kit/format.mjs" でインポート可能です(ジェネレーターはこれを行います)。

フォーマッターはロケール対応です — relativeTime は toLocaleDateString() で終わり、compactNumber/percent は Number.prototype.toLocaleString を通るため、その出力はホストロケールに依存します("1.5K" vs "1,5 K"、2025-01-31 vs 31/01/2025)。永続状態には生の値(ISO文字列、Number)を保持し、ビューでフォーマットしてください。フォーマットされた文字列を保存したり、解析して戻したりしないでください。フォーマットはプレゼンテーションの関心事であり、あるロケールでフォーマットされた値は、別のロケールで確実に再読み取りできません。

Fragment はエクスポートされません。 ラッパーなしで兄弟ノードを返すには、htmの組み込み空タグ構文 — html`<><${A} /><${B} /></>` — を使用するか、単にvnodeの配列を返してください。import { Fragment } しないでください。存在しません。

外部データキャンバス — フェッチ + 自動更新

ほとんどの本番キャンバスは、ユーザーが入力するCRUDリストではなく、ネットワークからプルして更新します。形式は次のとおりです。

  1. fetch() はアクションハンドラ(またはそれが呼び出すヘルパー)に存在し、ビューには決して存在しません。 常にタイムアウトとバインドして、遅い上流がパネルをハングさせないようにします。

    async function fetchItems(url) {
      const res = await fetch(url, { signal: AbortSignal.timeout(12000) });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return mapItems(await res.json());
    }
    

    フェッチはループバックランタイム上でサーバーサイドで実行されるため、生成された data テンプレートはSSRFチェックでこれをガードします。http/https をパブリックホストにのみ許可し、ループバック、リンクローカル(クラウドメタデータを含む)、プライベート範囲 — ホスト名が解決するすべてのアドレスを含む — を拒否します。エージェントにフィードバックするもの(例:list_items 経由)は依然として攻撃者に影響されるテキストであるため、あなたが選択したソースを維持し、フェッチされたコンテンツを信頼できないものとして扱います(テキストとしてレンダリングし、決して innerHTML ではレンダリングしません)。

  2. refresh アクションを公開して、フェッチして結果を共有状態に書き込みます。失敗を state.error にキャプチャし、スローせずに返します。これにより、バックグラウンドのポーリングティックが静かに行われている間、エラーカードが表示されます。await の前に入力をキャプチャし、関数型 set で書き戻して、並行アクション(別のポーリング、set_source)が上書きされないようにします。

    refresh: {
      inputSchema: { type: "object", properties: {}, additionalProperties: false },
      handler: async ({ state, set }) => {
        const sourceUrl = state.sourceUrl;          // awaitの前にキャプチャ
        try {
          const items = await fetchItems(sourceUrl);
          set((cur) => cur.sourceUrl !== sourceUrl  // 古い応答を破棄
            ? cur
            : { ...cur, items, error: null, lastRefresh: new Date().toISOString() });
          return { count: items.length };
        } catch (err) {
          set((cur) => ({ ...cur, error: `Couldn't load: ${err.message}`, lastRefresh: new Date().toISOString() }));
          return { ok: false, error: String(err.message) };
        }
      },
    },
    
  3. キットでポーリングを行い、生の setInterval は使用しません。 pollWhileVisible(tick, seconds) は、パネルが表示されている間のみインターバルで tick を実行し(バックグラウンドのキャンバスはネットワークへのアクセスを停止します)、クリーンアップ関数を返します — useEffect の戻り値としてそのまま使用できます。

    import { pollWhileVisible } from "/kit/client.mjs";
    // 永続状態にバインドされたインターバル。変更時に再構築される:
    useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0),
              [state.autoRefreshSec]);
    

    固定インターバルの場合は、エフェクトを完全にスキップできます:mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } })。

この全体の形式を node scripts/new-canvas.mjs <name> --template data でスタンプします。

ドメイン戦略 — 共有ボード vs パーソナルプロファイル

状態は resolveDomainId が返すドメインIDによってキー設定されます。二つの意図、同一のメカニズム(fileFor はIDをファイル名にサニタイズします — そのままコピーしてください)。

  • 共有ボード(ほとんどのキャンバス):エージェントとユーザーの両方が名前を付けるトピックからキーを取得します。resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default")。誰でも domain: "tech" を開くと同じボードを表示します。
  • パーソナルストア:トピックではなく、アイデンティティからキーを取得します。resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default")。入力スキーマプロパティは profile という名前です。トピック間で漏れてはいけない学習者別/ユーザー別データに使用します。

名詞(domain と profile)を意図的に選択してください。それが誰が何を共有するかに関する契約全体です。

パターンと制限事項(これらを知っておくこと)

  • バンドルされたカタログ。 厳選されたシードデータ(コース、プリセット)には、別の catalog.mjs を出荷します。プレーンデータと、決定論的な位置ID(es-u1-l2)を割り当てる buildCourse(...) スタイルのビルダーを使用します。これにより、同じカタログは常に同じIDを生成します。小さなコンストラクタヘルパーを使用して、バルクデータを簡潔に保ちます。
  • statusLine はオープン時に一度だけ計算されます。 パネルのステータス文字列は openInstance で設定され、アクションが状態を変更しても再プッシュされません。そのため、statusLine 内のカウントは古くなります。ライブカウントは、すべてのSSEプッシュで再レンダリングされるキャンバス本体内部に表示し、statusLine はオープン時のみのサマリーとして扱います。
  • リッチペイロードアクション。 アクション inputSchema はデフォルトで additionalProperties: false になります。これは、スキーマがエージェントが入力する契約であり、厳格なスキーマにより、モデルが未検証のフィールドやタイプミスのフィールドを密輸するのを防ぎます。宣言したプロパティのみがハンドラに到達します。UIがハンドラにスカラーフィールドではなくオブジェクト全体(例:保存される記事)を渡す必要がある場合は、そのアクションの inputSchema で additionalProperties: true を設定し、ハンドラ内で必要なものを永続状態に非正規化します。アクションごとに意図的に、包括的なデフォルトとしてではなく、設定を反転させてください。
  • インストールレイアウト / SDK解決。 出荷される拡張機能フォルダには copilot-extension.json({ "name", "version": 1 } — package.json なし)、extension.mjs、canvas.mjs、web/、およびネストされた canvas-kit/ のみが含まれます。キットはNodeが直接実行するプレーンなESMです — 拡張機能にバンドラー、トランスパイル、ビルドステップ、package.json はありません(スタンプされた test/smoke.test.mjs はベアの node で実行されます)。ホストは @github/copilot-sdk/extension を解決します。extension.mjs だけがそれをインポートする唯一のファイルです。出荷されるキャンバスに package.json やビルドステップを追加しないでください。

キャンバスの構築 — 最速のパス

  1. スタンプする(動作するキャンバスが一つ、キットがすでにネストされ、スモークテストが含まれています):
    node scripts/new-ca
    

…

[以降の翻訳部分については、原文が不完全なため、ここまでとします]

関連スキル