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

350 lines
18 KiB
Markdown

# 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()` | **폐기.** 반환 `DOMMatrix``style.transform`에 반영 |
## 5. 필수 계약 3가지
### ① 반환 `DOMMatrix`를 매 프레임 `style.transform`에 반영한다
```js
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 공식 데모조차 양쪽을 지원한다.
```js
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을 바꿔 같은 요소를 두 번 그리는 패턴.
```html
<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를 넣는 경우.
```html
<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을 문서로 승격**시킨다.
```js
// 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';
},
};
```
```js
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`를 안 곱했다 |
| 클릭이 엉뚱한 데 떨어진다 | 반환 `DOMMatrix``style.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)