designpaca/packages/skill/references/experimental-canvas.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

18 KiB

experimental-canvas — HTML-in-Canvas 실전 지침

Chrome의 HTML-in-Canvas API로 실제 HTML 요소를 캔버스 픽셀/GPU 텍스처로 그린다. 요소는 DOM에 남아 클릭·포커스·접근성이 유지된다. 구현 직전에 읽는 문서다.

1. 발동 조건 게이트

기본값은 "쓰지 않는다". 세 관문을 전부 통과할 때만 진행한다.

관문 1 — 셰이더가 HTML의 렌더된 픽셀을 읽어야 하는가

하려는 것 판정
글로우, 파티클, 커서 트레일, 배경 그라디언트 배경 캔버스 오버레이로 충분. 쓰지 마라
유리 굴절, 프로스티드 패널 SVG feDisplacementMap + backdrop-filter
카드/패널을 3D로 기울이기 CSS3DRenderer. DOM 그대로라 완벽하다
상태 A → B 페이지 전환 View Transitions + mask-image
텍스트를 픽셀 단위로 왜곡, 요소의 일부만 압축 후보
라이트/다크 두 렌더를 노이즈로 픽셀 합성 후보
렌더된 픽셀의 휘도·엣지에 반응하는 효과 후보
3D 메시 위의 상호작용 UI (천, 책, 화면) 후보

배경 캔버스는 그릴 수는 있어도 읽을 수는 없다. 읽어야만 하는 경우가 아니면 탈락이다.

관문 2 — 폴백이 이미 완성되어 있는가

폴백 없는 구현은 금지다. 순서를 뒤집지 마라.

  1. HTML/CSS만으로 페이지를 완성한다. 레이아웃·포커스 순서·읽기 순서는 여기서 끝난다.
  2. 폴백 이펙트(SVG 필터 / CSS 트랜지션 / 배경 캔버스)를 붙여 그 상태로 출시 가능하게 만든다.
  3. 그 위에 HTML-in-Canvas를 얹는다.

캔버스는 장식이다. 구조가 아니다.

관문 3 — 맥락이 허용하는가

사용자가 명시적으로 실험을 원했거나, 데모·포트폴리오·사내 도구처럼 브라우저를 통제할 수 있을 때만. 일반 사용자 대상 프로덕션이면 여기서 멈춘다.

2. 가용성 — 읽는 시점에 반드시 확인

항목
플래그 chrome://flags/#canvas-draw-element → Enabled
권장 브라우저 Chrome Canary 149+
Origin Trial Chrome 148 ~ 154. 2026년 10월 초 만료 예정
Stable 기본 활성화 없음. chromestatus 상태는 In development
Firefox / Safari 구현 없음, 입장 미표명

OT 만료 후에는 플래그 전용으로 되돌아간다. 일반 사용자에게는 아무것도 보이지 않고 폴백이 곧 실제 결과물이 된다. 2026-10 이후에 읽고 있다면 chromestatus.com/feature/5172548013916160에서 연장/출시 여부를 먼저 확인하라.

3. 권장 경로 — 폴리필 우선

3D를 쓴다면 three-html-render 폴리필로 시작한다. three.js 공식 예제가 쓰는 방식이다. 네이티브가 있으면 texElementImage2D fast path, 없으면 foreignObject 래스터화 + matrix3d DOM 오버레이로 자동 전환된다. 같은 코드가 전 브라우저에서 돌고 상호작용도 유지된다.

폴리필 한계: textarea 내부 스크롤 미반영, contenteditable 캐럿/선택 미렌더, 동적 스타일시트 수동 무효화 필요, :visited 불가. 매 프레임 재캡처는 비싸다 — 무효화 시점에만 갱신하도록 짜라. 2D 전용 이펙트라면 폴리필 없이 능력 감지 분기(§7c)로 간다.

4. 현재 API 표면

<canvas layoutsubtree> 를 선언하고, 그릴 요소를 직계 자식으로 둔다. 손자는 그릴 수 없다.

용도 호출
2D 그리기 ctx.drawElementImage(el, dx, dy[, dw, dh])DOMMatrix
2D 크롭 ctx.drawElementImage(el, sx, sy, sw, sh, dx, dy[, dw, dh])
WebGL 업로드 gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, el)
WebGPU 업로드 device.queue.copyElementImageToTexture({source: el}, {destination: {texture}, width, height})
갱신 훅 canvas.onpaint = (e) => {}e.changedElements로 바뀐 요소만 온다
킥스타트 / 프레임 루프 canvas.requestPaint()
3D 위치 동기화 canvas.getElementTransform(el, screenSpaceMatrix)DOMMatrix
워커 전송 canvas.captureElementImage(el)ElementImage (Transferable)
three.js material.map = new THREE.HTMLTexture(element) (r184+)

이 이름을 본다면 낡은 자료다 — 따라 쓰지 마라

낡은 이름 현재
canvas place element, placeElement() 제안명 html-in-canvas
drawElement(), drawHTMLElement(), drawHTML() drawElementImage()
texElement2D() texElementImage2D()
copyElementImage() copyElementImageToTexture()
setHitTestRegions() 폐기. 반환 DOMMatrixstyle.transform에 반영

5. 필수 계약 3가지

① 반환 DOMMatrix를 매 프레임 style.transform에 반영한다

const t = ctx.drawElementImage(el, x, y);
el.style.transform = t.toString();   // 이 줄이 없으면 클릭이 전부 어긋난다

히트테스트·포커스·탭 이동·find-in-page는 전부 DOM 위치를 본다. 그린 위치와 DOM 위치를 맞추는 건 개발자 책임이다. 소스 요소의 CSS transform은 그리기에서 무시되므로 이 대입이 캔버스 그림을 바꾸지 않는다 — 무한 루프는 생기지 않는다. 3D에서는 canvas.getElementTransform(el, screenSpaceMatrix)가 같은 역할을 하고, three.js는 InteractionManager.update()가 대신 해준다.

② WebGL/WebGPU는 try/catch로 신·구 시그니처를 모두 지원한다

2026년 상반기에 두 시그니처가 모두 바뀌었다. Chrome 공식 블로그 예제가 이미 구버전이다. WICG 공식 데모조차 양쪽을 지원한다.

try { gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, el); }                         // 현재
catch (e) { gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,               // 구버전
                                 gl.UNSIGNED_BYTE, el); }

try { device.queue.copyElementImageToTexture(                                      // 현재
        { source: el }, { destination: { texture }, width, height }); }
catch (e) { device.queue.copyElementImageToTexture(el, width, height, { texture }); } // 구버전

③ 캔버스 안에 스크롤 영역을 넣지 마라

캔버스 안 콘텐츠는 JS로 그려진다. 컴포지터 스레드 스크롤·애니메이션을 잃고 스크롤이 메인 스레드에 묶여 끊긴다. 캔버스 안을 스크롤시키지 말고 캔버스 전체를 페이지와 함께 스크롤시켜라.

6. 그리지 않는 것

픽셀을 읽을 수 있으므로, 저자가 원래 못 보던 정보는 아예 안 그려진다. 검게 보인다고 버그가 아니다.

안 그려짐 비고
cross-origin <iframe>·<img>·url() 참조·SVG <use> same-origin iframe은 그려진다. 그 안의 교차 출처만 빠진다
:visited 링크 스타일 히스토리 스니핑 차단
시스템 색상 · OS 테마 · 사용자 환경설정
맞춤법/문법 밑줄, 폼 자동완성 미리보기
서브픽셀 텍스트 안티에일리어싱 텍스트가 미묘하게 다르게 보인다
IME 팝업 · IME 고유 서식 한글 조합 중 상태가 캔버스에 안 나온다
캡션/자막 사용자 설정
(그려짐) find-in-page 하이라이트, 스크롤바·폼 컨트롤 외형, 캐럿

한국어 사이트에서는 IME 항목이 치명적이다. 캔버스 안 텍스트 입력을 주요 UX로 쓰지 마라.

7. 완성 코드

(a) 2D 반사 버튼 — 2D 이펙트의 기본형

save/restore로 CTM을 바꿔 같은 요소를 두 번 그리는 패턴.

<style>
  canvas { width: 480px; height: 300px; }
  /* background 는 불투명 색으로. 반투명이면 효과가 비쳐 나온다 */
  #btn { font: 600 20px/1 system-ui, sans-serif; padding: 16px 32px; border: 0;
         border-radius: 999px; color: #fff; cursor: pointer; background: #ff5fa2;
         transition: background .2s, scale .12s; }
  #btn:hover { background: #a05cff; scale: 1.05; }
</style>

<canvas id="canvas" layoutsubtree>
  <button id="btn">Press me</button>
</canvas>

<script>
  const canvas = document.getElementById('canvas');
  const ctx    = canvas.getContext('2d');
  const btn    = document.getElementById('btn');
  const X_CSS = 100, Y_CSS = 90;

  canvas.onpaint = () => {
    const rect = canvas.getBoundingClientRect();
    const s = canvas.width / rect.width;        // 실효 dpr. 좌표는 device pixel 단위다
    const x = X_CSS * s, y = Y_CSS * s, h = btn.offsetHeight * s;

    ctx.reset();
    ctx.save();                                 // 반사본
    ctx.translate(0, 2 * (y + h));
    ctx.scale(1, -1);
    ctx.globalAlpha = 0.3;
    ctx.drawElementImage(btn, x, y);
    ctx.restore();

    const t = ctx.drawElementImage(btn, x, y);  // 본체 — 이 반환값만 동기화한다
    btn.style.transform = t.toString();
  };
  canvas.requestPaint();   // 최초 스냅샷 확보. 없으면 아무것도 안 그려진다

  new ResizeObserver(([e]) => {
    const dpc = e.devicePixelContentBoxSize;
    canvas.width  = dpc ? dpc[0].inlineSize : Math.round(e.contentRect.width  * devicePixelRatio);
    canvas.height = dpc ? dpc[0].blockSize  : Math.round(e.contentRect.height * devicePixelRatio);
    canvas.requestPaint();
  }).observe(canvas, { box: 'device-pixel-content-box' });
</script>

애니메이션이 필요하면 onpaint 끝에 canvas.requestPaint()를 한 줄 더 넣어 프레임 루프를 만든다. requestAnimationFrame 안에서 그리면 직전 프레임 스냅샷이 쓰여 1프레임 밀린다.

(b) three.js HTMLTexture + 폴리필 폴백 — 권장 기본 경로

3D 씬 안에 상호작용 UI를 넣는 경우.

<script type="importmap">
{ "imports": {
    "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.184.0/examples/jsm/",
    "three-html-render/polyfill": "https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.mjs"
}}
</script>

<script type="module">
import * as THREE from 'three';
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';

// 네이티브 없으면 foreignObject 폴리필로 자동 대체
if (!('requestPaint' in HTMLCanvasElement.prototype)) {
  const { installHtmlInCanvasPolyfill } = await import('three-html-render/polyfill');
  installHtmlInCanvasPolyfill();
}

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 1, 2000);
camera.position.z = 500;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0b10);

// 텍스처가 될 HTML. document 에 직접 붙이지 않는다 — HTMLTexture 가 캔버스 자식으로 넣는다
const element = document.createElement('div');
element.style.cssText = 'width:600px;padding:30px;background:#12121b;color:#eaeaf2;'
                      + 'font:28px/1.5 system-ui,sans-serif;text-align:center';
element.innerHTML = '진짜 HTML 입니다. 선택·복사·검색이 됩니다.'
                  + '<br><input type="text" placeholder="입력해 보세요"> <button>보내기</button>';

const material = new THREE.MeshBasicMaterial();   // 조명 없이 텍스트 가독성 유지
material.map = new THREE.HTMLTexture(element);    // ← 핵심 한 줄
const mesh = new THREE.Mesh(new THREE.BoxGeometry(200, 200, 200), material);
scene.add(mesh);

const interactions = new InteractionManager();   // 브라우저 히트테스트에 위임, raycast 불필요
interactions.connect(renderer, camera);
interactions.add(mesh);
element.querySelector('button').onclick = (e) => { e.target.textContent = '보냈습니다'; };

const spin = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 1;
renderer.setAnimationLoop((t) => {
  mesh.rotation.x = Math.sin(t * 0.0005) * 0.5 * spin;
  mesh.rotation.y = Math.cos(t * 0.0008) * 0.5 * spin;
  interactions.update();          // 매 프레임 transform 동기화. 빼면 클릭이 어긋난다
  renderer.render(scene, camera);
});

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
</script>

HTMLTexture는 내부에서 parent.onpaint를 구독해 needsUpdate를 세우고 parent.requestPaint()로 킥스타트한다. layoutsubtree 설정과 요소 부모 관리도 대신 해준다.

(c) 능력 감지 + 폴백 분기 유틸

2D 경로에서 쓴다. 네이티브가 없으면 캔버스를 걷어내고 자식 HTML을 문서로 승격시킨다.

// hic.js
export const HIC = {
  get native() {
    return typeof HTMLCanvasElement !== 'undefined'
        && 'requestPaint' in HTMLCanvasElement.prototype;
  },
  get native2D() {
    return this.native && 'drawElementImage' in CanvasRenderingContext2D.prototype;
  },
  get nativeGL() {
    return this.native && typeof WebGL2RenderingContext !== 'undefined'
        && 'texElementImage2D' in WebGL2RenderingContext.prototype;
  },
  get nativeGPU() {
    return typeof GPUQueue !== 'undefined' && 'copyElementImageToTexture' in GPUQueue.prototype;
  },
  /** 2D: 그리고 transform 을 동기화한다. */
  draw(ctx, el, x, y, w, h) {
    const t = (w === undefined) ? ctx.drawElementImage(el, x, y)
                                : ctx.drawElementImage(el, x, y, w, h);
    el.style.transform = t.toString();
    return t;
  },
  // WebGL/WebGPU 업로드는 §5② 의 try/catch 스니펫을 그대로 쓴다.
  /** 캔버스 그리드를 device pixel 에 맞춘다. 안 하면 텍스트가 흐리다. */
  observeSize(canvas) {
    const ro = new ResizeObserver(([e]) => {
      const dpc = e.devicePixelContentBoxSize;
      canvas.width  = dpc ? dpc[0].inlineSize : Math.round(e.contentRect.width  * devicePixelRatio);
      canvas.height = dpc ? dpc[0].blockSize  : Math.round(e.contentRect.height * devicePixelRatio);
      canvas.requestPaint?.();
    });
    const ok = typeof ResizeObserverEntry !== 'undefined'
            && 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
    ro.observe(canvas, ok ? { box: 'device-pixel-content-box' } : {});
    return ro;
  },
  /** 진입점. 어느 쪽으로 가든 HTML 콘텐츠는 화면에 남는다. */
  mount(canvas, { enhance, fallback }) {
    if (this.native2D) {
      canvas.setAttribute('layoutsubtree', '');
      this.observeSize(canvas);
      enhance(canvas);
      canvas.requestPaint();
      return 'native';
    }
    const parent = canvas.parentNode;
    canvas.replaceWith(...canvas.childNodes);   // 자식 HTML 을 문서로 승격
    fallback?.(parent);
    return 'fallback';
  },
};
import { HIC } from './hic.js';   // 사용부

HIC.mount(document.getElementById('canvas'), {
  enhance: (canvas) => {
    const ctx = canvas.getContext('2d');
    const ui = document.getElementById('ui');
    canvas.onpaint = () => { ctx.reset(); HIC.draw(ctx, ui, 0, 0); };
  },
  fallback: (root) => root.classList.add('fx-css-only'),   // SVG 필터 / CSS 트랜지션 경로
});

8. 접근성 체크리스트 — 캔버스를 얹은 뒤 매번 확인한다

  • 캔버스를 지워도 콘텐츠가 보이는가. DevTools에서 <canvas>를 삭제해도 페이지가 읽히고 동작해야 한다.
  • Tab 순서가 시각 순서와 일치하는가. 캔버스 자식의 DOM 순서가 곧 탭 순서다.
  • 포커스 링이 보이고 위치가 맞는가. 셰이더가 얇은 아웃라인을 뭉갠다. :focus-visible에 두꺼운 링을 주고, 키보드로 이동하며 링 위치가 그려진 픽셀과 맞는지 눈으로 확인한다.
  • prefers-reduced-motion: reduce에서 모션을 끈다. 왜곡·회전·진동은 정지 상태로 폴백한다.
  • 왜곡 중에는 클릭이 어긋난다. 큰 전환 동안 pointer-events: none을 걸거나 왜곡량을 작게 유지한다.
  • 한글 입력을 캔버스 안에서 요구하지 않는다. IME 조합 상태가 그려지지 않는다(§6).
  • 텍스트 대비를 셰이더 적용 후에 측정한다. 블렌딩·색수차가 대비를 떨어뜨린다.
  • 히트테스트가 필요 없는 장식 요소는 inert. WICG 공식 예제도 그렇게 한다.
  • 스크린리더로 한 번 통과시킨다. 자식은 접근성 트리에 그대로 노출되므로, 이상하면 HTML 구조가 이상한 것이다.

9. 흔한 실패와 원인

증상 원인
아무것도 안 그려진다 canvas.requestPaint() 최초 호출 누락
InvalidStateError 첫 스냅샷 전에 그렸다. onpaint 밖에서 그리고 있다
텍스트가 흐리다 캔버스 그리드를 device pixel로 안 맞췄다
Retina에서만 위치가 어긋난다 좌표에 canvas.width / rect.width를 안 곱했다
클릭이 엉뚱한 데 떨어진다 반환 DOMMatrixstyle.transform에 안 넣었다 (§5①)
페이지 아래로 갈수록 오차가 커진다 캔버스 자식 크기가 캔버스 CSS 크기와 불일치
텍스처가 뒤집혀 나온다 WebGL UV에서 Y를 안 뒤집었다
효과가 요소 배경을 뚫고 비친다 반투명 배경. 불투명 색으로 바꾼다
3D 텍스트가 뭉개진다 mipmap 대신 gl.LINEAR 필터를 쓴다
iframe/이미지가 검게 나온다, 캔버스가 콘텐츠 높이만큼 안 자란다 각각 cross-origin(§6), <canvas>는 div가 아니므로 크기 명시

근거: research/canvas/01-api-spec.md, 02-availability.md (조사일 2026-08-20)