Communitygithub.com

jongio/skills - 캔버스 앱 제작 키트

AI 코딩 에이전트(GitHub Copilot, Claude, Codex)를 위한 크로스-에이전트 스킬 플러그인으로, skills CLI(npx skills add)로 설치 가능합니다. Copilot 캔버스 확장을 구축하기 위한 create-canvas-kit을 포함합니다.

skills - 캔버스 앱 제작 키트란 무엇인가요?

이 스킬은 AI 코딩 에이전트(GitHub Copilot, Claude, Codex)를 위한 크로스-에이전트 스킬 플러그인입니다. 'npx skills add' 명령어를 통해 설치할 수 있으며, 특히 Copilot App 캔버스 확장을 손쉽게 구축할 수 있는 'create-canvas-kit'을 포함하고 있습니다. 캔버스는 사용자와 에이전트가 동일한 상태와 핸들러를 통해 상호작용하는 대화형 사이드 패널로, 복잡한 대시보드, 편집기, 스프레드시트 등에 유용합니다. 이 키트는 DOM을 효율적으로 업데이트하는 Preact를 사용하여, 기존 innerHTML 방식에서 발생하던 입력 포커스 손실이나 키 입력 누락 문제를 해결합니다. 또한 공유 상태 관리, SSE 기반 실시간 업데이트, 일관된 아이콘 및 테마 시스템 등을 제공하여 생산성과 사용자 경험을 크게 향상시킵니다. 핵심 아키텍처는 확장 로직(extension.mjs), 캔버스 로직(canvas.mjs), Preact 뷰(web/app.mjs)를 명확히 분리하여 유지보수성과 테스트 용이성을 높였습니다.

지원 대상✓Claude Code✓Codex CLI~Cursor
npx skills add jongio/skills

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

캔버스 앱 만들기

Copilot App 캔버스 확장을 구축하기 위한 올인원 방식입니다. 이 키트가 필요한 이유는 직접 만든 캔버스가 항상 동일한 문제에 부딪히기 때문입니다: 키 입력을 삼키는 innerHTML 리페인트 루프, 하나의 패널에만 존재하는 상태, 호스트 테마를 무시하는 임시 스타일링, 일관성 없는 아이콘. 이 키트는 네 가지 문제를 모두 해결합니다.

캔버스란 무엇이며, 언제 만들어야 하는가

캔버스는 에이전트가 open_canvas를 통해 사이드 패널에 여는 대화형 표면입니다. 에이전트와 사용자는 동일한 액션 핸들러를 통해 동일한 상태에 대해 작업합니다. 채팅 텍스트나 diff만으로는 부족할 때 캔버스를 사용하세요: 라이브 대시보드, 편집기, 스프레드시트, 트래커, 칸반/보드 뷰, 문서 미리보기, 도구별 워크플로우.

사용자에게 시각적 요소가 필요 없는 에이전트 도구만 필요하다면, 캔버스 대신 일반 확장 도구를 만드세요.

렌더링 계층 — 먼저 결정하세요

계층사용 시기방법
정적 HTML 문자열읽기 전용 또는 거의 정적인 콘텐츠; 보호할 입력 필드 없음런타임의 기본 스캐폴드(extensions_manage scaffold kind:canvas).
Preact + htm + 이 키트대화형 요소: 입력, 라이브 업데이트, 목록, 폼, 공유 상태이 키트. 실제 캔버스의 기본 선택.

키트를 사용하는 가장 중요한 이유: Preact가 DOM을 diff 방식으로 업데이트하므로, 에이전트의 라이브 상태 푸시가 입력 필드의 포커스, 캐럿 위치, 또는 입력 중인 텍스트를 방해하지 않습니다. 초기 캔버스가 주로 사용하는 innerHTML = ... 패턴은 전체 트리를 다시 그리므로 매 푸시마다 키 입력이 손실됩니다. 그러지 마세요.

모델 (코딩 전에 읽어보세요)

extension.mjs   ── Copilot SDK를 임포트하는 유일한 파일 (얇은 어댑터)
canvas.mjs      ── 당신의 캔버스: id, 스키마, 상태 로드/저장, 액션 핸들러 (SDK 없음)
canvas-kit/     ── 키트 (그대로 복사; 편집하지 마세요)
web/index.html  ── 셸: /kit/theme.css와 ./app.mjs를 로드
web/app.mjs     ── 당신의 Preact 뷰
  • 상태는 공유되고 지속됩니다. 열린 입력에서 확인된 도메인 id에 의해 키가 지정되며(resolveDomainId), instanceId가 아닙니다 — 동일한 도메인을 두 패널에서 열면 동일한 데이터를 보여줍니다. 지속성은 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) throw합니다. 런타임은 { ok: false, code, message }로 응답합니다 — 알려진 키트 오류(예: 알 수 없는 액션)의 경우 HTTP 400, 핸들러 throw의 경우 500 — 그리고 mountCanvas의 invoke는 해당 code/message를 담은 CanvasActionError로 거부하므로 버튼 핸들러가 try/catch할 수 있습니다. 에이전트 측에서는 extension.mjs가 동일한 오류를 CanvasError에 매핑합니다. throw는 표면화되며, 충돌이 아닙니다.
  • 예상되는 일시적 실패 (백그라운드 폴링에서 불안정한 업스트림 가져오기)의 경우, throw하지 말고 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 자체 글리프 선택과 일치시키세요 (예: 열기=circle-dot, 완료=circle-check, 북마크=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 — 실패한 fetch를 위한 인라인 알림/오류 표면 (예: 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-kit/format.mjs에서 import { nid } from "./canvas-kit/format.mjs"를 통해 서버 측 canvas.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 } 하지 마세요. 존재하지 않습니다.

외부 데이터 캔버스 — fetch + 자동 새로고침

대부분의 실제 캔버스는 사용자가 입력한 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());
    }
    

    fetch는 루프백 런타임에서 서버 측에서 실행되므로, 생성된 data 템플릿은 SSRF 검사로 보호합니다: http/https를 공개 호스트에만 허용하고 루프백, 링크-로컬(클라우드 메타데이터 포함) 및 개인 범위(호스트 이름이 확인하는 모든 주소 포함)를 거부합니다. 에이전트에 다시 피드하는 모든 것(예: list_items를 통해)은 여전히 공격자에 의해 영향을 받을 수 있는 텍스트이므로, 당신이 선택한 소스를 유지하고 가져온 콘텐츠를 신뢰할 수 없는 것으로 취급하세요(텍스트로 렌더링하고, 절대 innerHTML로 렌더링하지 마세요).

  2. refresh 액션을 노출하여 fetch하고 결과를 공유 상태에 씁니다. 실패를 state.error에 캡처하고 반환하여(throw하지 말고) 백그라운드 폴링 틱이 오류 카드가 표시되는 동안 조용히 유지되도록 합니다. 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: `로드할 수 없음: ${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]);
    

    고정 간격의 경우 effect를 완전히 건너뛸 수 있습니다: 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"를 여는 사람은 누구나 동일한 보드를 봅니다.
  • 개인 저장소: 주제가 아닌 ID에서 키를 가져옵니다. resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default"), 입력 스키마 속성 이름은 profile입니다. 학습자별/사용자별 데이터에 대해 사용하세요. 주제 간에 데이터가 섞여서는 안 됩니다.

명사(domain vs profile)를 의도적으로 선택하세요 — 이것이 누가 무엇을 공유하는지에 대한 전체 계약입니다.

패턴 및 제한 사항 (알아두세요)

  • 번들 카탈로그. 선별된 시드 데이터(과정, 프리셋)의 경우, 별도의 catalog.mjs를 제공하세요: 일반 데이터와 결정적 위치 ID(es-u1-l2)를 할당하는 buildCourse(...)-스타일 빌더를 포함하여 동일한 카탈로그가 항상 동일한 ID를 생성하도록 합니다. 작은 생성자 헬퍼를 사용하여 대량 데이터를 간결하게 유지하세요.
  • statusLine은 열 때 한 번 계산됩니다. 패널의 상태 문자열은 openInstance에서 설정되며, 액션이 상태를 변경할 때 다시 푸시되지 않으므로 statusLine의 count는 오래됩니다. 캔버스 본문 내부에서 라이브 count를 표시하세요 (모든 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
    

관련 스킬