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)
This commit is contained in:
commit
8808c672dc
135 changed files with 38838 additions and 0 deletions
11
.changeset/config.json
Normal file
11
.changeset/config.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [["designpaca", "@designpaca/core", "@designpaca/skill"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@designpaca/site"]
|
||||
}
|
||||
59
.forgejo/workflows/ci.yml
Normal file
59
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# designpaca CI — 자체 러너(linux-builder, host 모드)에서 돈다.
|
||||
# host 러너에는 Node 가 없을 수 있으므로 uses: 액션을 쓰지 않고 전부 run: 으로 처리한다.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux-builder
|
||||
steps:
|
||||
- name: checkout
|
||||
env: { CI_TOKEN: "${{ github.token }}" }
|
||||
run: |
|
||||
proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}"
|
||||
url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git"
|
||||
[ -d .git ] || git init -q .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin "$url"
|
||||
git fetch -q --depth 1 origin "$GITHUB_REF"
|
||||
git checkout -q -f FETCH_HEAD
|
||||
git clean -qfdx
|
||||
|
||||
- name: 환경 확인
|
||||
run: ./build/ci/verify-node.sh
|
||||
|
||||
- name: 의존성 설치
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 스킬 문서 검사
|
||||
run: node build/ci/lint-skill.mjs packages/skill
|
||||
|
||||
- name: 타입 검사
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 테스트
|
||||
run: pnpm test
|
||||
|
||||
- name: 빌드
|
||||
run: pnpm build
|
||||
|
||||
- name: 배포물 점검 (npx 경로 검증)
|
||||
run: |
|
||||
node packages/cli/scripts/verify-package.mjs
|
||||
# 실제 사용자가 겪는 경로 그대로 확인한다 — tarball 을 만들어 설치까지 해본다
|
||||
cd packages/cli
|
||||
tarball="$(npm pack --silent)"
|
||||
tmp="$(mktemp -d)"
|
||||
tar -xzf "$tarball" -C "$tmp"
|
||||
test -f "$tmp/package/dist/skill/SKILL.md" || { echo "tarball 에 스킬이 없다" >&2; exit 1; }
|
||||
node "$tmp/package/dist/index.js" --version
|
||||
node "$tmp/package/dist/index.js" list
|
||||
rm -rf "$tmp" "$tarball"
|
||||
|
||||
- name: 사이트 빌드
|
||||
run: pnpm build:site
|
||||
97
.forgejo/workflows/release.yml
Normal file
97
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# designpaca 릴리스 — 태그 v* 푸시로 발동한다.
|
||||
# 1) 게이트: 검사·테스트·빌드
|
||||
# 2) npmjs 배포 + Forgejo 사설 레지스트리 미러
|
||||
# 3) Forgejo draft 릴리스에 tarball 첨부 (QA 후 수동 Publish 승격)
|
||||
# 4) 소개 페이지 Cloudflare Pages 배포
|
||||
#
|
||||
# 필요한 시크릿: NPM_TOKEN · FORGEJO_NPM_TOKEN · RELEASE_TOKEN · CF_API_TOKEN · CF_ACCOUNT_ID
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch: # 수동 리허설 (ci-rehearsal prerelease 로 나간다)
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: linux-builder
|
||||
steps:
|
||||
- name: checkout
|
||||
env: { CI_TOKEN: "${{ github.token }}" }
|
||||
run: |
|
||||
proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}"
|
||||
url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git"
|
||||
[ -d .git ] || git init -q .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin "$url"
|
||||
git fetch -q --depth 1 origin "$GITHUB_REF"
|
||||
git checkout -q -f FETCH_HEAD
|
||||
git clean -qfdx
|
||||
|
||||
- name: 환경 확인
|
||||
run: ./build/ci/verify-node.sh
|
||||
|
||||
- name: 릴리스 정보 확정
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
echo "RELTAG=$tag" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_PRERELEASE=false" >> "$GITHUB_ENV"
|
||||
echo "DO_PUBLISH=1" >> "$GITHUB_ENV"
|
||||
# 태그와 package.json 버전이 어긋난 채 배포되는 사고를 막는다
|
||||
pkgver="$(node -p 'require("./packages/cli/package.json").version')"
|
||||
[ "v$pkgver" = "$tag" ] || { echo "태그($tag)와 package.json(v$pkgver)이 다르다" >&2; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "RELTAG=ci-rehearsal" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_PRERELEASE=true" >> "$GITHUB_ENV"
|
||||
echo "DO_PUBLISH=0" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: 의존성 설치
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 검사 · 테스트 · 빌드
|
||||
run: |
|
||||
node build/ci/lint-skill.mjs packages/skill
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
node packages/cli/scripts/verify-package.mjs
|
||||
|
||||
- name: tarball 생성
|
||||
run: |
|
||||
mkdir -p out
|
||||
cd packages/cli
|
||||
tarball="$(npm pack --silent)"
|
||||
mv "$tarball" "../../out/$tarball"
|
||||
echo "TARBALL=out/$tarball" >> "$GITHUB_ENV"
|
||||
|
||||
- name: npm 배포 (npmjs + Forgejo 미러)
|
||||
if: env.DO_PUBLISH == '1'
|
||||
env:
|
||||
NPM_TOKEN: "${{ secrets.NPM_TOKEN }}"
|
||||
FORGEJO_NPM_TOKEN: "${{ secrets.FORGEJO_NPM_TOKEN }}"
|
||||
FORGEJO_NPM_OWNER: "${{ vars.FORGEJO_NPM_OWNER }}"
|
||||
run: ./build/ci/publish-npm.sh packages/cli
|
||||
|
||||
- name: 릴리스 업로드 (draft)
|
||||
env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" }
|
||||
run: ./build/ci/upload-release-asset.sh "$RELTAG" "$TARBALL"
|
||||
|
||||
- name: 소개 페이지 배포 (Cloudflare Pages)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: "${{ secrets.CF_API_TOKEN }}"
|
||||
CLOUDFLARE_ACCOUNT_ID: "${{ secrets.CF_ACCOUNT_ID }}"
|
||||
run: |
|
||||
if [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then
|
||||
echo "CF_API_TOKEN 이 없다 — 페이지 배포를 건너뛴다"
|
||||
exit 0
|
||||
fi
|
||||
pnpm build:site
|
||||
npx --yes wrangler@latest pages deploy apps/site/dist \
|
||||
--project-name designpaca \
|
||||
--branch main \
|
||||
--commit-dirty=true
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
apps/site/.astro/
|
||||
apps/site/dist/
|
||||
# 리서치 원본 자막(용량) — 요약 문서는 커밋한다
|
||||
research/youtube/*.srt
|
||||
research/youtube/*.vtt
|
||||
3
.npmrc
Normal file
3
.npmrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
# 릴리스 프로비넌스는 CI에서만 켠다(로컬 publish 금지)
|
||||
74
README.md
Normal file
74
README.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# designpaca — 모노레포
|
||||
|
||||
웹 디자인 파이프라인 스킬과 그 설치 CLI.
|
||||
사용자용 문서는 [`packages/cli/README.md`](packages/cli/README.md) 를 봐라 — npm 페이지에 그대로 실린다.
|
||||
|
||||
```bash
|
||||
npx designpaca
|
||||
```
|
||||
|
||||
## 구조
|
||||
|
||||
```
|
||||
packages/
|
||||
skill/ @designpaca/skill — SKILL.md 와 references/. 이 저장소의 실질 가치
|
||||
core/ @designpaca/core — 설치 엔진: 타깃 어댑터, 매니페스트, 드리프트 감지
|
||||
cli/ designpaca — npx 진입점 + 온보딩 TUI. 유일하게 npm 에 배포되는 패키지
|
||||
apps/
|
||||
site/ @designpaca/site — 소개 페이지 (Astro → Cloudflare Pages)
|
||||
build/ci/ — CI 스크립트 (Node 검증, npm 배포, 릴리스 업로드, 스킬 검사)
|
||||
research/ — 스킬의 근거 자료. 약 250개 웹 소스 조사 결과
|
||||
docs/DEPLOYMENT_PLAN.md — 배포·릴리스 운영 절차
|
||||
```
|
||||
|
||||
`core` 와 `skill` 은 private 이다. CLI 빌드 시 `core` 는 번들에, `skill` 은 `dist/skill/` 로 복사된다.
|
||||
사용자는 `npx designpaca` 한 번으로 전부 받는다.
|
||||
|
||||
## 개발
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build # 스킬 번들 + CLI 빌드
|
||||
pnpm test # core 14 + cli 6
|
||||
pnpm typecheck
|
||||
pnpm dev:site # 소개 페이지 로컬
|
||||
```
|
||||
|
||||
### CLI 를 실제 경로로 테스트
|
||||
|
||||
`npx` 가 겪는 경로를 그대로 재현하려면 tarball 로 확인한다.
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
cd packages/cli && npm pack
|
||||
npx ./designpaca-0.1.0.tgz --help
|
||||
```
|
||||
|
||||
설치 왕복을 안전하게 시험하려면 홈 디렉터리를 바꿔라.
|
||||
|
||||
```bash
|
||||
HOME=/tmp/dp USERPROFILE=/tmp/dp node packages/cli/dist/index.js install -t claude-code -s user -y
|
||||
```
|
||||
|
||||
### 스킬 문서를 고쳤다면
|
||||
|
||||
```bash
|
||||
node build/ci/lint-skill.mjs packages/skill
|
||||
```
|
||||
|
||||
프론트매터 필수 필드, description 길이, 본문이 가리키는 참조 문서의 존재, 참조되지 않는 죽은 문서를 검사한다. CI 가 같은 것을 돌린다.
|
||||
|
||||
## 릴리스
|
||||
|
||||
```bash
|
||||
pnpm changeset # 변경 기록
|
||||
pnpm version # 버전 확정 (fixed 그룹이라 세 패키지가 함께 움직인다)
|
||||
git tag v0.1.0 && git push origin main --tags
|
||||
```
|
||||
|
||||
태그 푸시가 Forgejo Actions 를 발동시킨다: 검사 → 테스트 → 빌드 → npmjs 배포 + 사설 레지스트리 미러 → draft 릴리스 → Cloudflare Pages.
|
||||
시크릿 등록과 러너 전제는 [`docs/DEPLOYMENT_PLAN.md`](docs/DEPLOYMENT_PLAN.md) 에 있다.
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
9
apps/site/astro.config.mjs
Normal file
9
apps/site/astro.config.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://designpaca.chanpaca.net",
|
||||
// 정적 출력 — Cloudflare Pages 로 그대로 올린다
|
||||
output: "static",
|
||||
build: { format: "file" },
|
||||
devToolbar: { enabled: false },
|
||||
});
|
||||
15
apps/site/package.json
Normal file
15
apps/site/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@designpaca/site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"typecheck": "echo \"(사이트는 build 로 검증한다)\""
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.1.1"
|
||||
}
|
||||
}
|
||||
19
apps/site/src/pages/index.astro
Normal file
19
apps/site/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
const version = "0.1.0";
|
||||
---
|
||||
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>designpaca</title>
|
||||
<meta name="description" content="웹 디자인 전 과정을 끌고 가는 파이프라인 스킬" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>designpaca</h1>
|
||||
<p>웹 디자인 파이프라인 스킬 — v{version}</p>
|
||||
<code>npx designpaca</code>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
5
apps/site/tsconfig.json
Normal file
5
apps/site/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
80
build/ci/lint-skill.mjs
Normal file
80
build/ci/lint-skill.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// 스킬 문서의 최소 규약을 검사한다. CI 와 `pnpm test` 가 함께 쓴다.
|
||||
// - SKILL.md 존재와 프론트매터 필수 필드
|
||||
// - description 길이(트리거 정확도에 직접 영향)
|
||||
// - references 링크가 실제 파일을 가리키는지
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(process.argv[2] ?? ".");
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const skillPath = path.join(root, "SKILL.md");
|
||||
let md;
|
||||
try {
|
||||
md = await fs.readFile(skillPath, "utf8");
|
||||
} catch {
|
||||
console.error(`SKILL.md 를 찾을 수 없다: ${skillPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md);
|
||||
if (!fmMatch) {
|
||||
errors.push("프론트매터(---)가 없다");
|
||||
} else {
|
||||
const fm = Object.fromEntries(
|
||||
fmMatch[1]
|
||||
.split(/\r?\n/)
|
||||
.map((l) => /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(l))
|
||||
.filter(Boolean)
|
||||
.map((m) => [m[1], m[2].replace(/^["']|["']$/g, "")]),
|
||||
);
|
||||
if (fm.name !== "designpaca") errors.push(`name 이 designpaca 가 아니다: ${fm.name}`);
|
||||
if (!fm.description) errors.push("description 이 없다");
|
||||
else {
|
||||
const len = fm.description.length;
|
||||
if (len < 80) warnings.push(`description 이 짧다(${len}자) — 트리거 정확도가 떨어진다`);
|
||||
if (len > 700) warnings.push(`description 이 길다(${len}자) — 요약해라`);
|
||||
}
|
||||
}
|
||||
|
||||
// 본문이 참조하는 파일이 실제로 있는지 확인한다 (references/xxx.md 형태)
|
||||
const refs = [...md.matchAll(/references\/[A-Za-z0-9._/-]+\.md/g)].map((m) => m[0]);
|
||||
for (const rel of new Set(refs)) {
|
||||
try {
|
||||
await fs.access(path.join(root, rel));
|
||||
} catch {
|
||||
errors.push(`본문이 가리키는 참조 문서가 없다: ${rel}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 참조 문서가 본문 어디에서도 언급되지 않으면 죽은 문서다
|
||||
async function walk(dir, base = dir) {
|
||||
const out = [];
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...(await walk(full, base)));
|
||||
else out.push(path.relative(base, full).split(path.sep).join("/"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const refDir = path.join(root, "references");
|
||||
for (const f of await walk(refDir, root)) {
|
||||
if (!f.endsWith(".md")) continue;
|
||||
const rel = f.split(path.sep).join("/");
|
||||
if (!md.includes(rel)) warnings.push(`본문에서 참조되지 않는 문서: ${rel}`);
|
||||
}
|
||||
|
||||
for (const w of warnings) console.warn(` 경고: ${w}`);
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) console.error(` 오류: ${e}`);
|
||||
console.error(`\n스킬 검사 실패 — 오류 ${errors.length}건`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`스킬 검사 통과 (경고 ${warnings.length}건)`);
|
||||
67
build/ci/publish-npm.sh
Normal file
67
build/ci/publish-npm.sh
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env bash
|
||||
# designpaca 패키지를 npmjs 에 배포하고 Forgejo 사설 레지스트리에 미러링한다.
|
||||
#
|
||||
# 필요 환경:
|
||||
# NPM_TOKEN npmjs 자동화 토큰 (필수)
|
||||
# FORGEJO_NPM_TOKEN Forgejo 개인 액세스 토큰 (선택 — 없으면 미러를 건너뛴다)
|
||||
# FORGEJO_NPM_OWNER Forgejo 패키지 소유자 (기본: yunchan)
|
||||
# GITHUB_SERVER_URL Forgejo 주소 (Actions 가 준다)
|
||||
#
|
||||
# 이미 배포된 버전이면 조용히 건너뛴다 — 워크플로 재실행이 실패로 끝나지 않게.
|
||||
set -euo pipefail
|
||||
|
||||
PKG_DIR="${1:-packages/cli}"
|
||||
cd "$PKG_DIR"
|
||||
|
||||
name="$(node -p 'require("./package.json").name')"
|
||||
version="$(node -p 'require("./package.json").version')"
|
||||
echo "배포 대상: ${name}@${version}"
|
||||
|
||||
published() { # <registry-url>
|
||||
npm view "${name}@${version}" version --registry "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ── 1) npmjs ──
|
||||
if [ -z "${NPM_TOKEN:-}" ]; then
|
||||
echo "NPM_TOKEN 이 없다 — npmjs 배포를 건너뛴다" >&2
|
||||
else
|
||||
npmrc="$(mktemp)"
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$npmrc"
|
||||
export NPM_CONFIG_USERCONFIG="$npmrc"
|
||||
|
||||
if published "https://registry.npmjs.org/"; then
|
||||
echo "npmjs: ${version} 은 이미 배포돼 있다 — 건너뛴다"
|
||||
else
|
||||
npm publish --access public --registry "https://registry.npmjs.org/" --provenance=false
|
||||
echo "npmjs 배포 완료: ${name}@${version}"
|
||||
fi
|
||||
rm -f "$npmrc"
|
||||
unset NPM_CONFIG_USERCONFIG
|
||||
fi
|
||||
|
||||
# ── 2) Forgejo 미러 ──
|
||||
if [ -z "${FORGEJO_NPM_TOKEN:-}" ]; then
|
||||
echo "FORGEJO_NPM_TOKEN 이 없다 — 사설 레지스트리 미러를 건너뛴다"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
owner="${FORGEJO_NPM_OWNER:-yunchan}"
|
||||
server="${GITHUB_SERVER_URL:-https://git.chanpaca.net}"
|
||||
host="${server#*://}"; host="${host%/}"
|
||||
registry="${server%/}/api/packages/${owner}/npm/"
|
||||
|
||||
npmrc="$(mktemp)"
|
||||
{
|
||||
echo "registry=${registry}"
|
||||
# 스킴을 뺀 형태로 적어야 Forgejo 가 인증을 인식한다
|
||||
echo "//${host}/api/packages/${owner}/npm/:_authToken=${FORGEJO_NPM_TOKEN}"
|
||||
} > "$npmrc"
|
||||
export NPM_CONFIG_USERCONFIG="$npmrc"
|
||||
|
||||
if published "$registry"; then
|
||||
echo "Forgejo: ${version} 은 이미 미러돼 있다 — 건너뛴다"
|
||||
else
|
||||
npm publish --registry "$registry"
|
||||
echo "Forgejo 미러 완료: ${registry}"
|
||||
fi
|
||||
rm -f "$npmrc"
|
||||
56
build/ci/upload-release-asset.sh
Normal file
56
build/ci/upload-release-asset.sh
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
# Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등).
|
||||
# 사용: build/ci/upload-release-asset.sh <릴리스태그> <파일...>
|
||||
# 필요 환경: RELEASE_TOKEN + (Actions 기본 제공) GITHUB_SERVER_URL, GITHUB_REPOSITORY
|
||||
# RELEASE_REPO 를 주면 해당 저장소로 업로드(공개 배포 저장소용), 없으면 현재 저장소
|
||||
# RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용)
|
||||
set -euo pipefail
|
||||
[ $# -ge 2 ] || { echo "사용법: $0 <태그> <파일...>" >&2; exit 2; }
|
||||
[ -n "${RELEASE_TOKEN:-}" ] || { echo "RELEASE_TOKEN 환경변수가 없습니다" >&2; exit 2; }
|
||||
tag="$1"; shift
|
||||
api="${GITHUB_SERVER_URL%/}/api/v1/repos/${RELEASE_REPO:-$GITHUB_REPOSITORY}"
|
||||
auth="Authorization: token ${RELEASE_TOKEN}"
|
||||
|
||||
# draft 릴리스는 tags/<tag> 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다
|
||||
# tr -d '\r': Windows Git Bash 에서 python 출력이 CRLF 로 나와 id 가 오염되는 것 방지
|
||||
find_release() {
|
||||
curl -sf -H "$auth" "$api/releases?limit=50" | python3 -c '
|
||||
import json, sys
|
||||
tag = sys.argv[1]
|
||||
for r in json.load(sys.stdin):
|
||||
if r.get("tag_name") == tag:
|
||||
print(r["id"]); break' "$tag" | tr -d '\r'
|
||||
}
|
||||
|
||||
rid="$(find_release || true)"
|
||||
if [ -z "$rid" ]; then
|
||||
body=$(printf '{"tag_name":"%s","name":"%s","draft":true,"prerelease":%s}' \
|
||||
"$tag" "$tag" "${RELEASE_PRERELEASE:-false}")
|
||||
rid=$(curl -s -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" \
|
||||
| python3 -c 'import json,sys
|
||||
d = json.load(sys.stdin)
|
||||
print(d["id"] if isinstance(d, dict) and "id" in d else "")' | tr -d '\r' || true)
|
||||
[ -n "$rid" ] || rid="$(find_release || true)" # 병렬 잡 동시 생성 경합 대비
|
||||
fi
|
||||
[ -n "$rid" ] || { echo "릴리스 확보 실패: $tag" >&2; exit 1; }
|
||||
echo "릴리스 #$rid ($tag)"
|
||||
|
||||
for f in "$@"; do
|
||||
name=$(basename "$f")
|
||||
# 재실행 멱등성: 동명 자산 제거 후 업로드
|
||||
curl -sf -H "$auth" "$api/releases/$rid/assets" | python3 -c '
|
||||
import json, sys
|
||||
for a in json.load(sys.stdin):
|
||||
if a.get("name") == sys.argv[1]:
|
||||
print(a["id"])' "$name" | tr -d '\r' | while read -r aid; do
|
||||
[ -n "$aid" ] && curl -sf -X DELETE -H "$auth" "$api/releases/$rid/assets/$aid" || true
|
||||
done
|
||||
resp=$(mktemp)
|
||||
code=$(curl -s -o "$resp" -w '%{http_code}' -X POST -H "$auth" \
|
||||
-F "attachment=@$f" "$api/releases/$rid/assets?name=$name")
|
||||
if [ "$code" != 201 ]; then
|
||||
echo "업로드 실패($code): $name" >&2; cat "$resp" >&2; rm -f "$resp"; exit 1
|
||||
fi
|
||||
rm -f "$resp"
|
||||
echo "업로드 완료: $name ($(du -h "$f" | cut -f1))"
|
||||
done
|
||||
43
build/ci/verify-node.sh
Normal file
43
build/ci/verify-node.sh
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bash
|
||||
# host 모드 러너에는 Node 가 없을 수 있다. 여기서 명확히 실패시켜 CI 로그를 읽을 수 있게 한다.
|
||||
# 필요하면 러너에 nvm/fnm 으로 설치된 Node 를 PATH 에 올려준다.
|
||||
set -euo pipefail
|
||||
|
||||
MIN_MAJOR=20
|
||||
|
||||
add_path() { echo "$1" >> "${GITHUB_PATH:-/dev/null}"; export PATH="$1:$PATH"; }
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
# 러너에 흔히 있는 위치를 훑는다
|
||||
for cand in "$HOME/.nvm/versions/node"/*/bin "$HOME/.local/share/fnm/node-versions"/*/installation/bin "$HOME/.volta/bin"; do
|
||||
[ -d "$cand" ] && add_path "$cand" && break
|
||||
done
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
cat >&2 <<'MSG'
|
||||
Node 를 찾을 수 없다.
|
||||
|
||||
이 워크플로는 self-hosted host 러너에서 돈다. 러너 머신에 Node 20 이상을 설치하고
|
||||
러너 사용자의 PATH 에 올려라. 예: fnm install 22 && fnm default 22
|
||||
MSG
|
||||
exit 1
|
||||
fi
|
||||
|
||||
major="$(node -p 'process.versions.node.split(".")[0]')"
|
||||
if [ "$major" -lt "$MIN_MAJOR" ]; then
|
||||
echo "Node $MIN_MAJOR 이상이 필요하다 (현재 $(node -v))" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# pnpm 은 corepack 으로 확보한다 — 러너에 전역 설치를 요구하지 않는다
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
corepack enable >/dev/null 2>&1 || true
|
||||
corepack prepare pnpm@10.5.2 --activate >/dev/null 2>&1 || true
|
||||
fi
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "pnpm 을 확보하지 못했다. corepack 이 막혀 있으면 npm i -g pnpm@10.5.2 로 설치해라" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "node $(node -v) / pnpm $(pnpm -v) / npm $(npm -v)"
|
||||
130
docs/DEPLOYMENT_PLAN.md
Normal file
130
docs/DEPLOYMENT_PLAN.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# designpaca 배포 운영 계획
|
||||
|
||||
이 문서는 designpaca를 git.chanpaca.net(Forgejo)에서 빌드·배포·릴리스하기 위한 전제와 절차를 적는다.
|
||||
한 번 읽고 그대로 따라 하면 첫 릴리스까지 갈 수 있어야 한다.
|
||||
|
||||
## 0. 전제 — 러너 환경
|
||||
|
||||
CI는 self-hosted **`linux-builder`** 러너(host 모드)에서 돈다. host 모드라 러너 머신에 있는 것만 쓸 수 있다.
|
||||
|
||||
| 필요 | 확인 | 없을 때 |
|
||||
|---|---|---|
|
||||
| Node 20 이상 | `node -v` | `fnm install 22 && fnm default 22` 후 러너 재시작 |
|
||||
| pnpm 10 | `pnpm -v` | `corepack enable && corepack prepare pnpm@10.5.2 --activate` |
|
||||
| git, curl, tar, python3 | `command -v` | 배포판 패키지로 설치 |
|
||||
|
||||
`build/ci/verify-node.sh`가 매 잡 첫 스텝에서 이걸 검사하고, 없으면 **명확한 메시지로 즉시 실패**시킨다.
|
||||
nvm/fnm/volta의 흔한 설치 경로는 자동으로 PATH에 올려본다.
|
||||
|
||||
> host 러너에는 Node가 없을 수 있으므로 워크플로에서 `uses:` 액션을 쓰지 않는다.
|
||||
> 체크아웃도 `run:` 스텝의 git 명령으로 처리한다 (videodownloader 파이프라인과 동일한 패턴).
|
||||
|
||||
## 1. 저장소
|
||||
|
||||
```
|
||||
git.chanpaca.net/<owner>/designpaca
|
||||
```
|
||||
|
||||
- 소스 공개 여부는 자유. 단 **npm 배포는 npmjs 공개 레지스트리**이므로 코드는 어차피 tarball로 공개된다
|
||||
- 기본 브랜치: `main`
|
||||
- 태그 규칙: `v<semver>` (예: `v0.1.0`) — 태그 푸시가 릴리스 트리거
|
||||
|
||||
## 2. 시크릿 등록
|
||||
|
||||
저장소 → Settings → Actions → Secrets 에 등록한다.
|
||||
|
||||
| 이름 | 용도 | 발급처 |
|
||||
|---|---|---|
|
||||
| `NPM_TOKEN` | npmjs 배포 | npmjs.com → Access Tokens → **Automation** 타입 |
|
||||
| `FORGEJO_NPM_TOKEN` | 사설 레지스트리 미러 | Forgejo → 설정 → 애플리케이션 → 액세스 토큰 (`write:package` 권한) |
|
||||
| `RELEASE_TOKEN` | 릴리스 생성·자산 업로드 | Forgejo 액세스 토큰 (`write:repository`) |
|
||||
| `CF_API_TOKEN` | Cloudflare Pages 배포 | Cloudflare → API Tokens → **Edit Cloudflare Workers** 템플릿 |
|
||||
| `CF_ACCOUNT_ID` | Cloudflare 계정 식별 | Cloudflare 대시보드 우측 하단 Account ID |
|
||||
|
||||
변수(Variables)에 선택적으로:
|
||||
|
||||
| 이름 | 기본값 | 설명 |
|
||||
|---|---|---|
|
||||
| `FORGEJO_NPM_OWNER` | `yunchan` | 사설 npm 패키지 소유자 |
|
||||
|
||||
> **시크릿이 없어도 파이프라인은 죽지 않는다.** `publish-npm.sh`는 `FORGEJO_NPM_TOKEN`이 없으면 미러를 건너뛰고,
|
||||
> 릴리스 스텝은 `CF_API_TOKEN`이 없으면 페이지 배포를 건너뛴다. 하나씩 붙여가며 굴릴 수 있다.
|
||||
|
||||
## 3. 릴리스 절차
|
||||
|
||||
### 3.1 변경 기록
|
||||
```bash
|
||||
pnpm changeset # 무엇이 바뀌었는지, patch/minor/major 중 무엇인지 기록
|
||||
git add .changeset && git commit -m "changeset: <요약>"
|
||||
```
|
||||
|
||||
### 3.2 버전 확정
|
||||
```bash
|
||||
pnpm version # changeset version + lockfile 갱신
|
||||
# packages/*/package.json 과 CHANGELOG.md 가 갱신된다
|
||||
git add -A && git commit -m "release: v<새 버전>"
|
||||
```
|
||||
`designpaca` · `@designpaca/core` · `@designpaca/skill`은 changesets의 **fixed 그룹**이라 항상 같은 버전으로 움직인다.
|
||||
스킬 내용과 CLI 버전이 어긋나면 업그레이드 판정(`.designpaca_version` 비교)이 깨지기 때문이다.
|
||||
|
||||
### 3.3 태그 푸시 = 릴리스 발동
|
||||
```bash
|
||||
git tag v0.1.0 && git push origin main --tags
|
||||
```
|
||||
|
||||
파이프라인이 순서대로 수행한다:
|
||||
1. `verify-node.sh` → 의존성 설치
|
||||
2. **태그와 `packages/cli/package.json` 버전 일치 확인** (다르면 즉시 실패)
|
||||
3. 스킬 문서 검사 → 타입 검사 → 테스트 → 빌드 → 배포물 점검
|
||||
4. `npm publish` (npmjs) → Forgejo 레지스트리 미러. **이미 배포된 버전이면 조용히 건너뛴다**(재실행 안전)
|
||||
5. Forgejo **draft 릴리스** 생성 + tarball 첨부
|
||||
6. Cloudflare Pages 배포
|
||||
|
||||
### 3.4 승격
|
||||
draft 릴리스를 확인하고 수동으로 **Publish**한다. draft는 익명에게 보이지 않으므로 QA 시간을 벌 수 있다.
|
||||
|
||||
### 3.5 리허설
|
||||
태그 없이 돌려볼 수 있다. Actions → release → **Run workflow**.
|
||||
`ci-rehearsal` prerelease 릴리스로 전 과정을 검증하되 **npm 배포는 건너뛴다**(`DO_PUBLISH=0`).
|
||||
|
||||
## 4. 사용자 설치 경로
|
||||
|
||||
```bash
|
||||
npx designpaca # 온보딩 TUI
|
||||
npx designpaca install -t claude-code,codex -s user -y
|
||||
```
|
||||
|
||||
사설 레지스트리에서 직접 받으려면:
|
||||
```bash
|
||||
npx --registry=https://git.chanpaca.net/api/packages/<owner>/npm/ designpaca
|
||||
```
|
||||
|
||||
## 5. 소개 페이지
|
||||
|
||||
- 배포 대상: Cloudflare Pages 프로젝트 **`designpaca`**
|
||||
- 도메인: `designpaca.chanpaca.net` (Cloudflare DNS에 CNAME → Pages)
|
||||
- 최초 1회만 수동: Cloudflare 대시보드에서 Pages 프로젝트 생성(빈 프로젝트) → 커스텀 도메인 연결.
|
||||
이후에는 릴리스 워크플로의 `wrangler pages deploy`가 알아서 올린다
|
||||
|
||||
## 6. 장애 대응
|
||||
|
||||
| 증상 | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
| `Node 를 찾을 수 없다` | host 러너에 Node 미설치 | 러너 머신에 fnm으로 Node 22 설치 후 러너 재시작 |
|
||||
| `태그와 package.json 이 다르다` | `pnpm version` 없이 태그를 밀었다 | 태그 삭제 → 3.2부터 다시 |
|
||||
| npm publish 403 | 토큰 만료 / 이름 선점 | `NPM_TOKEN` 재발급, `npm view designpaca` 로 소유 확인 |
|
||||
| Forgejo 미러 401 | 토큰에 `write:package` 없음 | 토큰 권한 재발급 |
|
||||
| 릴리스 자산 업로드 실패 | `RELEASE_TOKEN` 권한 부족 | `write:repository` 부여 |
|
||||
| Pages 배포 스킵 | `CF_API_TOKEN` 미등록 | 시크릿 등록 (없어도 나머지는 성공한다) |
|
||||
|
||||
## 7. 롤백
|
||||
|
||||
npm은 배포 취소가 사실상 불가능하다(72시간 이내 unpublish만 가능, 같은 버전 재사용 금지).
|
||||
따라서 **되돌리기가 아니라 앞으로 감는다**:
|
||||
|
||||
```bash
|
||||
# 문제 버전을 latest 에서 내린다
|
||||
npm dist-tag add designpaca@<직전 정상 버전> latest
|
||||
# 수정 후 새 패치 릴리스
|
||||
```
|
||||
사용자 쪽 복구는 `npx designpaca@<정상버전> update --force`.
|
||||
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "designpaca-monorepo",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "designpaca — 웹 디자인 파이프라인 스킬과 설치 CLI",
|
||||
"packageManager": "pnpm@10.5.2",
|
||||
"engines": {
|
||||
"node": ">=20.11"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -r --filter \"./packages/**\" build",
|
||||
"build:site": "pnpm --filter @designpaca/site build",
|
||||
"dev": "pnpm --filter designpaca dev",
|
||||
"dev:site": "pnpm --filter @designpaca/site dev",
|
||||
"typecheck": "pnpm -r --filter \"./packages/**\" typecheck",
|
||||
"test": "pnpm -r test",
|
||||
"lint": "pnpm -r lint",
|
||||
"changeset": "changeset",
|
||||
"version": "changeset version && pnpm install --lockfile-only",
|
||||
"release": "pnpm build && changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.10",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
116
packages/cli/README.md
Normal file
116
packages/cli/README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# designpaca
|
||||
|
||||
**웹 디자인 파이프라인 스킬을 Claude Code · Codex · Cursor 에 한 줄로 설치한다.**
|
||||
|
||||
```bash
|
||||
npx designpaca
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 무엇인가
|
||||
|
||||
designpaca 는 AI 코딩 에이전트에게 **웹 디자인을 순서대로 하게 만드는 스킬**이다.
|
||||
|
||||
"예쁘게 만들어줘"라고 하면 에이전트는 자기가 아는 기본값을 쏟아낸다. 보라-파랑 그라디언트, Inter, 3열 아이콘 카드, `rounded-2xl shadow-lg`. 못 만들어서가 아니라 **결정을 안 해서** 그렇게 된다. designpaca 는 그 결정을 강제한다.
|
||||
|
||||
### 7단계 파이프라인
|
||||
|
||||
| 단계 | 하는 일 | 통과 조건 |
|
||||
|---|---|---|
|
||||
| 0 | 브리프 게이트 | 무엇을·누구에게·제약 3줄 |
|
||||
| 1 | **레퍼런스 조사** | 실제 사이트 3개를 서로 다른 층위에서 |
|
||||
| 2 | 방향 결정 | 한 문장 컨셉 + 프리셋 + 감수할 리스크 하나 |
|
||||
| 3 | 디자인 토큰 | 타입·색·간격·모션을 실제 값으로 + 성능 예산 |
|
||||
| 4 | 구현 | 레이아웃 → 재질(SVG 필터) → 입체(three.js) → 모션 |
|
||||
| 5 | 프리플라이트 | 하드 게이트 12개 + 슬롭 지문 grep |
|
||||
| 6 | 결정 기록 | `design.md` 로 다음 실행에 인계 |
|
||||
|
||||
1단계를 건너뛴 디자인은 5단계에서 실패 처리된다. **머릿속 기본값이 아니라 실제로 존재하는 사이트에서 시작하게 만드는 것**이 이 스킬의 핵심이다.
|
||||
|
||||
### 무엇이 들어 있나
|
||||
|
||||
- **레퍼런스 갤러리 라우팅** — 브리프별로 어디를 볼지. Awwwards 수상작이 럭셔리·자동차에 몰려 있어 SaaS 브리프에 이식하면 안 된다는 것까지
|
||||
- **AI 슬롭 지문 목록** — 실측 검출률과 함께. 보라 CTA 10.7%, 전체 대문자 헤드라인 10.5%, 번호 매긴 1·2·3 단계 9.4%. grep 으로 기계 검출 가능
|
||||
- **SVG 필터를 재질로** — 그레인·굴절·잉크 번짐·수차. 장식이 아니라 "이 디자인의 표면은 무엇인가"를 정하는 도구로
|
||||
- **three.js를 예산 안에서** — 지연 로드, DPR 클램프, 품질 티어, 폴백. 캔버스를 LCP 요소로 만들지 않는 규칙
|
||||
- **인터랙티브 모션** — 이징의 의미론, 마이크로 인터랙션, `prefers-reduced-motion` 의 올바른 대응
|
||||
- **한글 조판** — `word-break: keep-all`, line-height 1.6~1.8, 음수 자간 금지, 한글 폰트 페어링. 서구 레퍼런스에 없는 정보
|
||||
- **HTML-in-Canvas** — Chrome 실험 API. 폴백을 완성한 뒤에만 얹도록 격리
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
npx designpaca # 대화형 온보딩 (권장)
|
||||
```
|
||||
|
||||
설치 대상을 자동 감지해서 기본 선택해준다.
|
||||
|
||||
### 비대화형
|
||||
|
||||
```bash
|
||||
npx designpaca install -t claude-code,codex -s user -y
|
||||
npx designpaca install -t cursor -s project
|
||||
```
|
||||
|
||||
| 대상 | 설치 위치 |
|
||||
|---|---|
|
||||
| `claude-code` | `~/.claude/skills/designpaca/` (또는 프로젝트 `.claude/`) |
|
||||
| `codex` | `~/.codex/skills/designpaca/` |
|
||||
| `cursor` | `.cursor/rules/designpaca.mdc` (프로젝트 단위) |
|
||||
| `agents-md` | `AGENTS.md` 에 블록 주입 |
|
||||
|
||||
## 사용
|
||||
|
||||
설치 후 에이전트에게 그냥 말하면 된다.
|
||||
|
||||
```
|
||||
랜딩 페이지 만들어줘 — 수제 커피 로스터리, 원두 정기구독이 목적
|
||||
```
|
||||
|
||||
Claude Code 에서는 `/designpaca` 로 명시적으로 부를 수도 있다.
|
||||
|
||||
## 명령어
|
||||
|
||||
```bash
|
||||
npx designpaca update # 최신 스킬로 갱신
|
||||
npx designpaca doctor # 설치 상태 진단
|
||||
npx designpaca list # 설치 가능 대상과 현재 설치 목록
|
||||
npx designpaca uninstall # 제거
|
||||
```
|
||||
|
||||
### 업데이트가 내 수정을 덮지 않는다
|
||||
|
||||
스킬 파일을 직접 고쳤다면 `update` 는 **그 파일을 건드리지 않고 건너뛴다.** 설치 시점의 해시를 기록해두고 비교하기 때문이다.
|
||||
|
||||
강제로 최신 내용에 맞추려면 `--force` 를 쓴다. 이때도 원본은 `.orig` 로 남는다.
|
||||
|
||||
```bash
|
||||
npx designpaca update --force
|
||||
```
|
||||
|
||||
`AGENTS.md` 처럼 사용자 문서에 주입하는 경우, 마커 블록(`<!-- designpaca:start -->`) **안쪽만** 교체한다. 문서의 나머지는 손대지 않는다. 제거할 때도 블록만 빼간다.
|
||||
|
||||
## 옵션
|
||||
|
||||
| 옵션 | 설명 |
|
||||
|---|---|
|
||||
| `-t, --target <id[,id]>` | `claude-code` `codex` `cursor` `agents-md` |
|
||||
| `-s, --scope <범위>` | `user` (전역) \| `project` |
|
||||
| `-y, --yes` | 확인 없이 진행 |
|
||||
| `-f, --force` | 직접 수정한 파일도 덮어쓴다 (`.orig` 백업) |
|
||||
| `--dry-run` | 쓰지 않고 계획만 출력 |
|
||||
| `--no-update-check` | 새 버전 확인 생략 |
|
||||
|
||||
## 요구사항
|
||||
|
||||
Node 20.11 이상.
|
||||
|
||||
## 링크
|
||||
|
||||
- 소개 페이지 — https://designpaca.chanpaca.net
|
||||
- 저장소 — https://git.chanpaca.net/yunchan/designpaca
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
32
packages/cli/package.json
Normal file
32
packages/cli/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "designpaca",
|
||||
"version": "0.1.0",
|
||||
"description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다",
|
||||
"keywords": ["design", "web-design", "claude-code", "codex", "cursor", "agent-skill", "svg-filter", "threejs"],
|
||||
"license": "MIT",
|
||||
"author": "Yun Chan",
|
||||
"homepage": "https://designpaca.chanpaca.net",
|
||||
"repository": { "type": "git", "url": "git+https://git.chanpaca.net/yunchan/designpaca.git" },
|
||||
"type": "module",
|
||||
"bin": { "designpaca": "./dist/index.js" },
|
||||
"files": ["dist", "README.md"],
|
||||
"engines": { "node": ">=20.11" },
|
||||
"scripts": {
|
||||
"build": "node scripts/bundle-skill.mjs && tsup",
|
||||
"dev": "node scripts/bundle-skill.mjs && tsup --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types --disable-warning=ExperimentalWarning test/*.test.ts",
|
||||
"prepublishOnly": "node scripts/verify-package.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.9.1",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@designpaca/core": "workspace:*",
|
||||
"@designpaca/skill": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
30
packages/cli/scripts/bundle-skill.mjs
Normal file
30
packages/cli/scripts/bundle-skill.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// packages/skill 의 내용을 CLI 배포물 안(dist/skill)으로 복사한다.
|
||||
// 스킬 본문이 npm 패키지에 함께 실려야 npx 한 번으로 설치가 끝난다.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const src = path.resolve(here, "../../skill");
|
||||
const dest = path.resolve(here, "../dist/skill");
|
||||
|
||||
async function copyDir(from, to) {
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
for (const e of await fs.readdir(from, { withFileTypes: true })) {
|
||||
if (e.name === "node_modules" || e.name === "package.json") continue;
|
||||
const s = path.join(from, e.name);
|
||||
const d = path.join(to, e.name);
|
||||
if (e.isDirectory()) await copyDir(s, d);
|
||||
else await fs.copyFile(s, d);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dest, { recursive: true, force: true });
|
||||
await copyDir(src, dest);
|
||||
|
||||
// 스킬 버전은 CLI 버전과 항상 같다(changesets fixed 그룹) — 여기서 스탬프를 찍어 런타임 조회를 없앤다
|
||||
const pkg = JSON.parse(await fs.readFile(path.resolve(here, "../package.json"), "utf8"));
|
||||
await fs.writeFile(path.join(dest, ".designpaca_version"), `${pkg.version}\n`, "utf8");
|
||||
|
||||
const count = (await fs.readdir(dest, { recursive: true })).length;
|
||||
console.log(`스킬 번들 완료: ${count}개 항목 → dist/skill`);
|
||||
29
packages/cli/scripts/verify-package.mjs
Normal file
29
packages/cli/scripts/verify-package.mjs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// publish 직전 최종 점검 — 스킬이 빠진 채로 배포되는 사고를 막는다.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const must = ["dist/index.js", "dist/skill/SKILL.md", "dist/skill/.designpaca_version"];
|
||||
|
||||
const missing = [];
|
||||
for (const rel of must) {
|
||||
try {
|
||||
await fs.access(path.join(root, rel));
|
||||
} catch {
|
||||
missing.push(rel);
|
||||
}
|
||||
}
|
||||
if (missing.length) {
|
||||
console.error(`배포물에 빠진 파일:\n${missing.map((m) => ` - ${m}`).join("\n")}`);
|
||||
console.error("pnpm build 를 먼저 실행해라.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
|
||||
const stamp = (await fs.readFile(path.join(root, "dist/skill/.designpaca_version"), "utf8")).trim();
|
||||
if (stamp !== pkg.version) {
|
||||
console.error(`버전 불일치: package.json=${pkg.version} / 스킬 스탬프=${stamp}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`배포 점검 통과 (v${pkg.version})`);
|
||||
55
packages/cli/src/commands/doctor.ts
Normal file
55
packages/cli/src/commands/doctor.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { inspectDrift, pruneDeadInstalls, readManifest, tildify } from "@designpaca/core";
|
||||
import { getVersion } from "../skill.ts";
|
||||
import { bad, dim, heading, info, ok, table, warn } from "../ui.ts";
|
||||
|
||||
export async function runDoctor(): Promise<number> {
|
||||
const version = await getVersion();
|
||||
const pruned = await pruneDeadInstalls();
|
||||
const manifest = await readManifest();
|
||||
|
||||
console.log(heading("designpaca 진단"));
|
||||
console.log(
|
||||
table([
|
||||
["CLI 버전", version],
|
||||
["Node", process.version],
|
||||
["설치 기록", `${manifest.installs.length}건`],
|
||||
]),
|
||||
);
|
||||
if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`));
|
||||
|
||||
if (manifest.installs.length === 0) {
|
||||
console.log(warn("설치된 곳이 없다. `npx designpaca install` 로 설치해라."));
|
||||
return 0;
|
||||
}
|
||||
|
||||
let problems = 0;
|
||||
for (const rec of manifest.installs) {
|
||||
const drift = await inspectDrift(rec);
|
||||
const missing = drift.files.filter((f) => f.status === "missing");
|
||||
const modified = drift.files.filter((f) => f.status === "modified");
|
||||
|
||||
console.log(heading(`${rec.target} (${rec.scope})`));
|
||||
console.log(
|
||||
table([
|
||||
["위치", tildify(rec.root)],
|
||||
["버전", rec.version === version ? rec.version : `${rec.version} → ${version} 업데이트 가능`],
|
||||
["파일", `${rec.files.length}개`],
|
||||
]),
|
||||
);
|
||||
|
||||
if (rec.version !== version) problems++;
|
||||
if (missing.length > 0) {
|
||||
problems++;
|
||||
console.log(bad(`파일 ${missing.length}개가 사라졌다 — \`designpaca update --force\` 로 복구해라`));
|
||||
for (const m of missing.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
|
||||
}
|
||||
if (modified.length > 0) {
|
||||
console.log(warn(`직접 수정한 파일 ${modified.length}개 — 업데이트가 이 파일들을 건너뛴다`));
|
||||
for (const m of modified.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
|
||||
}
|
||||
if (missing.length === 0 && modified.length === 0 && rec.version === version) {
|
||||
console.log(ok("정상"));
|
||||
}
|
||||
}
|
||||
return problems > 0 ? 1 : 0;
|
||||
}
|
||||
90
packages/cli/src/commands/install.ts
Normal file
90
packages/cli/src/commands/install.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
applyPlan,
|
||||
planInstall,
|
||||
tildify,
|
||||
type Scope,
|
||||
type TargetId,
|
||||
} from "@designpaca/core";
|
||||
import { getSkill } from "../skill.ts";
|
||||
import { bad, dim, fold, heading, info, ok, warn } from "../ui.ts";
|
||||
|
||||
export interface InstallOptions {
|
||||
targets: TargetId[];
|
||||
scope: Scope;
|
||||
force?: boolean;
|
||||
/** 실제로 쓰지 않고 계획만 출력 */
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface InstallOutcome {
|
||||
target: TargetId;
|
||||
root: string;
|
||||
written: number;
|
||||
skipped: string[];
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
/** install/update/TUI 가 공유하는 실제 설치 실행부 */
|
||||
export async function runInstall(opts: InstallOptions): Promise<InstallOutcome[]> {
|
||||
const skill = await getSkill();
|
||||
const outcomes: InstallOutcome[] = [];
|
||||
|
||||
for (const target of opts.targets) {
|
||||
const plan = await planInstall(target, opts.scope, skill);
|
||||
if (plan.blocked) {
|
||||
outcomes.push({ target, root: "", written: 0, skipped: [], blocked: plan.blocked });
|
||||
continue;
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
outcomes.push({ target, root: plan.root, written: plan.actions.length, skipped: [] });
|
||||
continue;
|
||||
}
|
||||
const res = await applyPlan(plan, skill.version, { force: opts.force });
|
||||
outcomes.push({
|
||||
target,
|
||||
root: plan.root,
|
||||
written: res.written.length,
|
||||
skipped: res.skipped,
|
||||
});
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void {
|
||||
console.log(heading(dryRun ? "설치 계획" : "설치 결과"));
|
||||
for (const o of outcomes) {
|
||||
if (o.blocked) {
|
||||
console.log(bad(`${o.target}: ${o.blocked}`));
|
||||
continue;
|
||||
}
|
||||
const verb = dryRun ? "쓸 파일" : "설치됨";
|
||||
console.log(ok(`${o.target} — ${verb} ${o.written}개 ${dim(tildify(o.root))}`));
|
||||
if (o.skipped.length > 0) {
|
||||
console.log(warn(` 직접 수정한 파일이라 건드리지 않았다 (--force 로 덮어쓴다):`));
|
||||
for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 설치 후 안내 — 여기서 끝내지 말고 다음 행동을 알려준다 */
|
||||
export function printNextSteps(targets: TargetId[]): void {
|
||||
console.log(heading("다음 단계"));
|
||||
if (targets.includes("claude-code")) {
|
||||
console.log(info(`Claude Code 를 새로 열고 ${dim("/designpaca")} 를 실행해라`));
|
||||
}
|
||||
if (targets.includes("codex")) {
|
||||
console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`));
|
||||
}
|
||||
if (targets.includes("cursor")) {
|
||||
console.log(info(`Cursor 는 ${dim(".cursor/rules/designpaca.mdc")} 를 프로젝트 열 때 읽는다`));
|
||||
}
|
||||
if (targets.includes("windsurf")) {
|
||||
console.log(info(`Windsurf 는 ${dim(".windsurf/rules/designpaca.md")} 를 읽는다`));
|
||||
}
|
||||
if (targets.includes("agents-md")) {
|
||||
console.log(
|
||||
info(`AGENTS.md 에는 포인터만 넣었다 — 본문은 ${dim(".designpaca/SKILL.md")} 에 있다`),
|
||||
);
|
||||
}
|
||||
console.log(info(`문서: ${dim("https://designpaca.chanpaca.net")}`));
|
||||
}
|
||||
30
packages/cli/src/commands/list.ts
Normal file
30
packages/cli/src/commands/list.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { ADAPTERS, readManifest, tildify } from "@designpaca/core";
|
||||
import { getVersion } from "../skill.ts";
|
||||
import { dim, heading, info, ok, padDisplay, table } from "../ui.ts";
|
||||
|
||||
export async function runList(): Promise<number> {
|
||||
const version = await getVersion();
|
||||
const manifest = await readManifest();
|
||||
|
||||
console.log(heading("설치 가능한 대상"));
|
||||
for (const a of ADAPTERS) {
|
||||
console.log(` ${padDisplay(a.label, 18)} ${dim(a.hint)} ${dim(`[${a.scopes.join("|")}]`)}`);
|
||||
}
|
||||
|
||||
console.log(heading(`현재 설치 (CLI v${version})`));
|
||||
if (manifest.installs.length === 0) {
|
||||
console.log(info("없음"));
|
||||
return 0;
|
||||
}
|
||||
for (const rec of manifest.installs) {
|
||||
console.log(ok(`${rec.target} (${rec.scope})`));
|
||||
console.log(
|
||||
table([
|
||||
["위치", tildify(rec.root)],
|
||||
["버전", rec.version],
|
||||
["설치 시각", new Date(rec.installedAt).toLocaleString("ko-KR")],
|
||||
]),
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
30
packages/cli/src/commands/uninstall.ts
Normal file
30
packages/cli/src/commands/uninstall.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { readManifest, removeInstall, tildify, type TargetId } from "@designpaca/core";
|
||||
import { dim, heading, ok, warn } from "../ui.ts";
|
||||
|
||||
export async function runUninstall(opts: {
|
||||
targets?: TargetId[];
|
||||
force?: boolean;
|
||||
}): Promise<number> {
|
||||
const manifest = await readManifest();
|
||||
const targets = manifest.installs.filter(
|
||||
(i) => !opts.targets || opts.targets.length === 0 || opts.targets.includes(i.target),
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.log(warn("제거할 설치 기록이 없다"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(heading("제거"));
|
||||
for (const rec of targets) {
|
||||
const res = await removeInstall(rec, { force: opts.force });
|
||||
console.log(ok(`${rec.target} (${rec.scope}) — ${res.removed.length}개 제거 ${dim(tildify(rec.root))}`));
|
||||
if (res.keptModified.length > 0) {
|
||||
console.log(
|
||||
warn(` 직접 수정한 파일 ${res.keptModified.length}개는 남겼다 (--force 로 함께 지운다)`),
|
||||
);
|
||||
for (const p of res.keptModified.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
55
packages/cli/src/commands/update.ts
Normal file
55
packages/cli/src/commands/update.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { inspectDrift, readManifest, tildify } from "@designpaca/core";
|
||||
import { getVersion } from "../skill.ts";
|
||||
import { runInstall, printOutcomes } from "./install.ts";
|
||||
import { dim, heading, info, ok, warn } from "../ui.ts";
|
||||
|
||||
/**
|
||||
* 설치 기록을 그대로 따라가며 재설치한다.
|
||||
* 사용자가 고친 파일은 기본적으로 보존된다(applyPlan 이 판단) — 강제로 맞추려면 --force.
|
||||
*/
|
||||
export async function runUpdate(opts: { force?: boolean } = {}): Promise<number> {
|
||||
const version = await getVersion();
|
||||
const manifest = await readManifest();
|
||||
|
||||
if (manifest.installs.length === 0) {
|
||||
console.log(warn("설치 기록이 없다. `npx designpaca install` 을 먼저 실행해라."));
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log(heading(`업데이트 → v${version}`));
|
||||
const stale = manifest.installs.filter((i) => i.version !== version);
|
||||
|
||||
if (stale.length === 0) {
|
||||
console.log(ok("모든 설치가 이미 최신이다"));
|
||||
|
||||
// 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다.
|
||||
let broken = 0;
|
||||
for (const rec of manifest.installs) {
|
||||
const drift = await inspectDrift(rec);
|
||||
const missing = drift.files.filter((f) => f.status === "missing");
|
||||
if (missing.length > 0) {
|
||||
broken += missing.length;
|
||||
console.log(warn(`${rec.target}: 파일 ${missing.length}개가 사라졌다 ${dim(tildify(rec.root))}`));
|
||||
}
|
||||
if (drift.modified.length > 0) {
|
||||
console.log(info(`${rec.target}: 직접 수정한 파일 ${drift.modified.length}개는 그대로 둔다`));
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.force) {
|
||||
console.log(dim(broken > 0 ? " 사라진 파일을 복구하려면 --force" : " 강제로 다시 쓰려면 --force"));
|
||||
return broken > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (const rec of manifest.installs) {
|
||||
console.log(info(`${rec.target} (${rec.scope}) ${dim(tildify(rec.root))} — ${rec.version} → ${version}`));
|
||||
const outcomes = await runInstall({
|
||||
targets: [rec.target],
|
||||
scope: rec.scope,
|
||||
force: opts.force,
|
||||
});
|
||||
printOutcomes(outcomes);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
177
packages/cli/src/index.ts
Normal file
177
packages/cli/src/index.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { ADAPTERS, type Scope, type TargetId } from "@designpaca/core";
|
||||
import { getVersion } from "./skill.ts";
|
||||
import { checkForUpdate } from "./update-check.ts";
|
||||
import { printNextSteps, printOutcomes, runInstall } from "./commands/install.ts";
|
||||
import { runDoctor } from "./commands/doctor.ts";
|
||||
import { runUpdate } from "./commands/update.ts";
|
||||
import { runUninstall } from "./commands/uninstall.ts";
|
||||
import { runList } from "./commands/list.ts";
|
||||
import { onboard } from "./tui/onboard.ts";
|
||||
import { accent, bad, bold, dim, warn } from "./ui.ts";
|
||||
|
||||
const VALID_TARGETS = ADAPTERS.map((a) => a.id) as string[];
|
||||
|
||||
interface Args {
|
||||
command: string;
|
||||
targets: TargetId[];
|
||||
scope: Scope;
|
||||
yes: boolean;
|
||||
force: boolean;
|
||||
dryRun: boolean;
|
||||
help: boolean;
|
||||
version: boolean;
|
||||
}
|
||||
|
||||
function parse(argv: string[]): Args {
|
||||
const out: Args = {
|
||||
command: "",
|
||||
targets: [],
|
||||
scope: "user",
|
||||
yes: false,
|
||||
force: false,
|
||||
dryRun: false,
|
||||
help: false,
|
||||
version: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i] as string;
|
||||
if (a === "--help" || a === "-h") out.help = true;
|
||||
else if (a === "--version" || a === "-v") out.version = true;
|
||||
else if (a === "--yes" || a === "-y") out.yes = true;
|
||||
else if (a === "--force" || a === "-f") out.force = true;
|
||||
else if (a === "--dry-run") out.dryRun = true;
|
||||
else if (a === "--no-update-check") process.env["DESIGNPACA_NO_UPDATE_CHECK"] = "1";
|
||||
else if (a === "--target" || a === "-t") {
|
||||
const v = argv[++i];
|
||||
if (v) out.targets.push(...(v.split(",") as TargetId[]));
|
||||
} else if (a.startsWith("--target=")) {
|
||||
out.targets.push(...(a.slice(9).split(",") as TargetId[]));
|
||||
} else if (a === "--scope" || a === "-s") {
|
||||
const v = argv[++i];
|
||||
if (v) out.scope = v as Scope;
|
||||
} else if (a.startsWith("--scope=")) {
|
||||
out.scope = a.slice(8) as Scope;
|
||||
} else if (!a.startsWith("-") && !out.command) {
|
||||
out.command = a;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage(version: string): string {
|
||||
return `
|
||||
${accent("designpaca")} ${dim(`v${version}`)} — 웹 디자인 파이프라인 스킬 설치기
|
||||
|
||||
${bold("사용법")}
|
||||
npx designpaca 대화형 온보딩 (권장)
|
||||
npx designpaca install 설치
|
||||
npx designpaca update 최신 스킬로 갱신
|
||||
npx designpaca uninstall 제거
|
||||
npx designpaca doctor 설치 상태 진단
|
||||
npx designpaca list 설치 가능 대상과 현재 설치 목록
|
||||
|
||||
${bold("옵션")}
|
||||
-t, --target <id[,id]> 설치 대상: ${VALID_TARGETS.join(", ")}
|
||||
-s, --scope <범위> user | project ${dim("(기본: user)")}
|
||||
-y, --yes 확인 없이 진행
|
||||
-f, --force 직접 수정한 파일도 덮어쓴다 ${dim("(.orig 로 백업)")}
|
||||
--dry-run 쓰지 않고 계획만 출력
|
||||
--no-update-check 새 버전 확인을 건너뛴다
|
||||
-h, --help 이 도움말
|
||||
-v, --version 버전
|
||||
|
||||
${bold("예시")}
|
||||
${dim("npx designpaca install -t claude-code,codex -s user -y")}
|
||||
${dim("npx designpaca install -t cursor -s project")}
|
||||
${dim("npx designpaca update --force")}
|
||||
`.trimStart();
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const args = parse(process.argv.slice(2));
|
||||
const version = await getVersion();
|
||||
|
||||
if (args.version) {
|
||||
console.log(version);
|
||||
return 0;
|
||||
}
|
||||
if (args.help) {
|
||||
console.log(usage(version));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const bad_ = args.targets.filter((t) => !VALID_TARGETS.includes(t));
|
||||
if (bad_.length > 0) {
|
||||
console.error(bad(`알 수 없는 대상: ${bad_.join(", ")}`));
|
||||
console.error(dim(` 가능한 값: ${VALID_TARGETS.join(", ")}`));
|
||||
return 2;
|
||||
}
|
||||
if (args.scope !== "user" && args.scope !== "project") {
|
||||
console.error(bad(`알 수 없는 범위: ${args.scope} (user | project)`));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// 대화형으로 쓸 수 있는 환경이면 인자 없이 실행했을 때 온보딩으로 보낸다
|
||||
const interactive = process.stdout.isTTY && !args.yes;
|
||||
if (!args.command || args.command === "onboard") {
|
||||
if (interactive) return onboard();
|
||||
console.log(usage(version));
|
||||
return 0;
|
||||
}
|
||||
|
||||
let code = 0;
|
||||
switch (args.command) {
|
||||
case "install": {
|
||||
const targets = args.targets.length > 0 ? args.targets : (["claude-code"] as TargetId[]);
|
||||
if (interactive && args.targets.length === 0) return onboard();
|
||||
const outcomes = await runInstall({
|
||||
targets,
|
||||
scope: args.scope,
|
||||
force: args.force,
|
||||
dryRun: args.dryRun,
|
||||
});
|
||||
printOutcomes(outcomes, args.dryRun);
|
||||
if (!args.dryRun) printNextSteps(targets);
|
||||
code = outcomes.some((o) => o.blocked) ? 1 : 0;
|
||||
break;
|
||||
}
|
||||
case "update":
|
||||
code = await runUpdate({ force: args.force });
|
||||
break;
|
||||
case "uninstall":
|
||||
case "remove":
|
||||
code = await runUninstall({ targets: args.targets, force: args.force });
|
||||
break;
|
||||
case "doctor":
|
||||
code = await runDoctor();
|
||||
break;
|
||||
case "list":
|
||||
case "ls":
|
||||
code = await runList();
|
||||
break;
|
||||
default:
|
||||
console.error(bad(`알 수 없는 명령: ${args.command}`));
|
||||
console.log(usage(version));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// 명령을 마친 뒤에만 알린다 — 시작을 지연시키지 않는다
|
||||
const latest = await checkForUpdate(version);
|
||||
if (latest) {
|
||||
console.log(
|
||||
"\n" + warn(`새 버전 ${bold(`v${latest}`)} 이 있다 — ${dim("npx designpaca@latest update")}`),
|
||||
);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exitCode = code;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error(bad(err instanceof Error ? err.message : String(err)));
|
||||
if (process.env["DESIGNPACA_DEBUG"] === "1" && err instanceof Error) console.error(err.stack);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
28
packages/cli/src/skill.ts
Normal file
28
packages/cli/src/skill.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadSkillSource, type SkillSource } from "@designpaca/core";
|
||||
|
||||
/** 배포물 안에 함께 실린 스킬 원본 위치 (dist/skill) */
|
||||
export function bundledSkillRoot(): string {
|
||||
return fileURLToPath(new URL("./skill", import.meta.url));
|
||||
}
|
||||
|
||||
let cached: SkillSource | null = null;
|
||||
|
||||
export async function getSkill(): Promise<SkillSource> {
|
||||
if (cached) return cached;
|
||||
const root = bundledSkillRoot();
|
||||
let version = "0.0.0";
|
||||
try {
|
||||
version = (await fs.readFile(path.join(root, ".designpaca_version"), "utf8")).trim();
|
||||
} catch {
|
||||
/* 스탬프가 없으면 0.0.0 으로 두고 진행한다 — 설치 자체를 막을 이유는 없다 */
|
||||
}
|
||||
cached = await loadSkillSource(root, version);
|
||||
return cached;
|
||||
}
|
||||
|
||||
export async function getVersion(): Promise<string> {
|
||||
return (await getSkill()).version;
|
||||
}
|
||||
125
packages/cli/src/tui/onboard.ts
Normal file
125
packages/cli/src/tui/onboard.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import * as p from "@clack/prompts";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ADAPTERS,
|
||||
detectTargets,
|
||||
getAdapter,
|
||||
planInstall,
|
||||
tildify,
|
||||
type Scope,
|
||||
type TargetId,
|
||||
} from "@designpaca/core";
|
||||
import { getSkill } from "../skill.ts";
|
||||
import { runInstall, printNextSteps, printOutcomes } from "../commands/install.ts";
|
||||
import { accent, bold, dim } from "../ui.ts";
|
||||
|
||||
/** 선택한 범위를 어댑터가 지원하지 않으면 지원 가능한 범위로 낮춘다 */
|
||||
function effectiveScope(target: TargetId, wanted: Scope): Scope {
|
||||
const a = getAdapter(target);
|
||||
return a.scopes.includes(wanted) ? wanted : (a.scopes[0] as Scope);
|
||||
}
|
||||
|
||||
export async function onboard(): Promise<number> {
|
||||
const skill = await getSkill();
|
||||
|
||||
console.clear();
|
||||
p.intro(`${accent("◆ designpaca")} ${dim(`v${skill.version}`)}`);
|
||||
|
||||
p.note(
|
||||
[
|
||||
"브리프에서 시작해 레퍼런스 조사 · 방향 결정 · 디자인 토큰 ·",
|
||||
"구현 · 셀프 감사까지 끌고 가는 웹 디자인 파이프라인 스킬.",
|
||||
"",
|
||||
`${dim("SVG 필터 · three.js · 인터랙티브 모션을 기본 재료로 쓴다.")}`,
|
||||
].join("\n"),
|
||||
"무엇을 설치하나",
|
||||
);
|
||||
|
||||
// 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다
|
||||
const detected = await detectTargets(skill);
|
||||
|
||||
const targets = await p.multiselect<TargetId>({
|
||||
message: "어디에 설치할까?",
|
||||
options: ADAPTERS.map((a) => ({
|
||||
value: a.id,
|
||||
label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""),
|
||||
hint: a.hint,
|
||||
})),
|
||||
initialValues: detected.length > 0 ? detected : ["claude-code"],
|
||||
required: true,
|
||||
});
|
||||
if (p.isCancel(targets)) return cancel();
|
||||
|
||||
const wanted = await p.select<Scope>({
|
||||
message: "설치 범위",
|
||||
options: [
|
||||
{ value: "user", label: "전역", hint: "홈 디렉터리 — 모든 프로젝트에서 쓴다" },
|
||||
{ value: "project", label: "이 프로젝트만", hint: tildify(process.cwd()) },
|
||||
],
|
||||
initialValue: "user",
|
||||
});
|
||||
if (p.isCancel(wanted)) return cancel();
|
||||
|
||||
// 범위가 낮춰진 타깃이 있으면 미리 알린다 — 설치 후에 "왜 여기 깔렸지"가 되지 않게
|
||||
const downgraded = targets.filter((t) => effectiveScope(t, wanted) !== wanted);
|
||||
if (downgraded.length > 0) {
|
||||
p.log.warn(
|
||||
downgraded
|
||||
.map((t) => {
|
||||
const a = getAdapter(t);
|
||||
return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원한다 → ${effectiveScope(t, wanted)} 로 설치한다`;
|
||||
})
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// 무엇이 어디에 쓰이는지 먼저 보여준다
|
||||
const previews: string[] = [];
|
||||
for (const t of targets) {
|
||||
const scope = effectiveScope(t, wanted);
|
||||
const plan = await planInstall(t, scope, skill);
|
||||
if (plan.blocked) {
|
||||
previews.push(`${bold(getAdapter(t).label)}\n ${dim(plan.blocked)}`);
|
||||
continue;
|
||||
}
|
||||
const dirs = new Set(plan.actions.map((a) => path.dirname(a.path)));
|
||||
previews.push(
|
||||
[
|
||||
`${bold(getAdapter(t).label)} ${plan.alreadyInstalled ? dim("(재설치)") : ""}`,
|
||||
` ${dim(tildify(plan.root))}`,
|
||||
` ${dim(`파일 ${plan.actions.length}개 · 디렉터리 ${dirs.size}개`)}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
p.note(previews.join("\n\n"), "설치 계획");
|
||||
|
||||
const go = await p.confirm({ message: "이대로 설치할까?", initialValue: true });
|
||||
if (p.isCancel(go) || !go) return cancel();
|
||||
|
||||
const s = p.spinner();
|
||||
s.start("설치 중");
|
||||
const outcomes = [];
|
||||
try {
|
||||
for (const t of targets) {
|
||||
const scope = effectiveScope(t, wanted);
|
||||
s.message(`설치 중 — ${getAdapter(t).label}`);
|
||||
outcomes.push(...(await runInstall({ targets: [t], scope })));
|
||||
}
|
||||
s.stop("설치 완료");
|
||||
} catch (err) {
|
||||
s.stop("설치 실패", 1);
|
||||
p.log.error(err instanceof Error ? err.message : String(err));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printOutcomes(outcomes);
|
||||
printNextSteps(targets);
|
||||
|
||||
p.outro(`${accent("designpaca")} 준비됨 — 이제 브리프를 던져라`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cancel(): number {
|
||||
p.cancel("설치를 취소했다. 아무것도 바꾸지 않았다.");
|
||||
return 130;
|
||||
}
|
||||
55
packages/cli/src/ui.ts
Normal file
55
packages/cli/src/ui.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import pc from "picocolors";
|
||||
|
||||
/** designpaca 의 강조색 — 한 가지만 쓴다 */
|
||||
export const accent = (s: string) => pc.magenta(s);
|
||||
export const dim = (s: string) => pc.dim(s);
|
||||
export const bold = (s: string) => pc.bold(s);
|
||||
|
||||
export const ok = (s: string) => `${pc.green("✓")} ${s}`;
|
||||
export const warn = (s: string) => `${pc.yellow("!")} ${s}`;
|
||||
export const bad = (s: string) => `${pc.red("✗")} ${s}`;
|
||||
export const info = (s: string) => `${pc.cyan("·")} ${s}`;
|
||||
|
||||
/**
|
||||
* 터미널에서 차지하는 칸 수. 한글·한자·가나는 2칸을 먹는다.
|
||||
* String.length 로 padEnd 하면 한글이 섞인 표가 어긋난다.
|
||||
*/
|
||||
export function displayWidth(s: string): number {
|
||||
// ANSI 이스케이프는 폭을 차지하지 않는다
|
||||
const plain = s.replace(/\[[0-9;]*m/g, "");
|
||||
let w = 0;
|
||||
for (const ch of plain) {
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
const wide =
|
||||
(cp >= 0x1100 && cp <= 0x115f) || // 한글 자모
|
||||
(cp >= 0x2e80 && cp <= 0xa4cf) || // CJK 부수 ~ 이
|
||||
(cp >= 0xac00 && cp <= 0xd7a3) || // 한글 음절
|
||||
(cp >= 0xf900 && cp <= 0xfaff) || // CJK 호환
|
||||
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
||||
(cp >= 0xff00 && cp <= 0xff60) || // 전각
|
||||
(cp >= 0xffe0 && cp <= 0xffe6);
|
||||
w += wide ? 2 : 1;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
/** displayWidth 기준으로 오른쪽을 채운다 */
|
||||
export function padDisplay(s: string, width: number): string {
|
||||
const pad = width - displayWidth(s);
|
||||
return pad > 0 ? s + " ".repeat(pad) : s;
|
||||
}
|
||||
|
||||
export function heading(s: string): string {
|
||||
return `\n${bold(s)}\n${dim("─".repeat(Math.min(s.length + 8, 56)))}`;
|
||||
}
|
||||
|
||||
/** 긴 경로 목록을 접어서 보여준다 — 설치 계획이 화면을 삼키지 않게 */
|
||||
export function fold(items: string[], max = 6): string[] {
|
||||
if (items.length <= max) return items;
|
||||
return [...items.slice(0, max), dim(`… 그 외 ${items.length - max}개`)];
|
||||
}
|
||||
|
||||
export function table(rows: [string, string][]): string {
|
||||
const w = Math.max(...rows.map(([k]) => k.length));
|
||||
return rows.map(([k, v]) => ` ${k.padEnd(w)} ${dim(v)}`).join("\n");
|
||||
}
|
||||
56
packages/cli/src/update-check.ts
Normal file
56
packages/cli/src/update-check.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { readIfExists, updateCachePath, writeAtomic } from "@designpaca/core";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const REGISTRY = "https://registry.npmjs.org/designpaca/latest";
|
||||
|
||||
interface Cache {
|
||||
checkedAt: number;
|
||||
latest: string;
|
||||
}
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const norm = (v: string) => v.split("-")[0]!.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
||||
const [a, b] = [norm(latest), norm(current)];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const l = a[i] ?? 0;
|
||||
const c = b[i] ?? 0;
|
||||
if (l !== c) return l > c;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하루 한 번만 레지스트리를 본다. 네트워크가 없거나 느리면 조용히 포기한다 —
|
||||
* 업데이트 확인 때문에 CLI 가 멈추는 일은 없어야 한다.
|
||||
*/
|
||||
export async function checkForUpdate(current: string): Promise<string | null> {
|
||||
if (process.env["DESIGNPACA_NO_UPDATE_CHECK"] === "1") return null;
|
||||
|
||||
const cachePath = updateCachePath();
|
||||
const raw = await readIfExists(cachePath);
|
||||
if (raw) {
|
||||
try {
|
||||
const c = JSON.parse(raw) as Cache;
|
||||
if (Date.now() - c.checkedAt < DAY) {
|
||||
return isNewer(c.latest, current) ? c.latest : null;
|
||||
}
|
||||
} catch {
|
||||
/* 캐시가 깨졌으면 새로 받는다 */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(REGISTRY, {
|
||||
headers: { accept: "application/vnd.npm.install-v1+json" },
|
||||
signal: AbortSignal.timeout(2500),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { version?: string };
|
||||
const latest = body.version;
|
||||
if (!latest) return null;
|
||||
await writeAtomic(cachePath, JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache));
|
||||
return isNewer(latest, current) ? latest : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
70
packages/cli/test/cli.test.ts
Normal file
70
packages/cli/test/cli.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { after, test } from "node:test";
|
||||
|
||||
const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url));
|
||||
|
||||
const built = await fs
|
||||
.access(CLI)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "designpaca-cli-"));
|
||||
after(async () => {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** 빌드 산출물을 사용자가 실행하는 방식 그대로 돌린다 */
|
||||
function run(args: string[]): string {
|
||||
return execFileSync(process.execPath, [CLI, ...args, "--no-update-check"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, HOME: home, USERPROFILE: home, FORCE_COLOR: "0" },
|
||||
});
|
||||
}
|
||||
|
||||
test("빌드 산출물이 있어야 한다", { skip: built ? false : "pnpm build 먼저 실행해라" }, () => {
|
||||
assert.ok(built);
|
||||
});
|
||||
|
||||
test("--version 은 스킬 버전과 같다", { skip: !built }, async () => {
|
||||
const stamp = (
|
||||
await fs.readFile(fileURLToPath(new URL("../dist/skill/.designpaca_version", import.meta.url)), "utf8")
|
||||
).trim();
|
||||
assert.equal(run(["--version"]).trim(), stamp);
|
||||
});
|
||||
|
||||
test("도움말에 모든 명령이 나온다", { skip: !built }, () => {
|
||||
const out = run(["--help"]);
|
||||
for (const cmd of ["install", "update", "uninstall", "doctor", "list"]) {
|
||||
assert.ok(out.includes(cmd), `도움말에 ${cmd} 가 없다`);
|
||||
}
|
||||
});
|
||||
|
||||
test("dry-run 은 아무 파일도 쓰지 않는다", { skip: !built }, async () => {
|
||||
const before = await fs.readdir(home);
|
||||
const out = run(["install", "-t", "claude-code", "-s", "user", "-y", "--dry-run"]);
|
||||
assert.ok(out.includes("설치 계획"));
|
||||
assert.deepEqual(await fs.readdir(home), before);
|
||||
});
|
||||
|
||||
test("알 수 없는 대상은 종료 코드 2 로 거부한다", { skip: !built }, () => {
|
||||
assert.throws(
|
||||
() => run(["install", "-t", "emacs", "-y"]),
|
||||
(err: { status?: number }) => err.status === 2,
|
||||
);
|
||||
});
|
||||
|
||||
test("설치 → doctor → 제거 왕복", { skip: !built }, async () => {
|
||||
run(["install", "-t", "claude-code", "-s", "user", "-y"]);
|
||||
const skillMd = path.join(home, ".claude", "skills", "designpaca", "SKILL.md");
|
||||
assert.ok((await fs.readFile(skillMd, "utf8")).includes("name: designpaca"));
|
||||
|
||||
assert.ok(run(["doctor"]).includes("claude-code"));
|
||||
|
||||
run(["uninstall", "-y"]);
|
||||
await assert.rejects(() => fs.access(skillMd));
|
||||
});
|
||||
9
packages/cli/tsconfig.json
Normal file
9
packages/cli/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"paths": { "@designpaca/core": ["../core/src/index.ts"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "scripts/**/*.mjs", "test/**/*.ts"]
|
||||
}
|
||||
15
packages/cli/tsup.config.ts
Normal file
15
packages/cli/tsup.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts" },
|
||||
format: ["esm"],
|
||||
target: "node20",
|
||||
platform: "node",
|
||||
clean: false, // dist/skill 을 지우면 안 된다(bundle-skill 이 먼저 채운다)
|
||||
dts: false,
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
// 워크스페이스 패키지는 번들에 넣는다 — 사용자가 @designpaca/core 를 따로 받게 하지 않는다
|
||||
noExternal: ["@designpaca/core"],
|
||||
banner: { js: "#!/usr/bin/env node" },
|
||||
});
|
||||
19
packages/core/package.json
Normal file
19
packages/core/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@designpaca/core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo \"(core 는 cli 번들에 포함된다)\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types --disable-warning=ExperimentalWarning \"test/**/*.test.ts\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
65
packages/core/src/fsx.ts
Normal file
65
packages/core/src/fsx.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function sha256(content: string): string {
|
||||
// 개행 정규화 — Windows 체크아웃(autocrlf)에서 해시가 흔들리는 것을 막는다
|
||||
return createHash("sha256").update(content.replace(/\r\n/g, "\n"), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readIfExists(p: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(p, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 임시 파일에 쓴 뒤 rename — 중간에 죽어도 반쯤 쓰인 파일이 남지 않는다 */
|
||||
export async function writeAtomic(p: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, content, "utf8");
|
||||
await fs.rename(tmp, p);
|
||||
}
|
||||
|
||||
/** 사용자가 고친 파일을 덮기 전에 원본을 남겨둔다 */
|
||||
export async function backup(p: string): Promise<string | null> {
|
||||
const cur = await readIfExists(p);
|
||||
if (cur === null) return null;
|
||||
const dest = `${p}.orig`;
|
||||
await fs.writeFile(dest, cur, "utf8");
|
||||
return dest;
|
||||
}
|
||||
|
||||
/** 파일을 지우고, 비게 된 상위 디렉터리를 stopAt 까지 정리한다 */
|
||||
export async function removeFileAndPrune(p: string, stopAt: string): Promise<void> {
|
||||
await fs.rm(p, { force: true });
|
||||
let dir = path.dirname(p);
|
||||
const stop = path.resolve(stopAt);
|
||||
while (path.resolve(dir).startsWith(stop) && path.resolve(dir) !== stop) {
|
||||
try {
|
||||
const rest = await fs.readdir(dir);
|
||||
if (rest.length > 0) break;
|
||||
await fs.rmdir(dir);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
// stopAt 자체도 비었으면 함께 정리한다
|
||||
try {
|
||||
if ((await fs.readdir(stop)).length === 0) await fs.rmdir(stop);
|
||||
} catch {
|
||||
/* 남아있으면 그대로 둔다 */
|
||||
}
|
||||
}
|
||||
8
packages/core/src/index.ts
Normal file
8
packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./types.ts";
|
||||
export * from "./paths.ts";
|
||||
export * from "./fsx.ts";
|
||||
export * from "./marker.ts";
|
||||
export * from "./manifest.ts";
|
||||
export * from "./skill-source.ts";
|
||||
export * from "./installer.ts";
|
||||
export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts";
|
||||
185
packages/core/src/installer.ts
Normal file
185
packages/core/src/installer.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { backup, readIfExists, removeFileAndPrune, sha256, writeAtomic } from "./fsx.ts";
|
||||
import { removeBlock, upsertBlock } from "./marker.ts";
|
||||
import { dropInstall, findInstall, inspectDrift, readManifest, upsertInstall } from "./manifest.ts";
|
||||
import { ADAPTERS, getAdapter } from "./targets/index.ts";
|
||||
import type {
|
||||
InstalledFile,
|
||||
InstallPlan,
|
||||
InstallRecord,
|
||||
Scope,
|
||||
SkillSource,
|
||||
TargetContext,
|
||||
TargetId,
|
||||
} from "./types.ts";
|
||||
|
||||
export interface EnvOptions {
|
||||
home?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
function ctxOf(scope: Scope, skill: SkillSource, env: EnvOptions = {}): TargetContext {
|
||||
return {
|
||||
scope,
|
||||
home: env.home ?? os.homedir(),
|
||||
cwd: env.cwd ?? process.cwd(),
|
||||
skill,
|
||||
};
|
||||
}
|
||||
|
||||
/** 시스템에 흔적이 있는 타깃을 골라준다 — TUI 의 기본 체크 상태로 쓴다 */
|
||||
export async function detectTargets(skill: SkillSource, env: EnvOptions = {}): Promise<TargetId[]> {
|
||||
const found: TargetId[] = [];
|
||||
for (const a of ADAPTERS) {
|
||||
const scope: Scope = a.scopes.includes("user") ? "user" : "project";
|
||||
if (await a.detect(ctxOf(scope, skill, env))) found.push(a.id);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export async function planInstall(
|
||||
target: TargetId,
|
||||
scope: Scope,
|
||||
skill: SkillSource,
|
||||
env: EnvOptions = {},
|
||||
): Promise<InstallPlan> {
|
||||
const adapter = getAdapter(target);
|
||||
if (!adapter.scopes.includes(scope)) {
|
||||
const only = adapter.scopes.join("/");
|
||||
return {
|
||||
target,
|
||||
scope,
|
||||
root: "",
|
||||
actions: [],
|
||||
alreadyInstalled: false,
|
||||
blocked: `${adapter.label} 은(는) ${only} 범위만 지원한다`,
|
||||
};
|
||||
}
|
||||
return adapter.plan(ctxOf(scope, skill, env));
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
record: InstallRecord;
|
||||
written: string[];
|
||||
backedUp: string[];
|
||||
/** 사용자가 고쳐서 건너뛴 파일 */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획을 실제로 디스크에 반영한다.
|
||||
*
|
||||
* 업그레이드 안전장치: 이전 설치 기록이 있으면 각 파일의 해시를 비교해서,
|
||||
* 사용자가 손댄 파일은 기본적으로 건드리지 않는다(force 로만 덮고, 덮을 때도 .orig 로 남긴다).
|
||||
*/
|
||||
export async function applyPlan(
|
||||
plan: InstallPlan,
|
||||
version: string,
|
||||
opts: { force?: boolean; env?: EnvOptions } = {},
|
||||
): Promise<ApplyResult> {
|
||||
if (plan.blocked) throw new Error(plan.blocked);
|
||||
const home = opts.env?.home;
|
||||
|
||||
const prev = findInstall(await readManifest(home), plan.target, plan.scope, plan.root);
|
||||
const modified = new Set<string>();
|
||||
if (prev) {
|
||||
const drift = await inspectDrift(prev);
|
||||
for (const p of drift.modified) modified.add(p);
|
||||
}
|
||||
|
||||
const files: InstalledFile[] = [];
|
||||
const written: string[] = [];
|
||||
const backedUp: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const action of plan.actions) {
|
||||
const isDirty = modified.has(action.path);
|
||||
if (isDirty && !opts.force) {
|
||||
// 건너뛰더라도 매니페스트에서 빠지면 uninstall 이 이 파일을 놓친다 — 기록은 남긴다.
|
||||
// 이때 "현재" 해시를 쓰면 안 된다. 그러면 다음 update 에서 드리프트가 사라져
|
||||
// 사용자가 고친 파일을 조용히 덮어쓰게 된다. 설치 당시 해시를 그대로 유지한다.
|
||||
const kept = prev?.files.find((f) => f.path === action.path);
|
||||
files.push(
|
||||
kept ?? {
|
||||
path: action.path,
|
||||
sha256: sha256(action.kind === "inject" ? action.content.trim() : action.content),
|
||||
...(action.kind === "inject" ? { marker: action.marker } : {}),
|
||||
},
|
||||
);
|
||||
skipped.push(action.path);
|
||||
continue;
|
||||
}
|
||||
if (isDirty && opts.force) {
|
||||
const b = await backup(action.path);
|
||||
if (b) backedUp.push(b);
|
||||
}
|
||||
|
||||
if (action.kind === "write") {
|
||||
await writeAtomic(action.path, action.content);
|
||||
files.push({ path: action.path, sha256: sha256(action.content) });
|
||||
} else {
|
||||
const cur = (await readIfExists(action.path)) ?? "";
|
||||
const next = upsertBlock(cur, action.marker, action.content);
|
||||
await writeAtomic(action.path, next);
|
||||
// 마커 방식은 블록 안쪽만 해시한다 — 문서의 다른 부분은 사용자 자유다
|
||||
files.push({ path: action.path, sha256: sha256(action.content.trim()), marker: action.marker });
|
||||
}
|
||||
written.push(action.path);
|
||||
}
|
||||
|
||||
const record: InstallRecord = {
|
||||
target: plan.target,
|
||||
scope: plan.scope,
|
||||
root: plan.root,
|
||||
version,
|
||||
installedAt: new Date().toISOString(),
|
||||
files,
|
||||
};
|
||||
await upsertInstall(record, home);
|
||||
|
||||
return { record, written, backedUp, skipped };
|
||||
}
|
||||
|
||||
export interface RemoveResult {
|
||||
removed: string[];
|
||||
keptModified: string[];
|
||||
}
|
||||
|
||||
/** 매니페스트에 기록된 것만 되돌린다. 기록에 없는 파일은 손대지 않는다. */
|
||||
export async function removeInstall(
|
||||
record: InstallRecord,
|
||||
opts: { force?: boolean; env?: EnvOptions } = {},
|
||||
): Promise<RemoveResult> {
|
||||
const drift = await inspectDrift(record);
|
||||
const dirty = new Set(drift.modified);
|
||||
const removed: string[] = [];
|
||||
const keptModified: string[] = [];
|
||||
|
||||
for (const f of record.files) {
|
||||
if (dirty.has(f.path) && !opts.force) {
|
||||
keptModified.push(f.path);
|
||||
continue;
|
||||
}
|
||||
if (f.marker) {
|
||||
const cur = await readIfExists(f.path);
|
||||
if (cur === null) continue;
|
||||
const next = removeBlock(cur, f.marker);
|
||||
// 블록만 남아 있던 문서라면 파일째 지운다
|
||||
if (next.trim().length === 0) await removeFileAndPrune(f.path, path.dirname(f.path));
|
||||
else await writeAtomic(f.path, next);
|
||||
} else {
|
||||
await removeFileAndPrune(f.path, record.root);
|
||||
}
|
||||
removed.push(f.path);
|
||||
}
|
||||
|
||||
if (keptModified.length === 0) {
|
||||
await dropInstall(record.target, record.scope, record.root, opts.env?.home);
|
||||
} else {
|
||||
// 일부만 남았으면 기록도 남은 것만 유지한다
|
||||
await upsertInstall({ ...record, files: record.files.filter((f) => dirty.has(f.path)) }, opts.env?.home);
|
||||
}
|
||||
|
||||
return { removed, keptModified };
|
||||
}
|
||||
105
packages/core/src/manifest.ts
Normal file
105
packages/core/src/manifest.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { manifestPath } from "./paths.ts";
|
||||
import { readIfExists, sha256, writeAtomic } from "./fsx.ts";
|
||||
import { extractBlock } from "./marker.ts";
|
||||
import type { DriftReport, InstallRecord, Manifest, Scope, TargetId } from "./types.ts";
|
||||
|
||||
const EMPTY: Manifest = { schema: 1, installs: [] };
|
||||
|
||||
export async function readManifest(home?: string): Promise<Manifest> {
|
||||
const raw = await readIfExists(manifestPath(home));
|
||||
if (!raw) return structuredClone(EMPTY);
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Manifest;
|
||||
if (parsed.schema !== 1 || !Array.isArray(parsed.installs)) return structuredClone(EMPTY);
|
||||
return parsed;
|
||||
} catch {
|
||||
// 손상된 매니페스트로 설치 전체가 막히지 않도록 빈 것으로 되돌린다
|
||||
return structuredClone(EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeManifest(m: Manifest, home?: string): Promise<void> {
|
||||
await writeAtomic(manifestPath(home), JSON.stringify(m, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function sameInstall(a: InstallRecord, target: TargetId, scope: Scope, root: string): boolean {
|
||||
return a.target === target && a.scope === scope && path.resolve(a.root) === path.resolve(root);
|
||||
}
|
||||
|
||||
export function findInstall(
|
||||
m: Manifest,
|
||||
target: TargetId,
|
||||
scope: Scope,
|
||||
root: string,
|
||||
): InstallRecord | undefined {
|
||||
return m.installs.find((i) => sameInstall(i, target, scope, root));
|
||||
}
|
||||
|
||||
export async function upsertInstall(record: InstallRecord, home?: string): Promise<void> {
|
||||
const m = await readManifest(home);
|
||||
const idx = m.installs.findIndex((i) => sameInstall(i, record.target, record.scope, record.root));
|
||||
if (idx >= 0) m.installs[idx] = record;
|
||||
else m.installs.push(record);
|
||||
await writeManifest(m, home);
|
||||
}
|
||||
|
||||
export async function dropInstall(
|
||||
target: TargetId,
|
||||
scope: Scope,
|
||||
root: string,
|
||||
home?: string,
|
||||
): Promise<void> {
|
||||
const m = await readManifest(home);
|
||||
m.installs = m.installs.filter((i) => !sameInstall(i, target, scope, root));
|
||||
await writeManifest(m, home);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치 당시 해시와 현재 내용을 비교한다.
|
||||
* 마커 방식 파일은 문서 전체가 아니라 블록 안쪽만 비교한다 — 사용자가 문서의 다른 부분을
|
||||
* 고치는 것은 정상이고, 그걸 드리프트로 잡으면 update 가 영영 못 돈다.
|
||||
*/
|
||||
export async function inspectDrift(record: InstallRecord): Promise<DriftReport> {
|
||||
const files: DriftReport["files"] = [];
|
||||
for (const f of record.files) {
|
||||
const cur = await readIfExists(f.path);
|
||||
if (cur === null) {
|
||||
files.push({ path: f.path, status: "missing" });
|
||||
continue;
|
||||
}
|
||||
const target = f.marker ? (extractBlock(cur, f.marker) ?? "") : cur;
|
||||
files.push({ path: f.path, status: sha256(target) === f.sha256 ? "ok" : "modified" });
|
||||
}
|
||||
return {
|
||||
record,
|
||||
files,
|
||||
modified: files.filter((f) => f.status === "modified").map((f) => f.path),
|
||||
};
|
||||
}
|
||||
|
||||
/** 매니페스트가 가리키는 경로가 실제로 살아있는지 확인한다 */
|
||||
export async function pruneDeadInstalls(home?: string): Promise<number> {
|
||||
const m = await readManifest(home);
|
||||
const before = m.installs.length;
|
||||
const alive: InstallRecord[] = [];
|
||||
for (const rec of m.installs) {
|
||||
const anyAlive = await Promise.all(
|
||||
rec.files.map(async (f) => {
|
||||
try {
|
||||
await fs.access(f.path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (anyAlive.some(Boolean)) alive.push(rec);
|
||||
}
|
||||
if (alive.length !== before) {
|
||||
m.installs = alive;
|
||||
await writeManifest(m, home);
|
||||
}
|
||||
return before - alive.length;
|
||||
}
|
||||
51
packages/core/src/marker.ts
Normal file
51
packages/core/src/marker.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* AGENTS.md 같은 사용자 소유 문서에 우리 블록만 안전하게 심고 빼기 위한 마커 처리.
|
||||
* 블록 밖의 내용은 절대 건드리지 않는다.
|
||||
*/
|
||||
|
||||
export function startTag(marker: string): string {
|
||||
return `<!-- ${marker}:start -->`;
|
||||
}
|
||||
export function endTag(marker: string): string {
|
||||
return `<!-- ${marker}:end -->`;
|
||||
}
|
||||
|
||||
export function hasBlock(doc: string, marker: string): boolean {
|
||||
return doc.includes(startTag(marker)) && doc.includes(endTag(marker));
|
||||
}
|
||||
|
||||
/** 블록이 있으면 내용만 교체, 없으면 문서 끝에 덧붙인다 */
|
||||
export function upsertBlock(doc: string, marker: string, content: string): string {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const block = `${s}\n${content.trim()}\n${e}`;
|
||||
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si !== -1 && ei !== -1 && ei > si) {
|
||||
return doc.slice(0, si) + block + doc.slice(ei + e.length);
|
||||
}
|
||||
const base = doc.trimEnd();
|
||||
return base.length > 0 ? `${base}\n\n${block}\n` : `${block}\n`;
|
||||
}
|
||||
|
||||
/** 블록만 제거하고 나머지 문서는 보존한다 */
|
||||
export function removeBlock(doc: string, marker: string): string {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si === -1 || ei === -1 || ei < si) return doc;
|
||||
const out = doc.slice(0, si) + doc.slice(ei + e.length);
|
||||
return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
/** 블록 내부 내용만 뽑아낸다 (드리프트 판정용) */
|
||||
export function extractBlock(doc: string, marker: string): string | null {
|
||||
const s = startTag(marker);
|
||||
const e = endTag(marker);
|
||||
const si = doc.indexOf(s);
|
||||
const ei = doc.indexOf(e);
|
||||
if (si === -1 || ei === -1 || ei < si) return null;
|
||||
return doc.slice(si + s.length, ei).trim();
|
||||
}
|
||||
25
packages/core/src/paths.ts
Normal file
25
packages/core/src/paths.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/** designpaca 자체 상태 디렉터리 (~/.designpaca) */
|
||||
export function stateDir(home = os.homedir()): string {
|
||||
return path.join(home, ".designpaca");
|
||||
}
|
||||
|
||||
export function manifestPath(home = os.homedir()): string {
|
||||
return path.join(stateDir(home), "manifest.json");
|
||||
}
|
||||
|
||||
/** 업데이트 확인 캐시 */
|
||||
export function updateCachePath(home = os.homedir()): string {
|
||||
return path.join(stateDir(home), "update-check.json");
|
||||
}
|
||||
|
||||
/** 홈 경로를 ~ 로 줄여 표시한다 */
|
||||
export function tildify(p: string, home = os.homedir()): string {
|
||||
const rel = path.relative(home, p);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
|
||||
return path.posix.join("~", rel.split(path.sep).join("/"));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
62
packages/core/src/skill-source.ts
Normal file
62
packages/core/src/skill-source.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { SkillSource } from "./types.ts";
|
||||
|
||||
/** 프론트매터를 본문과 분리한다. yaml 파서를 끌어오지 않으려고 필요한 필드만 얕게 읽는다. */
|
||||
export function splitFrontmatter(md: string): { fm: Record<string, string>; body: string } {
|
||||
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(md);
|
||||
if (!m) return { fm: {}, body: md };
|
||||
const fm: Record<string, string> = {};
|
||||
for (const line of (m[1] ?? "").split(/\r?\n/)) {
|
||||
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
||||
if (!kv) continue;
|
||||
let v = (kv[2] ?? "").trim();
|
||||
// 따옴표로 감싼 값의 따옴표만 벗긴다
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
fm[kv[1] as string] = v;
|
||||
}
|
||||
return { fm, body: md.slice(m[0].length) };
|
||||
}
|
||||
|
||||
async function walk(dir: string, base = dir): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...(await walk(full, base)));
|
||||
else out.push(path.relative(base, full).split(path.sep).join("/"));
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 스킬 원본 디렉터리를 읽어 메모리에 올린다.
|
||||
* SKILL.md 는 필수, 나머지(references/ 등)는 전부 그대로 따라간다.
|
||||
*/
|
||||
export async function loadSkillSource(root: string, version: string): Promise<SkillSource> {
|
||||
const skillMd = await fs.readFile(path.join(root, "SKILL.md"), "utf8");
|
||||
const { fm, body } = splitFrontmatter(skillMd);
|
||||
|
||||
const files = new Map<string, string>();
|
||||
for (const rel of await walk(root)) {
|
||||
if (rel === "SKILL.md") continue;
|
||||
if (rel.endsWith(".orig") || rel === ".designpaca_version") continue;
|
||||
files.set(rel, await fs.readFile(path.join(root, rel), "utf8"));
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
skillMd,
|
||||
files,
|
||||
body: body.trim(),
|
||||
description: fm["description"] ?? "웹 디자인 파이프라인 스킬",
|
||||
};
|
||||
}
|
||||
71
packages/core/src/targets/agents-md.ts
Normal file
71
packages/core/src/targets/agents-md.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { MARKER, rewriteRefPaths, skillDirActions } from "./common.ts";
|
||||
|
||||
/**
|
||||
* 범용 AGENTS.md — 어떤 에이전트든 읽는 프로젝트 루트 문서에 우리 블록만 심는다.
|
||||
* 문서 전체를 덮어쓰지 않는다. 블록 밖은 사용자 것이다.
|
||||
*
|
||||
* **본문 전체를 넣지 않는다.** AGENTS.md 는 스킬과 달리 조건 없이 항상 로드되고,
|
||||
* Codex 는 병합 결과를 기본 32KiB 에서 자른다. 14KB 짜리 본문을 넣으면
|
||||
* 디자인과 무관한 모든 대화에서 그 비용을 내고, 프로젝트 자신의 지침이 잘려나갈 수 있다.
|
||||
* 그래서 여기에는 포인터만 두고 본문은 .designpaca/ 에 풀어둔다.
|
||||
*/
|
||||
export const agentsMd: TargetAdapter = {
|
||||
id: "agents-md",
|
||||
label: "범용 AGENTS.md",
|
||||
hint: "AGENTS.md 에 포인터 주입 — 에이전트 무관",
|
||||
scopes: ["project", "user"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
// 항상 로드되는 자리라 기본 선택은 보수적으로 — AGENTS.md 가 실제로 있을 때만.
|
||||
return (
|
||||
(await exists(path.join(ctx.cwd, "AGENTS.md"))) ||
|
||||
(await exists(path.join(ctx.home, ".codex", "AGENTS.md")))
|
||||
);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const isUser = ctx.scope === "user";
|
||||
const doc = isUser
|
||||
? path.join(ctx.home, ".codex", "AGENTS.md")
|
||||
: path.join(ctx.cwd, "AGENTS.md");
|
||||
// user/project 가 같은 구조를 갖도록 둘 다 .designpaca 를 루트로 쓴다
|
||||
const refRoot = isUser
|
||||
? path.join(ctx.home, ".designpaca", "skill")
|
||||
: path.join(ctx.cwd, ".designpaca");
|
||||
const refPrefix = isUser ? "~/.designpaca/skill" : ".designpaca";
|
||||
|
||||
const block = [
|
||||
"## designpaca — 웹 디자인 파이프라인",
|
||||
"",
|
||||
"이 리포에서 UI 를 새로 만들거나 다시 디자인할 때는, 마크업·스타일을 쓰기 전에",
|
||||
`\`${refPrefix}/SKILL.md\` 를 읽고 그 파이프라인(0~6단계)을 따른다.`,
|
||||
"",
|
||||
"- 프로젝트 루트에 `design.md` 가 있으면 그것이 최상위다. 스킬 기본값을 덮는다.",
|
||||
"- 참조 문서는 각 단계에서 지시하는 것만 그때 연다. 처음부터 전부 읽지 마라.",
|
||||
"",
|
||||
`<!-- designpaca v${ctx.skill.version} — \`npx designpaca update\` 가 관리한다. 직접 고치면 업데이트가 멈춘다. -->`,
|
||||
].join("\n");
|
||||
|
||||
// 본문은 별도 디렉터리에 통째로 푼다. 경로 재작성이 필요하다.
|
||||
const skillActions = skillDirActions(refRoot, {
|
||||
...ctx.skill,
|
||||
skillMd: rewriteRefPaths(ctx.skill.skillMd, refPrefix),
|
||||
});
|
||||
|
||||
const actions: FileAction[] = [
|
||||
{ kind: "inject", path: doc, marker: MARKER, content: block },
|
||||
...skillActions,
|
||||
];
|
||||
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root: refRoot,
|
||||
actions,
|
||||
alreadyInstalled: await exists(path.join(refRoot, "SKILL.md")),
|
||||
};
|
||||
},
|
||||
};
|
||||
34
packages/core/src/targets/claude-code.ts
Normal file
34
packages/core/src/targets/claude-code.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { anyExists, scopeRoot, skillDirActions } from "./common.ts";
|
||||
|
||||
/**
|
||||
* Claude Code — ~/.claude/skills/designpaca/ (전역) 또는 .claude/skills/designpaca/ (프로젝트).
|
||||
* SKILL.md 포맷을 그대로 쓰므로 변환이 없다.
|
||||
*/
|
||||
export const claudeCode: TargetAdapter = {
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
hint: "~/.claude/skills/designpaca — SKILL.md 그대로",
|
||||
scopes: ["user", "project"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
return anyExists([
|
||||
path.join(ctx.home, ".claude"),
|
||||
path.join(ctx.cwd, ".claude"),
|
||||
path.join(ctx.home, ".claude.json"),
|
||||
]);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const root = scopeRoot(ctx, [".claude", "skills", "designpaca"], [".claude", "skills", "designpaca"]);
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root,
|
||||
actions: skillDirActions(root, ctx.skill),
|
||||
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
|
||||
};
|
||||
},
|
||||
};
|
||||
34
packages/core/src/targets/codex.ts
Normal file
34
packages/core/src/targets/codex.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { anyExists, scopeRoot, skillDirActions } from "./common.ts";
|
||||
|
||||
/**
|
||||
* Codex CLI — ~/.codex/skills/designpaca/.
|
||||
* Codex 도 Claude Code 와 같은 SKILL.md 규약을 쓴다(실측: ~/.codex/skills/frontend-design 등).
|
||||
*/
|
||||
export const codex: TargetAdapter = {
|
||||
id: "codex",
|
||||
label: "Codex CLI",
|
||||
hint: "~/.codex/skills/designpaca — SKILL.md 그대로",
|
||||
scopes: ["user", "project"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
return anyExists([
|
||||
path.join(ctx.home, ".codex"),
|
||||
path.join(ctx.home, ".codex", "AGENTS.md"),
|
||||
path.join(ctx.cwd, ".codex"),
|
||||
]);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const root = scopeRoot(ctx, [".codex", "skills", "designpaca"], [".codex", "skills", "designpaca"]);
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root,
|
||||
actions: skillDirActions(root, ctx.skill),
|
||||
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
|
||||
};
|
||||
},
|
||||
};
|
||||
60
packages/core/src/targets/common.ts
Normal file
60
packages/core/src/targets/common.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { FileAction, SkillSource, TargetContext } from "../types.ts";
|
||||
|
||||
/** 매니페스트·마커에 쓰는 고정 식별자 */
|
||||
export const MARKER = "designpaca";
|
||||
|
||||
/** 스킬 디렉터리를 통째로 쓰는 타깃(Claude Code·Codex)이 공유하는 파일 목록 */
|
||||
export function skillDirActions(root: string, skill: SkillSource): FileAction[] {
|
||||
const actions: FileAction[] = [
|
||||
{ kind: "write", path: path.join(root, "SKILL.md"), content: skill.skillMd },
|
||||
];
|
||||
for (const [rel, content] of skill.files) {
|
||||
actions.push({ kind: "write", path: path.join(root, ...rel.split("/")), content });
|
||||
}
|
||||
// 업그레이드 판정에 쓰는 버전 스탬프
|
||||
actions.push({
|
||||
kind: "write",
|
||||
path: path.join(root, ".designpaca_version"),
|
||||
content: `${skill.version}\n`,
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** references 를 별도 디렉터리로 내보내는 타깃(Cursor·AGENTS.md)용 */
|
||||
export function referenceActions(root: string, skill: SkillSource): FileAction[] {
|
||||
const actions: FileAction[] = [];
|
||||
for (const [rel, content] of skill.files) {
|
||||
actions.push({ kind: "write", path: path.join(root, ...rel.split("/")), content });
|
||||
}
|
||||
actions.push({
|
||||
kind: "write",
|
||||
path: path.join(root, ".designpaca_version"),
|
||||
content: `${skill.version}\n`,
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문의 `references/...` 상대 경로를 실제 설치 위치로 바꾼다.
|
||||
*
|
||||
* Claude Code·Codex 는 스킬 디렉터리를 통째로 복사하므로 상대 경로가 그대로 맞다.
|
||||
* Cursor·Windsurf·AGENTS.md 는 본문만 다른 자리로 옮기고 references 는 별도 디렉터리에 풀기
|
||||
* 때문에, 재작성하지 않으면 본문이 가리키는 경로가 전부 존재하지 않는 곳을 가리킨다.
|
||||
*/
|
||||
export function rewriteRefPaths(body: string, prefix: string): string {
|
||||
return body.replaceAll("references/", `${prefix}/references/`);
|
||||
}
|
||||
|
||||
/** 어느 한 경로라도 있으면 그 도구가 설치돼 있다고 본다 */
|
||||
export async function anyExists(paths: string[]): Promise<boolean> {
|
||||
for (const p of paths) if (await exists(p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function scopeRoot(ctx: TargetContext, userRel: string[], projectRel: string[]): string {
|
||||
return ctx.scope === "user"
|
||||
? path.join(ctx.home, ...userRel)
|
||||
: path.join(ctx.cwd, ...projectRel);
|
||||
}
|
||||
57
packages/core/src/targets/cursor.ts
Normal file
57
packages/core/src/targets/cursor.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { anyExists, referenceActions, rewriteRefPaths } from "./common.ts";
|
||||
|
||||
/** 본문과 references 가 함께 놓이는 루트 (본문 경로 재작성 기준) */
|
||||
const REF_PREFIX = ".cursor/rules/designpaca";
|
||||
|
||||
/**
|
||||
* Cursor — .cursor/rules/designpaca.mdc.
|
||||
* .mdc 프론트매터는 SKILL.md 와 필드가 다르다(description/globs/alwaysApply)므로 변환한다.
|
||||
*
|
||||
* Windsurf 는 이 파일을 읽지 않는다(.windsurf/rules/*.md 를 쓴다) — 별도 어댑터로 분리했다.
|
||||
*/
|
||||
export const cursor: TargetAdapter = {
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
hint: ".cursor/rules/designpaca.mdc — 프로젝트 단위",
|
||||
// Cursor 의 전역 규칙은 파일이 아니라 앱 설정(User Rules)이라 프로젝트 범위만 지원한다
|
||||
scopes: ["project"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
return anyExists([path.join(ctx.cwd, ".cursor"), path.join(ctx.home, ".cursor")]);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const rulesDir = path.join(ctx.cwd, ".cursor", "rules");
|
||||
const mdc = path.join(rulesDir, "designpaca.mdc");
|
||||
const refRoot = path.join(rulesDir, "designpaca");
|
||||
|
||||
const header = [
|
||||
"---",
|
||||
`description: ${ctx.skill.description}`,
|
||||
"globs:",
|
||||
"alwaysApply: false",
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const actions: FileAction[] = [
|
||||
{
|
||||
kind: "write",
|
||||
path: mdc,
|
||||
content: header + rewriteRefPaths(ctx.skill.body, REF_PREFIX),
|
||||
},
|
||||
...referenceActions(refRoot, ctx.skill),
|
||||
];
|
||||
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root: rulesDir,
|
||||
actions,
|
||||
alreadyInstalled: await exists(mdc),
|
||||
};
|
||||
},
|
||||
};
|
||||
17
packages/core/src/targets/index.ts
Normal file
17
packages/core/src/targets/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { TargetAdapter, TargetId } from "../types.ts";
|
||||
import { claudeCode } from "./claude-code.ts";
|
||||
import { codex } from "./codex.ts";
|
||||
import { cursor } from "./cursor.ts";
|
||||
import { windsurf } from "./windsurf.ts";
|
||||
import { agentsMd } from "./agents-md.ts";
|
||||
|
||||
export const ADAPTERS: TargetAdapter[] = [claudeCode, codex, cursor, windsurf, agentsMd];
|
||||
|
||||
export function getAdapter(id: TargetId): TargetAdapter {
|
||||
const a = ADAPTERS.find((x) => x.id === id);
|
||||
if (!a) throw new Error(`알 수 없는 설치 대상: ${id}`);
|
||||
return a;
|
||||
}
|
||||
|
||||
export { MARKER, rewriteRefPaths } from "./common.ts";
|
||||
export { claudeCode, codex, cursor, windsurf, agentsMd };
|
||||
66
packages/core/src/targets/windsurf.ts
Normal file
66
packages/core/src/targets/windsurf.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import path from "node:path";
|
||||
import { exists } from "../fsx.ts";
|
||||
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
|
||||
import { anyExists, referenceActions, rewriteRefPaths } from "./common.ts";
|
||||
|
||||
const REF_PREFIX = ".windsurf/rules/designpaca";
|
||||
|
||||
/** Windsurf 규칙 파일의 하드 상한. 넘으면 잘려서 조용히 망가진다. */
|
||||
const WINDSURF_CHAR_LIMIT = 12_000;
|
||||
|
||||
/**
|
||||
* Windsurf — .windsurf/rules/designpaca.md.
|
||||
* Cursor 와 경로·프론트매터가 모두 다르다(trigger/globs, .mdc 아님).
|
||||
*
|
||||
* trigger 는 model_decision 을 쓴다. always_on 으로 두면 디자인 스킬이 모든 메시지의
|
||||
* 시스템 프롬프트에 상주한다.
|
||||
*/
|
||||
export const windsurf: TargetAdapter = {
|
||||
id: "windsurf",
|
||||
label: "Windsurf",
|
||||
hint: ".windsurf/rules/designpaca.md — 프로젝트 단위",
|
||||
scopes: ["project"],
|
||||
|
||||
async detect(ctx: TargetContext) {
|
||||
return anyExists([
|
||||
path.join(ctx.cwd, ".windsurf"),
|
||||
path.join(ctx.cwd, ".windsurfrules"),
|
||||
path.join(ctx.home, ".windsurf"),
|
||||
]);
|
||||
},
|
||||
|
||||
async plan(ctx: TargetContext) {
|
||||
const rulesDir = path.join(ctx.cwd, ".windsurf", "rules");
|
||||
const rule = path.join(rulesDir, "designpaca.md");
|
||||
const refRoot = path.join(rulesDir, "designpaca");
|
||||
|
||||
const header = [
|
||||
"---",
|
||||
"trigger: model_decision",
|
||||
`description: ${ctx.skill.description}`,
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const body = header + rewriteRefPaths(ctx.skill.body, REF_PREFIX);
|
||||
|
||||
const actions: FileAction[] = [
|
||||
{ kind: "write", path: rule, content: body },
|
||||
...referenceActions(refRoot, ctx.skill),
|
||||
];
|
||||
|
||||
return {
|
||||
target: this.id,
|
||||
scope: ctx.scope,
|
||||
root: rulesDir,
|
||||
actions,
|
||||
alreadyInstalled: await exists(rule),
|
||||
// 상한을 넘으면 설치는 되지만 Windsurf 가 뒷부분을 버린다. 조용히 깨지느니 막는다.
|
||||
...(body.length > WINDSURF_CHAR_LIMIT
|
||||
? {
|
||||
blocked: `규칙 본문이 ${body.length}자로 Windsurf 상한(${WINDSURF_CHAR_LIMIT}자)을 넘는다. SKILL.md 를 줄여야 한다.`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
96
packages/core/src/types.ts
Normal file
96
packages/core/src/types.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/** 설치 대상 식별자 */
|
||||
export type TargetId = "claude-code" | "codex" | "cursor" | "windsurf" | "agents-md";
|
||||
|
||||
/** 설치 범위 — user: 홈 디렉터리 전역, project: 현재 프로젝트 */
|
||||
export type Scope = "user" | "project";
|
||||
|
||||
/** 한 파일에 대한 설치 동작 */
|
||||
export type FileAction =
|
||||
| { kind: "write"; path: string; content: string }
|
||||
/** 마커 블록으로 감싼 영역만 교체(사용자 문서 보존) */
|
||||
| { kind: "inject"; path: string; marker: string; content: string };
|
||||
|
||||
/** 설치 전 사용자에게 보여줄 계획 */
|
||||
export interface InstallPlan {
|
||||
target: TargetId;
|
||||
scope: Scope;
|
||||
/** 이 타깃이 파일을 쓰는 루트 (표시용) */
|
||||
root: string;
|
||||
actions: FileAction[];
|
||||
/** 이미 설치돼 있는가 */
|
||||
alreadyInstalled: boolean;
|
||||
/** 설치를 막는 사유. 있으면 apply 하지 않는다 */
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
/** 매니페스트에 기록되는 개별 파일 */
|
||||
export interface InstalledFile {
|
||||
path: string;
|
||||
/** 설치 시점 내용의 sha256 — 사용자 수정(드리프트) 감지에 쓴다 */
|
||||
sha256: string;
|
||||
/** 마커 주입 방식으로 설치된 파일인가 */
|
||||
marker?: string;
|
||||
}
|
||||
|
||||
export interface InstallRecord {
|
||||
target: TargetId;
|
||||
scope: Scope;
|
||||
root: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
files: InstalledFile[];
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
/** 매니페스트 스키마 버전 — 향후 마이그레이션 판단용 */
|
||||
schema: 1;
|
||||
installs: InstallRecord[];
|
||||
}
|
||||
|
||||
/** doctor/update 가 쓰는 드리프트 판정 */
|
||||
export type DriftStatus = "ok" | "modified" | "missing";
|
||||
|
||||
export interface DriftReport {
|
||||
record: InstallRecord;
|
||||
files: { path: string; status: DriftStatus }[];
|
||||
/** 사용자가 수정한 파일 경로 */
|
||||
modified: string[];
|
||||
}
|
||||
|
||||
/** 타깃 어댑터가 구현해야 하는 인터페이스 */
|
||||
export interface TargetAdapter {
|
||||
id: TargetId;
|
||||
/** TUI 에 표시할 이름 */
|
||||
label: string;
|
||||
/** 한 줄 설명 */
|
||||
hint: string;
|
||||
/** 이 타깃이 지원하는 범위 */
|
||||
scopes: Scope[];
|
||||
/** 시스템에 이 도구가 설치돼 있는 흔적이 있는가 (기본 선택 여부 판단) */
|
||||
detect(ctx: TargetContext): Promise<boolean>;
|
||||
/** 설치 계획 수립 — 파일을 쓰지 않는다 */
|
||||
plan(ctx: TargetContext): Promise<InstallPlan>;
|
||||
}
|
||||
|
||||
export interface TargetContext {
|
||||
scope: Scope;
|
||||
/** 홈 디렉터리 */
|
||||
home: string;
|
||||
/** 현재 작업 디렉터리(프로젝트 범위일 때 기준) */
|
||||
cwd: string;
|
||||
/** 번들된 스킬 소스 */
|
||||
skill: SkillSource;
|
||||
}
|
||||
|
||||
/** 번들에 포함된 스킬 원본 */
|
||||
export interface SkillSource {
|
||||
version: string;
|
||||
/** SKILL.md 본문 (프론트매터 포함) */
|
||||
skillMd: string;
|
||||
/** references/ 이하 상대경로 → 내용 */
|
||||
files: Map<string, string>;
|
||||
/** 프론트매터를 제외한 본문 — 프론트매터를 쓰지 않는 포맷용 */
|
||||
body: string;
|
||||
/** 프론트매터에서 뽑은 description */
|
||||
description: string;
|
||||
}
|
||||
163
packages/core/test/installer.test.ts
Normal file
163
packages/core/test/installer.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, test } from "node:test";
|
||||
import { applyPlan, planInstall, removeInstall } from "../src/installer.ts";
|
||||
import { inspectDrift, readManifest } from "../src/manifest.ts";
|
||||
import { extractBlock } from "../src/marker.ts";
|
||||
import type { SkillSource } from "../src/types.ts";
|
||||
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "designpaca-test-"));
|
||||
const home = path.join(tmp, "home");
|
||||
const cwd = path.join(tmp, "proj");
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** 본문에 references 경로를 넣어 타깃별 경로 재작성을 검증할 수 있게 한다 */
|
||||
const BODY = "# 본문\n\n토큰은 `references/tokens.md` 를 봐라.";
|
||||
|
||||
const skill: SkillSource = {
|
||||
version: "9.9.9",
|
||||
skillMd: ['---', 'name: designpaca', 'description: "테스트용"', '---', '', BODY, ''].join("\n"),
|
||||
files: new Map([["references/tokens.md", "# 토큰\n"]]),
|
||||
body: BODY,
|
||||
description: "테스트용",
|
||||
};
|
||||
|
||||
const env = { home, cwd };
|
||||
|
||||
test("claude-code: 설치 → 드리프트 없음 → 제거", async () => {
|
||||
const plan = await planInstall("claude-code", "user", skill, env);
|
||||
assert.equal(plan.alreadyInstalled, false);
|
||||
assert.equal(plan.actions.length, 3); // SKILL.md + references 1개 + 버전 스탬프
|
||||
|
||||
const res = await applyPlan(plan, skill.version, { env });
|
||||
assert.equal(res.written.length, 3);
|
||||
assert.equal(res.skipped.length, 0);
|
||||
|
||||
const skillMd = await fs.readFile(path.join(plan.root, "SKILL.md"), "utf8");
|
||||
assert.ok(skillMd.includes("name: designpaca"));
|
||||
// 디렉터리를 통째로 복사하는 타깃은 상대 경로가 그대로 맞다. 재작성하면 안 된다.
|
||||
assert.ok(skillMd.includes("`references/tokens.md`"));
|
||||
assert.ok(!skillMd.includes(".claude/skills"));
|
||||
|
||||
const drift = await inspectDrift(res.record);
|
||||
assert.deepEqual(drift.modified, []);
|
||||
|
||||
const removed = await removeInstall(res.record, { env });
|
||||
assert.equal(removed.removed.length, 3);
|
||||
assert.equal(removed.keptModified.length, 0);
|
||||
assert.equal((await readManifest(home)).installs.length, 0);
|
||||
await assert.rejects(() => fs.access(plan.root));
|
||||
});
|
||||
|
||||
test("사용자가 고친 파일은 update 가 건너뛴다", async () => {
|
||||
const plan = await planInstall("claude-code", "user", skill, env);
|
||||
const first = await applyPlan(plan, skill.version, { env });
|
||||
|
||||
const target = path.join(plan.root, "SKILL.md");
|
||||
await fs.writeFile(target, "내가 고친 내용\n", "utf8");
|
||||
|
||||
const drift = await inspectDrift(first.record);
|
||||
assert.deepEqual(drift.modified, [target]);
|
||||
|
||||
const again = await applyPlan(await planInstall("claude-code", "user", skill, env), skill.version, {
|
||||
env,
|
||||
});
|
||||
assert.deepEqual(again.skipped, [target]);
|
||||
assert.equal(await fs.readFile(target, "utf8"), "내가 고친 내용\n");
|
||||
|
||||
// force 면 덮되 .orig 로 남긴다
|
||||
const forced = await applyPlan(await planInstall("claude-code", "user", skill, env), skill.version, {
|
||||
env,
|
||||
force: true,
|
||||
});
|
||||
assert.equal(forced.skipped.length, 0);
|
||||
assert.equal(forced.backedUp.length, 1);
|
||||
assert.equal(await fs.readFile(`${target}.orig`, "utf8"), "내가 고친 내용\n");
|
||||
assert.ok((await fs.readFile(target, "utf8")).includes("name: designpaca"));
|
||||
|
||||
await removeInstall(forced.record, { env });
|
||||
await fs.rm(`${target}.orig`, { force: true });
|
||||
});
|
||||
|
||||
test("agents-md: 포인터만 주입하고 본문은 별도 디렉터리에 푼다", async () => {
|
||||
const doc = path.join(cwd, "AGENTS.md");
|
||||
await fs.writeFile(doc, "# 내 프로젝트\n\n내 지침.\n", "utf8");
|
||||
|
||||
const plan = await planInstall("agents-md", "project", skill, env);
|
||||
const res = await applyPlan(plan, skill.version, { env });
|
||||
|
||||
const after1 = await fs.readFile(doc, "utf8");
|
||||
assert.ok(after1.includes("# 내 프로젝트"));
|
||||
assert.ok(after1.includes("내 지침."));
|
||||
|
||||
const block = extractBlock(after1, "designpaca") ?? "";
|
||||
// AGENTS.md 는 항상 로드된다. 본문 전체가 아니라 포인터만 들어가야 한다.
|
||||
assert.ok(block.includes(".designpaca/SKILL.md"), "포인터가 없다");
|
||||
assert.ok(!block.includes("토큰은"), "본문이 통째로 들어갔다");
|
||||
assert.ok(block.length < 1000, `블록이 ${block.length}자로 너무 크다`);
|
||||
|
||||
// 본문은 .designpaca/ 에 있고, 그 안의 참조 경로가 재작성돼 있어야 한다
|
||||
const skillMd = await fs.readFile(path.join(cwd, ".designpaca", "SKILL.md"), "utf8");
|
||||
assert.ok(skillMd.includes(".designpaca/references/tokens.md"), "참조 경로가 재작성되지 않았다");
|
||||
await fs.access(path.join(cwd, ".designpaca", "references", "tokens.md"));
|
||||
|
||||
// 문서의 다른 곳을 고쳐도 드리프트로 잡히면 안 된다 (블록 안쪽만 본다)
|
||||
await fs.writeFile(doc, after1.replace("내 지침.", "내 지침을 고쳤다."), "utf8");
|
||||
assert.deepEqual((await inspectDrift(res.record)).modified, []);
|
||||
|
||||
await removeInstall(res.record, { env });
|
||||
const after2 = await fs.readFile(doc, "utf8");
|
||||
assert.ok(after2.includes("내 지침을 고쳤다."));
|
||||
assert.ok(!after2.includes("designpaca:start"));
|
||||
});
|
||||
|
||||
test("cursor 는 user 범위를 거부한다", async () => {
|
||||
const plan = await planInstall("cursor", "user", skill, env);
|
||||
assert.ok(plan.blocked);
|
||||
await assert.rejects(() => applyPlan(plan, skill.version, { env }));
|
||||
});
|
||||
|
||||
test("cursor: .mdc 프론트매터로 변환하고 참조 경로를 재작성한다", async () => {
|
||||
const plan = await planInstall("cursor", "project", skill, env);
|
||||
const res = await applyPlan(plan, skill.version, { env });
|
||||
|
||||
const mdc = await fs.readFile(path.join(cwd, ".cursor", "rules", "designpaca.mdc"), "utf8");
|
||||
assert.ok(mdc.startsWith("---\ndescription: 테스트용"));
|
||||
assert.ok(mdc.includes("alwaysApply: false"));
|
||||
assert.ok(mdc.includes("# 본문"));
|
||||
// 본문과 references 가 다른 디렉터리에 놓이므로 경로가 재작성돼야 한다
|
||||
assert.ok(
|
||||
mdc.includes(".cursor/rules/designpaca/references/tokens.md"),
|
||||
"참조 경로가 재작성되지 않았다",
|
||||
);
|
||||
await fs.access(path.join(cwd, ".cursor", "rules", "designpaca", "references", "tokens.md"));
|
||||
|
||||
await removeInstall(res.record, { env });
|
||||
});
|
||||
|
||||
test("windsurf: Cursor 와 다른 경로·프론트매터를 쓴다", async () => {
|
||||
const plan = await planInstall("windsurf", "project", skill, env);
|
||||
const res = await applyPlan(plan, skill.version, { env });
|
||||
|
||||
const rule = await fs.readFile(path.join(cwd, ".windsurf", "rules", "designpaca.md"), "utf8");
|
||||
// always_on 이면 디자인 스킬이 모든 메시지의 시스템 프롬프트에 상주한다
|
||||
assert.ok(rule.includes("trigger: model_decision"));
|
||||
assert.ok(!rule.includes("alwaysApply"));
|
||||
assert.ok(rule.includes(".windsurf/rules/designpaca/references/tokens.md"));
|
||||
|
||||
await removeInstall(res.record, { env });
|
||||
});
|
||||
|
||||
test("windsurf: 12,000자 상한을 넘으면 설치를 막는다", async () => {
|
||||
const huge: SkillSource = { ...skill, body: "가".repeat(13_000) };
|
||||
const plan = await planInstall("windsurf", "project", huge, env);
|
||||
assert.ok(plan.blocked, "상한 초과인데 막지 않았다");
|
||||
assert.match(plan.blocked, /상한/);
|
||||
});
|
||||
50
packages/core/test/marker.test.ts
Normal file
50
packages/core/test/marker.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { extractBlock, hasBlock, removeBlock, upsertBlock } from "../src/marker.ts";
|
||||
|
||||
const M = "designpaca";
|
||||
|
||||
test("빈 문서에 블록을 넣는다", () => {
|
||||
const out = upsertBlock("", M, "본문");
|
||||
assert.ok(hasBlock(out, M));
|
||||
assert.equal(extractBlock(out, M), "본문");
|
||||
});
|
||||
|
||||
test("기존 문서 뒤에 붙이고 원문을 보존한다", () => {
|
||||
const doc = "# 내 지침\n\n건드리지 마라.\n";
|
||||
const out = upsertBlock(doc, M, "우리 블록");
|
||||
assert.ok(out.startsWith("# 내 지침"));
|
||||
assert.ok(out.includes("건드리지 마라."));
|
||||
assert.equal(extractBlock(out, M), "우리 블록");
|
||||
});
|
||||
|
||||
test("두 번째 호출은 블록 내용만 교체한다", () => {
|
||||
const first = upsertBlock("# 문서\n\n앞부분\n", M, "v1");
|
||||
const second = upsertBlock(first, M, "v2");
|
||||
assert.equal(extractBlock(second, M), "v2");
|
||||
assert.ok(!second.includes("v1"));
|
||||
assert.ok(second.includes("앞부분"));
|
||||
// 블록이 두 개로 늘어나면 안 된다
|
||||
assert.equal(second.split("designpaca:start").length - 1, 1);
|
||||
});
|
||||
|
||||
test("블록 뒤에 사용자가 쓴 내용도 보존한다", () => {
|
||||
const doc = upsertBlock("앞\n", M, "블록") + "\n뒤에 쓴 내용\n";
|
||||
const out = upsertBlock(doc, M, "새 블록");
|
||||
assert.ok(out.includes("앞"));
|
||||
assert.ok(out.includes("뒤에 쓴 내용"));
|
||||
assert.equal(extractBlock(out, M), "새 블록");
|
||||
});
|
||||
|
||||
test("제거하면 우리 블록만 사라진다", () => {
|
||||
const doc = upsertBlock("# 문서\n\n내용\n", M, "블록");
|
||||
const out = removeBlock(doc, M);
|
||||
assert.ok(!hasBlock(out, M));
|
||||
assert.ok(out.includes("# 문서"));
|
||||
assert.ok(out.includes("내용"));
|
||||
});
|
||||
|
||||
test("블록이 없는 문서를 제거해도 그대로다", () => {
|
||||
const doc = "# 문서\n";
|
||||
assert.equal(removeBlock(doc, M), doc);
|
||||
});
|
||||
23
packages/core/test/skill-source.test.ts
Normal file
23
packages/core/test/skill-source.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { splitFrontmatter } from "../src/skill-source.ts";
|
||||
|
||||
test("프론트매터와 본문을 나눈다", () => {
|
||||
const { fm, body } = splitFrontmatter(
|
||||
['---', 'name: designpaca', 'description: "따옴표 있는 값"', '---', '', '# 제목', '내용'].join("\n"),
|
||||
);
|
||||
assert.equal(fm["name"], "designpaca");
|
||||
assert.equal(fm["description"], "따옴표 있는 값");
|
||||
assert.ok(body.trim().startsWith("# 제목"));
|
||||
});
|
||||
|
||||
test("프론트매터가 없으면 전체가 본문이다", () => {
|
||||
const { fm, body } = splitFrontmatter("# 제목만 있다");
|
||||
assert.deepEqual(fm, {});
|
||||
assert.equal(body, "# 제목만 있다");
|
||||
});
|
||||
|
||||
test("CRLF 문서도 처리한다", () => {
|
||||
const { fm } = splitFrontmatter("---\r\nname: designpaca\r\n---\r\n본문");
|
||||
assert.equal(fm["name"], "designpaca");
|
||||
});
|
||||
5
packages/core/tsconfig.json
Normal file
5
packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["node"], "noEmit": true },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
261
packages/skill/SKILL.md
Normal file
261
packages/skill/SKILL.md
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
---
|
||||
name: designpaca
|
||||
description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬. 랜딩 페이지·포트폴리오·마케팅 사이트·웹앱 UI를 새로 만들거나 기존 사이트를 리디자인할 때 쓴다. 레퍼런스 조사 → 방향 결정 → 디자인 토큰 → 구현(SVG 필터·three.js·인터랙티브 모션) → 셀프 감사까지 순서대로 진행하고, AI가 만든 티 나는 결과물을 구체적 지문 목록으로 차단한다. Use when building or redesigning any website, landing page, portfolio, hero section, or web UI where visual quality matters."
|
||||
---
|
||||
|
||||
# designpaca
|
||||
|
||||
너는 작은 디자인 스튜디오의 디자인 리드다. 이 스튜디오는 **어떤 클라이언트의 사이트도 다른 클라이언트의 것과 혼동될 수 없다**는 평판으로 먹고산다. 클라이언트는 이미 템플릿 같은 시안을 거절한 적이 있고, 지금 값을 치르고 사는 것은 **이 브리프에만 맞는 관점**이다.
|
||||
|
||||
그러므로 이 스킬의 목적은 "예쁘게 만들기"가 아니다. **왜 이 선택인지 말할 수 있는 디자인을 만드는 것**이다. 근거를 대지 못하는 결정은 기본값이고, 기본값의 총합이 AI 슬롭이다.
|
||||
|
||||
## 범위
|
||||
|
||||
**쓴다**: 랜딩 페이지 · 포트폴리오 · 마케팅 사이트 · 제품 소개 · 히어로 섹션 · 웹앱의 시각 언어 · 기존 사이트 리디자인
|
||||
|
||||
**쓰지 않는다**: 대시보드의 데이터 밀도 설계(→ 데이터 시각화 스킬) · 순수 백엔드 · 이미 확립된 디자인 시스템을 따라야만 하는 작업(그 시스템을 따르는 게 맞다) · "일단 돌아가게만" 요청
|
||||
|
||||
## 우선순위
|
||||
|
||||
충돌하면 위가 이긴다. **이 스킬의 기본값은 맨 아래다.**
|
||||
|
||||
```
|
||||
사용자의 명시적 지시 > 프로젝트의 design.md > 프로젝트의 기존 토큰·코드 > designpaca 기본값
|
||||
```
|
||||
|
||||
사용자가 "보라색으로 해달라"고 하면 보라색으로 한다. 아래 규칙은 **브리프가 침묵할 때의 기본값**이지 금지 목록이 아니다. 단 기본값을 벗어날 때는 **왜 이 브리프에 그것이 맞는지 한 문장으로 말하고** 진행한다. 말할 수 없으면 그건 결정이 아니라 기본값 회귀다.
|
||||
|
||||
## 핵심 규칙
|
||||
|
||||
브리프가 명시적으로 뒤집지 않는 한 지킨다.
|
||||
|
||||
1. **레퍼런스 없이 시작하지 않는다.** 전체 경로에서 1단계를 건너뛴 디자인은 5단계에서 실패 처리한다. 연장·국소 경로는 `design.md` 가 그 자리를 대신한다 — 그것도 없이 건너뛰면 실패다.
|
||||
2. **모든 시각적 결정에는 브리프로 소급되는 이유가 있어야 한다.** "보통 이렇게 한다"는 이유가 아니다.
|
||||
3. **화려함은 4순위다.** Awwwards 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10이다. 3D를 얹는 것보다 타입 스케일을 정돈하는 게 결과에 두 배 유효하다.
|
||||
4. **대담함은 한 곳에만.** 리스크는 하나다. 나머지는 조용히 받쳐준다. 두 곳에서 소리치면 둘 다 죽는다.
|
||||
5. **성능 예산을 넘기면 그 이펙트는 채택하지 않는다.** 예산은 3단계에서 정하고 5단계에서 검증한다. 데모·포트폴리오 브리프면 예산을 올려도 되지만 **올렸다고 말해야 한다**.
|
||||
|
||||
**접근성은 예외다. 이것만은 협상하지 않는다.** 키보드·포커스·대비·`prefers-reduced-motion`은 어떤 이펙트보다, 어떤 브리프보다 우선한다. 픽셀은 왜곡해도 DOM은 살린다.
|
||||
|
||||
## 파이프라인
|
||||
|
||||
일곱 단계다(0~6). 0단계에서 정한 경로에 따라 **일부 단계를 건너뛸 수는 있지만, 도는 단계의 순서는 고정이다.** 각 단계에는 통과 조건이 있고, 통과하지 못하면 다음 단계로 가지 않는다.
|
||||
|
||||
참조 문서는 **해당 단계에 들어갈 때 읽는다.** 처음부터 전부 읽지 마라 — 컨텍스트 낭비다.
|
||||
|
||||
---
|
||||
|
||||
### 0단계 — 브리프 게이트
|
||||
|
||||
**먼저 프로젝트 루트에 `design.md` 가 있는지 확인한다.** (프로젝트 루트 = 패키지 매니저 설정 파일이 있는 디렉터리. 모노레포면 **작업 대상 패키지의 루트**를 쓰고, 저장소 루트에도 있으면 둘 다 읽되 가까운 쪽이 이긴다) 있으면 읽고, 그 결정을 이 스킬의 기본값보다 우선한다. 같은 프로젝트를 두 번째로 작업할 때 지난번과 다른 디자인이 나오면 그건 실패다.
|
||||
|
||||
브리프를 세 줄로 압축한다. 세 줄을 못 쓰면 아직 작업을 시작할 수 없다.
|
||||
|
||||
```
|
||||
무엇을: (한 문장. 무엇을 만드는가)
|
||||
누구에게: (한 문장. 누가 보고, 무엇을 하길 바라는가)
|
||||
제약: (기술 스택 / 기존 브랜드 / 기한 / 성능 요구 / 콘텐츠 유무)
|
||||
```
|
||||
|
||||
**규모를 판정한다.** 셋 다 사실 질문이다. 추측하지 말고 파일을 보고 답해라.
|
||||
|
||||
- **A.** 없던 화면을 새로 만드는가?
|
||||
- **B.** 토큰 체계(색 역할·타입 스케일·간격 리듬·모션 문법)를 새로 정하거나 다시 정의하는가?
|
||||
- **C.** `design.md` 가 있는가?
|
||||
|
||||
| 조건 | 경로 | 도는 단계 |
|
||||
|---|---|---|
|
||||
| (A 또는 B) 이고 C 아니오 | **전체** | 0 → 1 → 2 → 3 → 4 → 5 → 6 |
|
||||
| (A 또는 B) 이고 C 예 | **연장** | 0 → 3 → 4 → 5 → 6 — 방향은 `design.md` 가 이미 답했다 |
|
||||
| A·B 둘 다 아니오, 손대는 섹션 2개 이하 | **국소** | 0 → 4 → 5 → 6(한 줄 추기) |
|
||||
|
||||
고른 경로와 근거를 한 줄로 말해라: *"국소 — 새 화면 없음, 토큰 재정의 없음, 섹션 1개."*
|
||||
|
||||
**애매하면 긴 쪽으로.** 단 국소 조건에 해당하면 국소로 가라 — 버튼 하나에 갤러리 3곳을 여는 것은 사용자가 이 스킬을 끄게 만든다.
|
||||
**국소로 시작했다가 조건이 깨지면 멈추고 올린다.** 토큰을 새로 정의하게 됐거나, 손댄 섹션이 3개를 넘었거나, 방향을 바꿔야 하면. **올렸다고 말해라. 조용히 국소에 머무는 것이 이 스킬의 최대 실패다.**
|
||||
|
||||
**막혔을 때**: 브리프가 비어 있으면 추측하지 말고 물어라. 단 **한 번에 다 묻지 마라.** 결과를 가장 크게 바꾸는 것 하나만 묻고, 나머지는 가정을 명시하고 진행한다.
|
||||
|
||||
**리디자인이면** 여기서 감사(audit)를 먼저 한다: 지금 무엇이 작동하고 무엇이 무너져 있는가, 유지해야 할 자산(로고·색·기존 사용자의 기대)은 무엇인가. 감사 없는 리디자인은 파괴다.
|
||||
|
||||
> 통과 조건: 세 줄이 채워졌다. 리디자인이면 감사 결과가 있다.
|
||||
|
||||
---
|
||||
|
||||
### 1단계 — 레퍼런스 조사 (전체 경로 전용, **건너뛰기 금지**)
|
||||
|
||||
이 단계가 designpaca의 심장이다. 머릿속 기본값이 아니라 **실제로 존재하는 사이트**에서 시작한다. 연장·국소 경로는 0단계에서 이미 이 단계를 건너뛰기로 정했고, 그 근거는 `design.md` 다.
|
||||
|
||||
레퍼런스 **3개**를 서로 **다른 층위**에서 고른다:
|
||||
|
||||
| 슬롯 | 무엇 | 규칙 |
|
||||
|---|---|---|
|
||||
| **R1 — 구조** | 같은 업종/목적의 사이트 | 정보 구조와 흐름을 가져온다 |
|
||||
| **R2 — 톤** | **반드시 다른 업종** | 분위기·재질·타이포 감각을 가져온다 |
|
||||
| **R3 — 디테일** | 어디서든 | 하나의 구체적 기법(모션·타입·인터랙션) |
|
||||
|
||||
R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된다. SaaS 구조 + SaaS 톤 = 또 하나의 SaaS 슬롭이다.
|
||||
|
||||
- 어디서 찾는가 → `references/galleries.md` (브리프별 라우팅 표)
|
||||
- 어떻게 뜯어보는가 → `references/reference-method.md` (6축 해체 프레임워크, WebFetch 템플릿)
|
||||
|
||||
**갤러리 목록 페이지가 아니라 원본 사이트를 열어라.** 이미지를 볼 수 없어도 구조는 읽을 수 있다.
|
||||
|
||||
> 통과 조건: R1/R2/R3 각각의 URL과, 그것에서 **무엇을 가져올지** 한 줄씩. 형식은 `reference-method.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
### 2단계 — 방향 결정
|
||||
|
||||
레퍼런스 3개를 하나의 방향으로 합성한다. 베끼지 않는다. **축을 정하고 그 축 위에서 결정한다.**
|
||||
|
||||
정해야 할 것:
|
||||
- **한 문장 컨셉** — 이 사이트가 주는 인상을 한 문장으로. ("고급 잡지의 여백", "계기판처럼 정확한", "밤의 스튜디오")
|
||||
- **미학 프리셋** — 아래에서 고르거나, 브리프가 요구하면 새로 정의한다
|
||||
- **감수할 리스크 하나** — 정당화할 수 있는 과감한 선택 하나. 없으면 그 디자인은 안전하고 잊힌다
|
||||
|
||||
| 프리셋 | 한 줄 | 언제 |
|
||||
|---|---|---|
|
||||
| `references/presets/editorial.md` | 잡지의 여백과 세리프 | 콘텐츠가 주인공. 브랜드·미디어·포트폴리오·럭셔리 |
|
||||
| `references/presets/swiss-minimal.md` | 그리드와 침묵 | 제품이 복잡할 때. B2B·도구·문서 |
|
||||
| `references/presets/anti-grid.md` | 의도적으로 깨진 격자 | 기억되어야 할 때. 에이전시·아트·캠페인 |
|
||||
| `references/presets/dark-instrument.md` | 계기판처럼 정확한 | 개발자·데이터·기술 제품 |
|
||||
| `references/presets/quiet-commerce.md` | 읽히는 커머스 | 전환이 목적인 B2C. 상품 수가 적고 사양이 설득의 주체 |
|
||||
|
||||
고른 프리셋 파일 **하나만** 읽어라. 다섯 다 읽지 마라. 고르는 기준은 `references/presets/README.md`.
|
||||
|
||||
> 통과 조건: 한 문장 컨셉 + 프리셋 + 리스크 하나가 적혔다.
|
||||
> **연장 경로는 이 단계를 돌지 않는다.** `design.md` 의 컨셉·프리셋·리스크를 그대로 이어받는다. 바꾸고 싶으면 전체 경로로 올린다.
|
||||
|
||||
---
|
||||
|
||||
### 3단계 — 디자인 토큰과 예산
|
||||
|
||||
구현 전에 **숫자를 먼저 정한다.** 코드를 쓰면서 색을 고르면 매번 다른 색이 나온다.
|
||||
|
||||
- 타입 스케일 · 색 역할 · 간격 리듬 · 모션 문법 → `references/tokens.md`
|
||||
- 한글이 들어가면 → `references/antipatterns.md` 의 한글 조판 섹션을 **반드시** 읽어라. 서구 레퍼런스에는 이 정보가 없다
|
||||
|
||||
**성능 예산도 여기서 정한다.** 나중에 정하면 이미 늦는다. 전체 표는 `references/tokens.md` §5 하나뿐이다 — 다른 문서에 예산 표를 만들지 마라.
|
||||
|
||||
기본선: 히어로까지 JS **150KB(gzip)** / 첫 인터랙션 **3초** / 애니메이션은 `transform`·`opacity` 만.
|
||||
브리프가 데모·포트폴리오라면 예산을 올려도 된다. **올린다는 사실과 이유를 명시해라.**
|
||||
|
||||
> 통과 조건: 토큰이 실제 값으로 적혔고, 성능 예산이 숫자로 정해졌다.
|
||||
|
||||
---
|
||||
|
||||
### 4단계 — 구현
|
||||
|
||||
순서가 있다. **레이아웃 → 재질 → 모션.** 거꾸로 가면 화려한데 읽을 수 없는 페이지가 나온다.
|
||||
|
||||
**4-1. 레이아웃과 타이포그래피** → `references/layout.md`
|
||||
그리드, 여백 리듬, 시선 흐름. 3단계의 토큰을 그대로 쓴다. 이 단계가 끝나면 **아무 이펙트 없이도 완성된 페이지**여야 한다. 이것이 모든 폴백의 기반이다.
|
||||
|
||||
**4-2. 재질(surface)** → `references/svg-filters.md`
|
||||
"이 디자인의 표면은 무엇으로 되어 있는가"를 결정한다. 종이인가, 유리인가, 금속인가, 필름인가. SVG 필터는 장식이 아니라 **재질을 만드는 도구**다. 그레인·굴절·번짐·수차를 여기서 선택한다.
|
||||
|
||||
**4-3. 입체와 공간** → `references/three.md`
|
||||
필요할 때만. 3단계 예산을 넘기면 채택하지 않는다. 무거운 씬 임포트보다 **셰이더 플레인 하나**로 같은 인상을 내는 쪽을 먼저 검토한다. 채택하면 폴백을 같이 만든다.
|
||||
|
||||
**4-4. 모션과 인터랙션** → `references/motion.md`
|
||||
모션은 장식이 아니라 **문법**이다. 무엇이 어디서 와서 어디로 가는지 말한다. 이유 없는 등장 애니메이션은 넣지 않는다.
|
||||
|
||||
**실험 경로** → `references/experimental-canvas.md`
|
||||
HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는다. 기본값은 쓰지 않는 것이다.
|
||||
|
||||
> 통과 조건: 이펙트를 전부 끈 상태에서도 페이지가 완성돼 있다.
|
||||
|
||||
---
|
||||
|
||||
### 5단계 — 프리플라이트 감사
|
||||
|
||||
**자기 결과물을 남의 것처럼 본다.** 통과 못 한 항목은 고치고 다시 돈다.
|
||||
|
||||
→ `references/preflight.md` (전체 체크리스트)
|
||||
→ `references/antipatterns.md` (슬롭 지문 목록 + grep 검출 + 자가 채점표)
|
||||
|
||||
특히 다음 셋은 기계적으로 검사할 수 있다. **반드시 돌려라**:
|
||||
|
||||
1. **슬롭 지문 grep** — 보라 CTA(`#6366f1`·`#8b5cf6` 계열), 전체 대문자 헤드라인, 번호 매긴 1·2·3 단계. 실측 검출률 상위 항목이다
|
||||
2. **카피 경쟁사 치환 테스트** — 제품명을 경쟁사 이름으로 바꿔도 문장이 성립하면, 그 카피는 아무것도 말하지 않았다
|
||||
3. **이펙트 전부 끄기** — CSS 필터·WebGL·애니메이션을 끈 상태에서 페이지가 여전히 읽히는가
|
||||
|
||||
#### 하드 게이트 — 전부 "아니오"여야 한다
|
||||
|
||||
취향이 아니라 **버그**다. 여기엔 오버라이드가 없다. 체크박스가 아니라 질문이니 실제로 검사해라.
|
||||
|
||||
1. 토큰 밖에 인라인 hex/rgb/oklch 색상값이나 인라인 `font-family` 가 있는가?
|
||||
2. 상태색(성공·경고·오류)을 제외하고, 한 페이지에서 강조색이 2개 이상인가?
|
||||
3. `border-radius` 값이 토큰 밖에 있거나, 서로 다른 값이 3종 이상인가?
|
||||
4. 페이지 중간에 테마(라이트/다크)가 뒤집히는데, 그 반전이 3단계 토큰에 규칙으로 정의돼 있지 않은가?
|
||||
(리듬으로 의도한 반전은 통과다. 정의 없이 섹션마다 다른 것이 실패다)
|
||||
5. 320~1920px 사이 어느 폭에서든 가로 스크롤이 생기는가?
|
||||
6. 버튼 라벨·내비 링크가 2줄로 접히는 폭이 있는가?
|
||||
7. 버튼 텍스트와 배경의 대비가 4.5:1 미만인가?
|
||||
8. `transform`/`opacity` 외의 속성을 애니메이션하는가?
|
||||
9. 풀하이트 섹션에 `100vh` 를 썼는가? (모바일 주소창 때문에 `100dvh` 여야 한다)
|
||||
10. `window.addEventListener('scroll')` 을 썼는가? (`IntersectionObserver` 또는 scroll-driven animation 을 써라)
|
||||
11. **사용자가 주지 않은 수치**(지표·통계·후기·고객 수)가 페이지에 **그럴듯한 값으로** 들어가 있는가?
|
||||
걸렸으면 셋 중 하나다: (a) `{{SETUP_TIME}}` 같은 **명시적 placeholder** 로 바꾸고 6단계 `design.md` 미확정 목록에 올린다, (b) 사용자에게 실제 값을 묻고 멈춘다, (c) 그 섹션 자체를 다른 구조로 바꾼다.
|
||||
**placeholder 는 통과다.** 숫자 모양의 구멍은 정직하고, 지어낸 숫자는 슬롭이다.
|
||||
12. `<div>` 로 만든 가짜 스크린샷·가짜 브라우저바·가짜 폰 프레임이 있는가?
|
||||
|
||||
#### 카운트 규칙
|
||||
|
||||
세어봐라. 넘으면 고친다.
|
||||
|
||||
- 작은 대문자 라벨(eyebrow) 개수 ≤ `ceil(섹션수 / 3)`
|
||||
- 같은 이미지+텍스트 스플릿 레이아웃 연속 ≤ 2회
|
||||
- 마퀴 ≤ 1개
|
||||
- 섹션이 6개 이상이면 서로 다른 레이아웃 패밀리가 최소 3개
|
||||
- 히어로: 헤드라인 ≤ 2줄, 서브텍스트 ≤ 20단어, 텍스트 요소 ≤ 4개
|
||||
|
||||
**국소 경로는 카운트를 페이지 전체로 다시 세지 않는다.** 내가 손댄 부분이 기존 카운트를 넘기게 만드는지만 본다.
|
||||
**하드 게이트 12개는 경로와 무관하게 전부 돈다.**
|
||||
|
||||
> 통과 조건: 하드 게이트 12개 전부 "아니오", 카운트 규칙 통과, `preflight.md` 체크리스트 통과. 실패 항목이 있으면 4단계로 돌아간다.
|
||||
|
||||
---
|
||||
|
||||
### 6단계 — 결정을 남긴다
|
||||
|
||||
작업이 끝나면 프로젝트 루트(0단계에서 정한 그 디렉터리)에 **`design.md`** 를 쓴다. 다음 실행(사람이든 에이전트든)이 이 파일을 읽고 같은 결정을 이어간다.
|
||||
|
||||
```markdown
|
||||
# design.md
|
||||
브리프 3줄 / 레퍼런스 R1·R2·R3 URL / 한 문장 컨셉 / 프리셋 / 감수한 리스크 하나
|
||||
토큰 전체(타입·색·간격·모션) / 성능 예산과 실측치 / 채택한 이펙트와 그 폴백
|
||||
의도적으로 하지 않은 것과 그 이유
|
||||
```
|
||||
|
||||
마지막 줄이 가장 중요하다. **하지 않기로 한 결정을 적어두지 않으면 다음 사람이 그것을 "빠뜨린 것"으로 착각하고 되돌린다.**
|
||||
|
||||
**국소 경로는 전체를 다시 쓰지 않는다.** `design.md` 끝에 한 줄만 추기한다 — `날짜 · 무엇을 바꿨는지 · 토큰 변경이 있으면 그 값`. 기록 없는 국소 작업이 쌓이면 design.md 가 거짓말이 된다.
|
||||
|
||||
> 통과 조건: `design.md` 가 프로젝트 루트에 있다(국소면 추기됐다).
|
||||
|
||||
---
|
||||
|
||||
## 참조 문서 지도
|
||||
|
||||
| 파일 | 언제 읽나 |
|
||||
|---|---|
|
||||
| `references/galleries.md` | 1단계 — 어느 갤러리를 볼지 정할 때 |
|
||||
| `references/reference-method.md` | 1단계 — 레퍼런스를 뜯어볼 때 |
|
||||
| `references/presets/README.md` | 2단계 — 미학 방향을 고를 때 (고른 프리셋 하나만 추가로 읽는다) |
|
||||
| `references/tokens.md` | 3단계 — 토큰을 정할 때 |
|
||||
| `references/layout.md` | 4-1 — 그리드와 타이포 |
|
||||
| `references/svg-filters.md` | 4-2 — 재질을 만들 때 |
|
||||
| `references/three.md` | 4-3 — 입체가 필요할 때 |
|
||||
| `references/motion.md` | 4-4 — 움직임을 설계할 때 |
|
||||
| `references/experimental-canvas.md` | 4단계 — HTML-in-Canvas를 검토할 때 |
|
||||
| `references/preflight.md` | 5단계 — 감사 |
|
||||
| `references/antipatterns.md` | 3·5단계 — 한글 조판 / 슬롭 검출 |
|
||||
|
||||
## 작업 중 지켜야 할 것
|
||||
|
||||
- **단계를 보고하며 진행해라.** 사용자는 어느 단계인지 알아야 개입할 수 있다
|
||||
- **가정은 소리 내서 말해라.** 브리프에 없어서 정한 것은 명시한다
|
||||
- **되돌릴 수 있게 만들어라.** 토큰을 바꾸면 전체가 따라 바뀌는 구조로 짠다. 값을 하드코딩하면 수정 요청 한 번에 무너진다
|
||||
- **모르면 열어봐라.** 레퍼런스 사이트도, 참조 문서도, 실제로 읽고 나서 결정해라
|
||||
11
packages/skill/package.json
Normal file
11
packages/skill/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "@designpaca/skill",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서",
|
||||
"scripts": {
|
||||
"build": "echo \"(스킬은 cli 빌드 시 dist/skill 로 번들된다)\"",
|
||||
"typecheck": "echo \"(문서 패키지 — 타입 검사 없음)\"",
|
||||
"test": "node ../../build/ci/lint-skill.mjs ."
|
||||
}
|
||||
}
|
||||
271
packages/skill/references/antipatterns.md
Normal file
271
packages/skill/references/antipatterns.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# antipatterns.md — 금지 목록
|
||||
|
||||
AI가 만든 티는 **못 만들어서** 나는 게 아니라 **결정을 안 해서** 난다.
|
||||
보라 그라디언트, Inter, 3열 아이콘 카드, `rounded-2xl shadow-lg`, "Get Started"는 전부 **미결정의 기본값**이다.
|
||||
|
||||
프리플라이트에서 이 문서를 훑고 걸린 항목을 전부 해소한다. **4개 이상 걸리면 폐기하고 다시 시작한다.**
|
||||
|
||||
---
|
||||
|
||||
## 0. 최우선 — 측정된 상위 지문
|
||||
|
||||
Show HN 랜딩 1,590개 실측에서 검출률이 가장 높았던 셋이다. **이 셋부터 확인하라.**
|
||||
|
||||
| 순위 | 지문 | 검출률 | 왜 문제 | 대신 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **CTA 버튼이 보라·인디고 계열** (`bg-indigo-600`, `#6366f1`, `#8b5cf6`) | **10.7%** | Tailwind 기본 팔레트 = "색을 안 골랐다"는 신호 | 브랜드 색. 없으면 중립 대비가 가장 강한 색(잉크 위 화이트 반전) |
|
||||
| 2 | **헤드라인·섹션 라벨이 전체 대문자** (`FEATURES`, `HOW IT WORKS`) | **10.5%** | 얻지 않은 권위를 빌리는 장치. 가독성도 낮음 | 라벨을 없애고 헤드라인만으로 섹션을 구분 |
|
||||
| 3 | **번호 매긴 1·2·3 단계 섹션** | **9.4%** | 실제 순서가 아닌데 순서인 척 | 단계가 진짜 순서일 때만. 그 경우에도 번호보다 화면 캡처 3장이 낫다 |
|
||||
|
||||
전체 표본의 22%가 4개 이상 지문을 가진 "heavy slop"이었다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 컬러
|
||||
|
||||
> **다크가 슬롭이 아니라 기본값이 슬롭이다.**
|
||||
> bun.sh는 다크 배경인데도 이 절의 항목을 거의 전부 회피한다 — 순흑이 아닌 `#0D0A0C`, 텍스트 3단계, 보라 대신 마젠타 `#FF2E97`, radius 어휘 3종.
|
||||
> A-01은 "다크를 쓰지 마라"가 아니라 **"고르지 않은 채로 다크에 떨어지지 마라"**는 뜻이다. 아래 전부 동일하다.
|
||||
|
||||
| 지문 | 왜 문제 | 대신 |
|
||||
|---|---|---|
|
||||
| **다크 모드를 브리프 근거 없이 기본값으로** (`bg-slate-900`, `#0B0B0F`) | 생성 도구의 반사 반응. 단일 시그니처 검출 빈도 1위 | 라이트로 설계하고 다크는 토큰으로 파생. 사용자 82%가 다크를 쓴다는 데이터는 "지원하라"이지 "기본값으로 하라"가 아니다 |
|
||||
| **보라→파랑 그라디언트 히어로** (`from-purple-600 to-blue-500`, `#6366f1→#a855f7`) | 수십만 튜토리얼의 기본값 | 브랜드 색 1개 + 중립. 그라디언트를 쓸 거면 같은 hue 내 명도 변화 (`#1B4332→#2D6A4F`) |
|
||||
| **라벤더 퍼플이 어디에나** (`#a78bfa`, `#c4b5fd`) | 텍스트→랜딩 생성기의 출력 지문 | 보라를 쓰지 마라. 꼭 필요하면 채도를 낮추고 온도를 틀어라 (`#6B5B95`) |
|
||||
| **네온 온 다크** — 시안(`#22d3ee`)·바이올렛이 검정 위에서 발광 | 게이밍·개발자 툴 브리프가 아닌데 나오면 즉시 티가 남 | 저채도 액센트 1개. 발광 대신 명도 차이로 위계 |
|
||||
| **컬러 글로우** (`box-shadow: 0 0 80px rgba(139,92,246,.5)`) | 광원 논리 없이 깊이를 색으로 위조 | 중립 그림자 2단 (`0 1px 2px rgba(0,0,0,.06)`, `0 8px 24px rgba(0,0,0,.08)`) |
|
||||
| **히어로 뒤 방사형 그라디언트 오브·헤일로** | 구성을 못 잡았을 때의 회피 수단 | 배경을 비우거나 실제 콘텐츠(제품 스크린샷, 타입)로 채워라 |
|
||||
| **순백 `#ffffff` / 순흑 `#000000`** 을 배경·텍스트로 | 아무 결정도 하지 않았다는 뜻. 눈부심·번짐 유발 | 오프화이트 `#FAFAF7`, 잉크 `#111014`. 브랜드 hue를 2~4% 섞으면 더 좋다 |
|
||||
| **Tailwind 기본 토큰 그대로** (`slate-900`, `gray-500`, `emerald-500`) | 누구나 알아본다 | `tailwind.config`의 색을 **교체**한다(확장 아님). `ink-900`, `ember-600` 같은 고유 이름 |
|
||||
| **코드에서 색 이름을 직접 호출** (`purple-500`) | 색이 역할을 못 가짐 | 의미 토큰: `--color-action-primary`, `--color-surface-elevated`, `--color-text-secondary` |
|
||||
| **그라디언트 텍스트** (`background-clip: text`) | 스캔성을 깎고 정보는 0. OG 이미지에서 깨짐 | 단색. 강조는 크기·굵기·여백으로 |
|
||||
| **크림/베이지(`#FDF8F3`)를 "고급"의 기본값으로** | 보라를 대체한 신종 슬롭 | 크림을 쓸 거면 왜 크림인지 브리프와 연결하고 텍스트·액센트를 그에 맞춰 재설계 |
|
||||
| **hue 4개 이상** | 결정 못 한 상태 | hue 3개 이하. 면적 60(지배)/30(중립)/10(강조) |
|
||||
| **다크에서 본문 대비 미달** (`#0f172a` 위 `#94a3b8`) | 기능적 결함 | 본문 4.5:1 또는 APCA Lc ≥ 75. 다크의 본문은 순백 대신 `#E8E6E3` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 타이포그래피
|
||||
|
||||
| 지문 | 왜 문제 | 대신 |
|
||||
|---|---|---|
|
||||
| **Inter를 이유 없이 기본값으로** (특히 중앙 정렬 히어로) | AI 인터페이스의 기본 서체 | Satoshi / Switzer / DM Sans / Work Sans. Inter는 초고밀도 다국어 UI 같은 명확한 이유가 있을 때만 |
|
||||
| **Space Grotesk + Instrument Serif + Geist 조합** | 이 셋의 **반복 조합**이 생성 지문으로 특정됨 | 셋 중 **하나만** 쓰는 것은 허용. 둘 이상 함께 쓰면 실패. 파운드리를 바꾸면 더 낫다 (Fontshare, Velvetyne) |
|
||||
| **헤드라인 한 단어만 세리프 이탤릭** (`Build <em>better</em> products`) | 가장 널리 퍼진 "성의 표시" 관용구. 이제는 성의가 아니라 지문 | 강조는 줄바꿈·크기·색·여백으로 |
|
||||
| **페이지 전체에 폰트 패밀리 1개** | 위계를 크기로만 만들게 되어 밋밋 | 디스플레이 1 + 본문 1. 성격 차이를 크게 |
|
||||
| **타입 스케일이 평평함** (16/18/20/24, 비율 1.15 미만) | 모든 게 똑같이 중요해 보임 | 비율 1.25 또는 1.333. **최대/본문 4배 이상** (17→68). 비율만으로 4배가 안 나오면(1.25 5단계 = 2.44배) **디스플레이 사이즈를 스케일 밖에 따로 정의한다**(`tokens.md` §1). **단계 수를 늘려서 맞추지 마라** — 그건 아래 행에 걸린다 |
|
||||
| **타입 스케일 단계 8개 이상** | 통제 실패 | 3~5단계로 제한하고 각 단계에 역할 이름 부여 |
|
||||
| **히어로 = pill 배지 + 거대 헤드라인** (`✨ Now in beta` + H1) | 가장 알아보기 쉬운 구조 지문 | 배지를 지워라. 정말 필요하면 문장 안이나 내비 옆으로 |
|
||||
| **`_01_ _02_ _03_` 장식 번호 라벨** | 에디토리얼 흉내인데 구조는 없음 | 번호가 순서를 의미할 때만 |
|
||||
| **본문 letter-spacing 0.05em 이상** | 단어 인식이 느려짐 | 본문 0. 소형 대문자 라벨에만 +0.08em |
|
||||
| **대형 헤드라인 음수 자간 -0.06em 이상** | 글자가 서로 먹힘 | -0.02em ~ -0.04em |
|
||||
| **본문 line-height 1.3 미만 / 본문 12px 이하** | 가독성·접근성 실패 | 라틴 본문 line-height 1.5–1.7, 크기 16–18px 권장. **예외**: 고밀도 라틴 UI(대시보드·개발자 도구)에서 13–14px는 의도된 선택일 수 있다 — bun.sh 본문 최다 크기가 13.5px다. 단 **한글에는 이 예외를 적용하지 마라.** 한글은 같은 px에서 라틴보다 작아 보여 16px가 하한이다 |
|
||||
| **모노스페이스를 장식으로** (코드 아닌 라벨에 `font-mono`) | "테크 느낌"의 저비용 흉내 | 모노는 코드·데이터·식별자에만 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 레이아웃 · 구조
|
||||
|
||||
| 지문 | 왜 문제 | 대신 |
|
||||
|---|---|---|
|
||||
| **표준 골격**: 히어로 → 3열 피처 → 로고월 → 요금제 → FAQ → 푸터 | shadcn/ui 예제·Tailwind UI·Vercel 템플릿의 순서 그대로 | 섹션 순서를 브리프의 설득 논리로 재배열. 예: 문제 제시 → 실제 사용 화면 → 반론 처리 → 가격 |
|
||||
| **중앙 정렬 히어로** (텍스트 가운데 + CTA 2개 + 아래 스크린샷) | 가장 안전해서 가장 흔함 | 비대칭 그리드(5:7, 7:5), 좌측 정렬 대형 타입, 또는 텍스트를 화면 하단 1/3로 |
|
||||
| **정확히 3열, 균등 폭 피처 카드** | 콘텐츠가 3개여서가 아니라 3이 예쁘게 떨어져서 3인 것 | 항목 수를 콘텐츠가 정하게. 폭도 중요도에 따라 다르게 |
|
||||
| **아이콘이 카드 상단 중앙**에 있는 피처 카드 (아이콘 타일 + 제목 + 2문장) | 가장 확실한 단일 지문 | 아이콘을 없애고 숫자·스크린샷·실제 UI 조각으로. 쓸 거면 좌측 인라인 |
|
||||
| **카드 좌측 3–4px 컬러 스트립** (`border-l-4 border-purple-500`) | em-dash에 맞먹는 신뢰도의 지문 | 삭제. 구분이 필요하면 배경 톤 차이나 여백 |
|
||||
| **카드 안의 카드 안의 카드** | 시각적 소음. 깊이가 정보와 무관 | 중첩 최대 1단계. 안쪽은 구분선이나 여백으로 |
|
||||
| **근거 없는 지표 배너** (`10,000+ users · 99.9% uptime · 4.9★`) | 검증 불가한 숫자 나열은 신뢰를 깎는다 | 숫자 1개만, 출처와 함께. 없으면 섹션 삭제 |
|
||||
| **모든 여백이 같은 값** (전부 `p-6`, 전부 `gap-4`) | 리듬이 없어 강약이 사라짐 | 8pt 그리드 위 4~6종. 섹션 간 : 요소 간 ≈ 1:5 |
|
||||
| **섹션마다 동일한 상하 패딩** (`py-20` 반복) | 어디가 중요한지 알 수 없음 | 중요한 섹션에 더 많은 여백. 여백이 강조다 |
|
||||
| **벤토 그리드를 기본 선택지로** | 2026년 기준 표준이라 차별화가 아니다. "고민 안 했음"의 새 신호 | 타일 크기 차이가 정보 위계를 반영할 때만. 아니면 단순 리스트 |
|
||||
| **콘텐츠가 뷰포트 가장자리에 붙음** | 모바일에서 특히 조악 | 모바일 16–24px, 데스크톱 24–80px 좌우 패딩 |
|
||||
| **본문 컬럼이 화면 전체 폭** | 한 줄이 100자를 넘어 읽기가 무너짐 | 영문 `max-width: 65ch`. 국문은 25–40자 폭 |
|
||||
| **푸터가 링크 4열 + 소셜 아이콘 + 카피라이트뿐** | AI가 가장 성의 없이 만드는 곳 | 실제 정보(연락처, 주소, 사업자 정보)를 넣어라. 링크가 4개뿐이면 4열로 만들지 마라 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트
|
||||
|
||||
| 지문 | 왜 문제 | 대신 |
|
||||
|---|---|---|
|
||||
| **`rounded-2xl shadow-lg p-6` 를 손대지 않고 사용** | shadcn/ui 기본값. AI가 복붙하도록 설계된 값 | radius를 프로젝트 고유값으로(4px 또는 12px). 큰 그림자 대신 1px 보더 + 배경 톤 차이 |
|
||||
| **모든 요소 border-radius 16px 균일 / 24px 이상 과대** | radius가 결정이 아니라 기본값임을 드러냄. 24px+ 는 블롭처럼 보임 | radius 어휘 2개 (컨테이너 12px, 인터랙티브 8px). 또는 0으로 통일 |
|
||||
| **1px 회색 보더 + 넓게 퍼진 그림자 동시 사용** | 두 깊이 표현을 겹친 것. 광원 논리 없음 | 하나만 선택 |
|
||||
| **글래스모피즘 전면 사용** (`backdrop-blur` 카드 다수) | 실기기 FPS 15~30% 하락 | 내비 바·모달로 제한. 카드에는 쓰지 마라 |
|
||||
| **이모지를 아이콘 대신** (🚀 ⚡ 🎯 ✨) | 즉각적인 아마추어 신호 | 아이콘 세트 하나 고정 (Lucide, Phosphor, Radix). 이모지는 카피 안에서만 |
|
||||
| **버튼이 항상 2개 나란히** (`Get started` + `Learn more`) | 주 행동을 정하지 못했다는 뜻 | 주 CTA 1개. 보조는 텍스트 링크로 격하 |
|
||||
| **회색조 로고 6~8개 일렬 로고월** | 대부분 무관하거나 검증 불가 | 실제 고객이면 한 곳의 사례를 문장으로. 없으면 섹션 삭제 |
|
||||
| **이니셜 원형 아바타 + 텍스트 후기 3개** | 검증 불가한 후기는 신뢰를 깎는다 | 실명 + 직함 + 원문 링크. 없으면 넣지 마라 |
|
||||
| **가상 질문으로 채운 FAQ 6개** (`How does it work?`) | 실제로 받은 질문이 아님 | 실제 문의 3개. 답변에 구체적 숫자·조건 |
|
||||
| **요금제 3열 카드 + 가운데 "Most popular" 배지** | 가장 재현율 높은 SaaS 템플릿 | 비교 표, 단일 가격, 또는 계산기 |
|
||||
| **펄스 애니메이션 상태 점** (초록 원 깜빡임 + "All systems operational") | 정적 정보에 장식 애니메이션 | 상태가 실제로 바뀔 때만 |
|
||||
| **자동 스크롤 마퀴** (로고·후기·태그가 끝없이 흐름) | 읽기를 방해하고 주의를 강탈 | 정적 그리드. 많으면 페이지네이션 |
|
||||
| **hover가 아무 반응 없거나 전 요소가 동일하게 `scale(1.02)`** | 인터랙션을 설계하지 않았다는 신호 | 요소마다 다른 반응(링크는 밑줄, 카드는 배경 톤, 버튼은 명도). **hover는 가장 먼저 설계한다** |
|
||||
| **편집 불가 히어로 카피 뒤 깜빡이는 커서 `|`** | 타이핑 흉내. 정보 없음 | 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 모션
|
||||
|
||||
| 지문 | 왜 문제 | 대신 |
|
||||
|---|---|---|
|
||||
| **모든 요소에 동일한 fade-in-up** (`opacity 0→1`, `translateY 20px`, 같은 delay) | 모션 언어가 없다는 뜻. 페이지가 계속 떠오르기만 함 | 중요한 것에만. 순차 등장이 필요하면 stagger 40–60ms |
|
||||
| **인터페이스에 bounce / elastic 이징** | 2010년대 감성. 즉시 촌스러움 | `ease-out` 또는 `cubic-bezier(0.2, 0, 0, 1)` |
|
||||
| **이미지 hover 시 `scale` 또는 `rotate`** | 생성 UI의 반복 지문 | 이미지는 두고 캡션·오버레이·보더를 변화시켜라 |
|
||||
| **duration이 전부 300ms** | 마이크로와 연출을 구분 못 함 | 마이크로 100–200 / 트랜지션 200–400 / 연출 600–1200ms |
|
||||
| **스크롤 reveal 실패 시 콘텐츠가 `opacity: 0`으로 남음** | JS 실패 시 빈 페이지가 됨 | 기본을 visible로 두고 JS가 숨긴 뒤 보여주는 방식. 또는 CSS scroll-driven animation |
|
||||
| **`prefers-reduced-motion` 미대응** | 접근성 실패 | 모션 제거 경로를 반드시 제공 |
|
||||
| **전 섹션 패럴랙스** | 스크롤 감각이 어긋나고 성능 저하 | 한 섹션만, 이동량 10–20px |
|
||||
| **히어로에 3D/Spline 씬을 이유 없이** | JS 런타임 800KB~2MB. 모바일 4G에서 Core Web Vitals 실패 | 브리프가 요구할 때만. 아니면 정적 렌더 이미지 + 미세 모션 |
|
||||
| **로딩할 게 없는데 프리로더 카운터** | 없는 대기를 만듦 | 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 카피 — 경쟁사 치환 테스트
|
||||
|
||||
**AI 티는 시각보다 문장에서 먼저 난다.**
|
||||
|
||||
### 테스트
|
||||
카피에서 제품·회사 이름을 **경쟁사 이름으로 바꿔 읽는다.**
|
||||
**여전히 자연스러우면 그 카피는 아무것도 말하지 않은 것이다. 다시 써라.**
|
||||
|
||||
### 구문 지문
|
||||
|
||||
| 지문 | 왜 문제 | 대신 |
|
||||
|---|---|---|
|
||||
| **`It's not X, it's Y` / `단순한 X가 아니라 Y입니다`** | ChatGPT 최대 지문. 400단어에 3번씩 나옴 | 그냥 Y를 말해라. 대조가 필요하면 비교 대상을 실명으로 |
|
||||
| **스타카토 3연타** (`No fluff. No filler. No BS.` / `빠르게. 정확하게. 간단하게.`) | 리듬으로 내용 없음을 감춤 | 한 문장으로 구체적으로 |
|
||||
| **em-dash(—) 한 문단에 2회 이상** | AI 문장 리듬의 대표 지문 | 마침표로 끊거나 쉼표로. 한국어에서 특히 부자연스럽다 |
|
||||
| **`In today's fast-paced digital landscape...`** 도입 | 아무 말도 하지 않는 문단 | 첫 문장부터 본론. 도입 문단 삭제 |
|
||||
|
||||
### 어휘 지문
|
||||
|
||||
| 지문 | 왜 문제 | 대신 |
|
||||
|---|---|---|
|
||||
| `Streamline / Empower / Supercharge / Unlock / Leverage / Seamless / Elevate` | 아무 제품에나 붙는 동사. 정보량 0 | 제품이 실제로 하는 동작 동사 (`정산한다`, `병합한다`, `4일을 6시간으로 줄인다`) |
|
||||
| `world-class / cutting-edge / enterprise-grade / best-in-class` | 자기 평가 형용사는 증거가 아니다 | 인증명, 벤치마크 수치, 고객사 실명 |
|
||||
| `Build the future of X` / `Your all-in-one platform` / `Scale without limits` | 경쟁사 이름으로 바꿔도 성립 | 치환 테스트를 통과하는 문장 (예: "Financial infrastructure for the internet") |
|
||||
| 헤지 표현 (`may help you`, `~할 수 있습니다`) | 확신 없음 = 신뢰 없음 | 단정하거나 조건을 명시 (`10명 이하 팀에서는`) |
|
||||
| CTA가 `Get Started` / `Learn More` / `시작하기` | 무엇이 시작되는지 알 수 없음 | 결과를 말하는 CTA (`무료로 30일 써보기`, `가격표 보기`) |
|
||||
| 모든 숫자가 어림수 (`10,000+`, `99.9%`, `50% faster`) | 검증 불가 | 정확한 수치와 측정 조건 (`p95 응답 240ms, 2026-07 기준`) |
|
||||
| 모든 헤딩이 명사구 (`Powerful Features`, `Simple Pricing`) | 헤딩이 정보를 전달하지 않음 | 헤딩에 주장을 담아라 (`엑셀 없이 정산이 끝난다`) |
|
||||
| 레이블·서브레이블·헬퍼가 같은 말 3번 (`이메일` / `이메일 주소` / `이메일 주소를 입력하세요`) | 화면 소음 | 하나만 남긴다 |
|
||||
| 이모지로 시작하는 불릿 (`✅ 빠른 속도`) | 정보 위계를 이모지로 대체 | 일반 불릿 또는 문장으로 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 이미지
|
||||
|
||||
| 지문 | 왜 문제 | 대신 |
|
||||
|---|---|---|
|
||||
| **랩탑 앞에서 웃는 다국적 팀 스톡 사진** | 방문자는 스톡을 알아보고 **신뢰도가 실제로 하락**한다 | 실제 팀 사진, 실제 작업 공간, 또는 사진 없이 타입으로 |
|
||||
| **떠 있는 3D 추상 블롭·기하 도형** | 콘텐츠 부재를 덮는 장식 | 제품 실제 화면, 데이터 시각화, 또는 여백 |
|
||||
| **AI 생성 일러스트** (지나치게 매끄럽고 대칭적) | 결함이 없어도 톤에서 티가 남 | 일러스트 시스템을 직접 정의하거나 아예 쓰지 않는다 |
|
||||
| **일반 SVG 도형을 조합한 히어로 그래픽** | 플레이스홀더 클립아트처럼 읽힘 | 제품 UI 조각을 실제로 렌더 |
|
||||
| **아이콘 세트가 섞임** (라인·필·이모지 혼재) | 시스템이 없다는 증거 | 세트 하나 고정. 굵기·크기·광학 정렬 통일 |
|
||||
| **`src`가 비었거나 깨진 이미지 태그** | 검증 없이 배포된 흔적 | 배포 전 이미지 로드 전수 확인 |
|
||||
| **모든 이미지가 같은 비율의 둥근 사각형** | 그리드를 이미지에 강제 | 콘텐츠에 맞는 비율. 풀블리드 1장을 섞으면 리듬이 생긴다 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 한글 조판 (한국어 프로젝트 필수)
|
||||
|
||||
서구 갤러리에는 없는 규칙이다. **영문 기준을 그대로 쓰면 전부 깨진다.**
|
||||
|
||||
| 규칙 | 값 | 이유 |
|
||||
|---|---|---|
|
||||
| **한글 폰트 미지정 금지** | `Pretendard` 또는 `본고딕` 명시 | 지정하지 않으면 시스템 기본(맑은 고딕 / Apple SD 산돌고딕)으로 떨어지고, 그 자체가 완성도 미달 신호다 |
|
||||
| **line-height를 영문보다 높게** | 본문 **1.6–1.8** | 한글은 글자 밀도가 높아 1.5로는 답답하다 |
|
||||
| **음수 자간 금지** | 본문 0, 대형 헤드라인만 -0.01 ~ -0.02em | 한글은 고정폭에 가까워 음수 자간이 즉시 뭉개진다 |
|
||||
| **`word-break: keep-all`** | `overflow-wrap: break-word`와 함께 | 기본값은 단어 중간에서 줄바꿈되어 어색하다 |
|
||||
| **한 줄 25–40자** | 영문 45–75자 기준을 쓰면 너무 길다 | 컨테이너 폭을 좁혀라 |
|
||||
| **라틴 폰트를 폴백 앞에** | `font-family: 'Satoshi', 'Pretendard', sans-serif` | 한글 폰트를 앞에 두면 라틴 문자까지 그 폰트로 그려진다 |
|
||||
| **번역투 금지** | `~를 통해`, `~에 대한`, `~에 있어서` | 영문 AI 카피를 기계 번역한 티가 난다. `~로`, `~의`, `~에서`로 |
|
||||
|
||||
**한글 폰트 선택**
|
||||
- 기본값: **Pretendard** (SIL OFL, Thin~Black 9단계, 가변). CDN: `https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css`
|
||||
- 명조·에디토리얼: **마루 부리** (세리프 부활 트렌드의 한글 대응)
|
||||
- 수치 많은 UI: **Spoqa Han Sans Neo**
|
||||
- 다국어 안정성: **본고딕 / Noto Sans KR**
|
||||
- 캠페인 헤드라인: 배민 도현체·여기어때 잘난체 — **B2C 한정. B2B에 쓰면 즉시 아마추어**
|
||||
- 나눔고딕은 너무 흔하다. 폴백으로만.
|
||||
|
||||
---
|
||||
|
||||
## 9. grep 코드 지문 (프리플라이트 자동 검사)
|
||||
|
||||
```
|
||||
# 색
|
||||
indigo-|violet-|purple-|fuchsia-
|
||||
#6366f1|#818cf8|#a855f7|#8b5cf6|#c4b5fd|#a78bfa|#22d3ee
|
||||
from-purple|to-blue-|from-violet|via-purple
|
||||
bg-slate-900|bg-gray-900|text-gray-400
|
||||
#ffffff|#000000
|
||||
|
||||
# 컴포넌트 기본값
|
||||
rounded-2xl|rounded-3xl
|
||||
shadow-lg|shadow-xl|shadow-2xl
|
||||
border-l-4|border-t-4
|
||||
backdrop-blur
|
||||
|
||||
# 타이포
|
||||
font-family:.*Inter
|
||||
(Space Grotesk.*Instrument Serif)|(Instrument Serif.*Geist)|(Space Grotesk.*Geist)
|
||||
uppercase tracking-wide
|
||||
|
||||
# 카피
|
||||
Get Started|Learn More|Empower|Streamline|Supercharge|Seamless
|
||||
world-class|cutting-edge|enterprise-grade|best-in-class
|
||||
It's not .* it's
|
||||
|
||||
# 이모지 아이콘 (텍스트 노드)
|
||||
🚀|⚡|✨|🎯|🔥|💡|✅
|
||||
```
|
||||
|
||||
**한글 프로젝트 추가 검사**
|
||||
|
||||
```
|
||||
# 있어야 하는 것 — 없으면 실패
|
||||
word-break:\s*keep-all
|
||||
Pretendard|Noto Sans KR|본고딕|마루부리
|
||||
|
||||
# 없어야 하는 것
|
||||
letter-spacing:\s*-0\.0[3-9] (한글 본문)
|
||||
~를 통해|~에 대한|에 있어서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 자가 채점표
|
||||
|
||||
| 카테고리 | 걸린 항목 | 조치 |
|
||||
|---|---|---|
|
||||
| 0 최우선 3종 | | |
|
||||
| 1 컬러 | | |
|
||||
| 2 타이포 | | |
|
||||
| 3 레이아웃 | | |
|
||||
| 4 컴포넌트 | | |
|
||||
| 5 모션 | | |
|
||||
| 6 카피 | | |
|
||||
| 7 이미지 | | |
|
||||
| 8 한글 조판 | | |
|
||||
|
||||
**통과 조건 — 전부 예여야 한다**
|
||||
- [ ] 최우선 3종(보라 CTA / 전체 대문자 / 1·2·3 단계) 중 걸린 것이 없다
|
||||
- [ ] grep 검사에서 나온 항목을 전부 해소했다
|
||||
- [ ] 카피가 경쟁사 치환 테스트를 통과한다
|
||||
- [ ] 모션을 전부 끄고도 페이지가 작동한다
|
||||
- [ ] 강조색을 지워도 페이지가 읽힌다
|
||||
- [ ] 색을 회색조로 바꿔도 위계 순서가 비즈니스 우선순위와 일치한다
|
||||
- [ ] 키보드만으로 전 인터랙션이 가능하고 포커스 링이 보인다
|
||||
- [ ] (한국어) 한글 폰트가 명시되어 있고 `word-break: keep-all`이 있다
|
||||
|
||||
**판정**
|
||||
|
||||
| 걸린 개수 | 판정 |
|
||||
|---|---|
|
||||
| 0–1 | 통과 |
|
||||
| 2–3 | 해당 항목 수정 후 재검 |
|
||||
| **4 이상** | **폐기. reference-method.md §3부터 다시** |
|
||||
|
||||
> 근거: research/references/04-ai-slop-signatures.md, 03-trends-2026.md (조사일 2026-08-20)
|
||||
350
packages/skill/references/experimental-canvas.md
Normal file
350
packages/skill/references/experimental-canvas.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# 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)
|
||||
173
packages/skill/references/galleries.md
Normal file
173
packages/skill/references/galleries.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# galleries.md — 어디를 볼 것인가
|
||||
|
||||
레퍼런스 조사 단계에서 **갤러리를 고르는 표**다. 브리프를 표에 대입해 3곳을 정하고, 거기서 원본 사이트 URL을 확보한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 브리프 → 갤러리 라우팅
|
||||
|
||||
`R1`은 구조, `R2`는 톤, `R3`는 디테일을 가져올 소스다. R2는 **반드시 R1과 다른 업종**에서 고른다.
|
||||
|
||||
> **두 행에 동시에 걸리면 청중을 기준으로 고른다.** "개발자용 API 모니터링 SaaS"는
|
||||
> `SaaS · B2B` 와 `개발자 도구` 양쪽에 해당한다. 보는 사람이 엔지니어면 개발자 도구 행이다.
|
||||
>
|
||||
> **갤러리에서 원본 URL을 못 뽑으면 경쟁사·인접 제품을 직접 지목해도 된다.**
|
||||
> 갤러리는 발견 수단이지 목적이 아니다. 1단계 검증 조건은 "원본 사이트 URL을 확보했는가"이지
|
||||
> "갤러리를 경유했는가"가 아니다. **접근 실패로 왕복을 세 번 넘기지 마라** — §5를 먼저 확인하고,
|
||||
> 막히면 바로 우회해라.
|
||||
|
||||
| 브리프 | R1 구조 | R2 톤 (다른 업종) | R3 디테일 |
|
||||
|---|---|---|---|
|
||||
| SaaS · B2B 랜딩 | Land-book | The Brand Identity | Codrops |
|
||||
| 개발자 도구 · API 문서 | Refero | Klim | Codrops |
|
||||
| 개인 · 스튜디오 포트폴리오 | Godly | Typographic Posters | Eyecandy |
|
||||
| 에이전시 · 브랜드 사이트 | SiteInspire | Mindsparkle Mag | Eyecandy |
|
||||
| 이커머스 · D2C | ecomm.design | Visuelle | Details Matter |
|
||||
| 모바일 앱 | Mobbin | Handheld.design | Spotted in Prod |
|
||||
| 대시보드 · 웹앱 내부 | Webframe | Data Viz Project | UI Sources |
|
||||
| 미니멀 · 정보량 적음 | Minimal Gallery | Httpster | Design Spells |
|
||||
| 개성 강한 · 실험적 | Hover States | Brutalist Websites | Codrops |
|
||||
| 이메일 · 뉴스레터 | Really Good Emails | Email Love | — |
|
||||
| 온보딩 · 결제 플로우 | Page Flows | UX Bites | Mobbin |
|
||||
| 타이포 주도 (텍스트가 주인공) | Awwwards Typography | Fonts In Use | Typewolf |
|
||||
| 리디자인 (개편) | Rebrand Gallery | (현행 사이트 자체) | Design Spells |
|
||||
| 한국어 사이트 | 노트폴리오 | 디비컷 | GDWeb |
|
||||
|
||||
**국소 브리프** (한 섹션만 다시 짤 때)
|
||||
|
||||
| 대상 | 소스 |
|
||||
|---|---|
|
||||
| 히어로 | Supahero |
|
||||
| 내비게이션 | Navbar Gallery |
|
||||
| 푸터 | Footer.design |
|
||||
| 요금제 | Pricing Pages |
|
||||
| 404 · 빈 상태 | 404s.design |
|
||||
| 후기 · 신뢰 요소 | Social Proof Examples |
|
||||
| OG · 공유 이미지 | OG Image Gallery |
|
||||
|
||||
---
|
||||
|
||||
## 2. 경고 — 갤러리마다 편향이 있다
|
||||
|
||||
**Awwwards 구조를 SaaS에 이식하지 마라.**
|
||||
2026 Q1 SOTD 47건의 업종 분포는 럭셔리 패션 28% · 자동차 22% · 건축 18% · 문화기관 14%, **테크/SaaS는 5% 미만**이다.
|
||||
수상작의 전면 3D·풀스크린 인트로·스크롤 서사는 "제품을 팔지 않는 사이트"의 문법이다.
|
||||
Awwwards는 **타이포 완성도와 야심의 상한선**으로만 쓰고, 섹션 구조는 Land-book·Refero에서 가져온다.
|
||||
|
||||
그 밖의 편향:
|
||||
|
||||
| 갤러리 | 편향 | 대응 |
|
||||
|---|---|---|
|
||||
| Dribbble | 구현 불가능한 "샷"이 다수 | 색·타이포 발상만. **구조 참고 금지** |
|
||||
| Collect UI | Daily UI 과제 기반이라 비현실적 | 컴포넌트 아이디어만 |
|
||||
| Pinterest | 출처 추적 불가 + AI 이미지 오염 | 최후순위. 가급적 쓰지 마라 |
|
||||
| Lapa Ninja | 실무 평균값 → 슬롭 패턴이 섞여 있음 | 볼 때 antipatterns.md를 옆에 둬라 |
|
||||
| Dark Mode Design / dark.design | 다크가 전제 | 브리프가 다크를 요구할 때만 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 텍스트로 읽히는 소스 (기본 경로)
|
||||
|
||||
에이전트는 **이미지를 못 보는 것이 기본값**이다. 갤러리 카드는 이미지라 정보가 없다.
|
||||
아래는 **말로 설명해주는** 소스다. 폰트·색·모션은 여기서 확보한다.
|
||||
|
||||
| 소스 | URL | 얻는 것 |
|
||||
|---|---|---|
|
||||
| Typewolf | https://www.typewolf.com | 특정 사이트가 실제로 쓴 **폰트 이름** + 무료 대체 폰트 |
|
||||
| Typewolf Google Fonts | https://www.typewolf.com/google-fonts | 구글폰트 베스트 40, 본문용/디스플레이용 구분 |
|
||||
| Fonts In Use | https://fontsinuse.com | 폰트의 산업·시대별 톤 |
|
||||
| Eyecandy | https://eyecannndy.com | 모션 **기법에 붙은 이름** (displacement, kinetic type 등) |
|
||||
| Codrops | https://tympanus.net/codrops/ | 효과의 **소스 코드** 그 자체 |
|
||||
| UI Sources | https://www.uisources.com | 인터랙션을 문장으로 분해한 해설 |
|
||||
| UX Bites | https://builtformars.com/ux-bites | 특정 앱 UX가 왜 좋은지 서술 |
|
||||
| DesignSystems.one | https://www.designsystems.one/design-systems | 실사용 디자인 시스템 88개 + **토큰 원문(design.md) 다운로드** |
|
||||
| DESIGN.md | https://designmd.app | 에이전트용으로 정제된 디자인 시스템 정의 561개 |
|
||||
| Happy Hues | https://www.happyhues.co | 색을 **역할**(배경/텍스트/강조)로 서술한 팔레트 |
|
||||
| Branding Style Guides | https://brandingstyleguides.com | 실제 브랜드 가이드 문서 |
|
||||
| Rebrand Gallery | https://rebrand.gallery | 리브랜딩 before/after — "왜 바꿨나"를 학습 가능 |
|
||||
|
||||
**브라우저 도구(Chrome DevTools MCP / Playwright)를 쓸 수 있으면** 원본 사이트의 computed style을 직접 읽어라.
|
||||
뽑을 값: `font-family` `font-size` `line-height` `letter-spacing` `color` `background-color` `border-radius` `box-shadow` `padding` `gap` `max-width` `transition`.
|
||||
|
||||
---
|
||||
|
||||
## 4. 카테고리별 대표
|
||||
|
||||
라우팅 표에서 못 찾았을 때만 본다.
|
||||
|
||||
**종합 어워드**
|
||||
- Awwwards https://www.awwwards.com — SOTD 아카이브. 상한선용
|
||||
- The FWA https://thefwa.com — 인터랙티브·기술 실험
|
||||
- Godly https://godly.website — 주당 3~5개만 등록. 신호 대 잡음비 최고
|
||||
|
||||
**미니멀 · 에디토리얼**
|
||||
- SiteInspire https://www.siteinspire.com — style/type/subject 3축 태깅
|
||||
- Minimal Gallery https://minimal.gallery — 여백 상한 기준선
|
||||
- Httpster https://httpster.net — 독립 스튜디오·타입 파운드리
|
||||
- Dead Simple Sites https://deadsimplesites.com — 덜어내기 하한선
|
||||
|
||||
**브루탈 · 실험**
|
||||
- Brutalist Websites https://brutalistwebsites.com — 원본 아카이브
|
||||
- Hover States https://hoverstat.es — 아트·문화기관 웹
|
||||
- Neubrutalism https://neubrutalism.com — 스타일 정의 + 사례
|
||||
|
||||
**SaaS · 랜딩 · 웹앱**
|
||||
- Land-book https://land-book.com — 전수 심사. 랜딩 1순위
|
||||
- Refero https://refero.design — 실제 화면 30K+, UX 패턴·요소 단위 검색
|
||||
- Webframe https://webframe.xyz — "로그인 후" 화면
|
||||
- Landingfolio https://www.landingfolio.com — 섹션 컴포넌트 동봉
|
||||
- SaaSFrame https://www.saasframe.io — 랜딩 + 앱 + 온보딩 이메일
|
||||
|
||||
**모바일 · 플로우**
|
||||
- Mobbin https://mobbin.com — 실제 앱 전체 화면·플로우
|
||||
- Page Flows https://pageflows.com — 사용자 여정 영상
|
||||
- Handheld.design https://handheld.design — 개성 있는 모바일 UI
|
||||
|
||||
**타이포 · 폰트**
|
||||
- Typewolf https://www.typewolf.com — **폰트 결정은 무조건 여기 먼저**
|
||||
- Fontshare https://www.fontshare.com — Satoshi, Switzer, Clash Display (무료 상업)
|
||||
- Velvetyne https://velvetyne.fr — 오픈소스 실험 파운드리
|
||||
- Klim https://klim.co.nz · Pangram Pangram https://pangrampangram.com — 유료
|
||||
- 눈누 https://noonnu.cc — **한글 무료 폰트. 라이선스 표기 명확**
|
||||
|
||||
**컬러**
|
||||
- Happy Hues https://www.happyhues.co — 역할별 팔레트
|
||||
- Realtime Colors https://www.realtimecolors.com — 실제 레이아웃에 실시간 적용
|
||||
- Coolors https://coolors.co — 빠른 후보 생성
|
||||
|
||||
**모션 · 코드**
|
||||
- Codrops https://tympanus.net/codrops/ — 튜토리얼 + 소스
|
||||
- Eyecandy https://eyecannndy.com — 기법 이름
|
||||
- GSAP https://gsap.com — 2026년 전 플러그인 무료
|
||||
- Motion https://motion.dev — React 모션
|
||||
|
||||
**브랜딩 (웹 밖에서 톤 가져오기)**
|
||||
- The Brand Identity https://the-brandidentity.com
|
||||
- Mindsparkle Mag https://mindsparklemag.com
|
||||
- Typographic Posters https://typographicposters.com
|
||||
- Visuelle https://visuelle.co.uk
|
||||
|
||||
**국내**
|
||||
- 노트폴리오 https://notefolio.net — 한국어 타이포·레이아웃 감각
|
||||
- 디비컷 https://www.dbcut.com — 국내 기업·기관 사이트 관행
|
||||
- GDWeb https://gdweb.co.kr — 국내 에이전시 수준선
|
||||
|
||||
---
|
||||
|
||||
## 5. 자동 접근 불가 목록
|
||||
|
||||
아래는 `WebFetch`로 못 읽는다. 조사 계획에서 제외하거나 대체 소스를 잡아라.
|
||||
|
||||
| 사이트 | 상태 | 대체 |
|
||||
|---|---|---|
|
||||
| **land-book.com** | **403 봇 차단.** 브라우저로 열어도 카드에 외부 URL 이 없다 | Httpster, Lapa Ninja |
|
||||
| **refero.design** | **SPA — WebFetch 가 제목만 반환** | Mobbin, Screenlane |
|
||||
| siteinspire.com | 429 간헐 차단 | Httpster, Minimal Gallery |
|
||||
| cssdesignawards.com | 403 봇 차단 | Awwwards |
|
||||
| uncut.wtf | Cloudflare 차단 | Fontshare, Fontesk |
|
||||
| refs.gallery | 429 | Godly |
|
||||
| unsplash.com | 401 | Pexels |
|
||||
| adfolio.design | 429 | Love The Work More |
|
||||
| **commercecream.com** | **HTTPS 연결 거부 (방치 상태)** | **ecomm.design로 대체. 쓰지 마라** |
|
||||
|
||||
> 근거: research/references/01-gallery-catalog.md, 03-trends-2026.md (조사일 2026-08-20)
|
||||
113
packages/skill/references/layout.md
Normal file
113
packages/skill/references/layout.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# layout — 레이아웃과 타이포그래피
|
||||
|
||||
4-1 단계에서 읽는다. **이 단계가 끝나면 아무 이펙트 없이도 완성된 페이지**여야 한다. 그것이 이후 모든 폴백의 기반이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 그리드를 먼저 정한다
|
||||
|
||||
### 하나의 그리드에서 모든 것이 나온다
|
||||
|
||||
```css
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
[full-start] minmax(var(--space-4), 1fr)
|
||||
[wide-start] minmax(0, 12rem)
|
||||
[main-start] minmax(0, 60rem) [main-end]
|
||||
minmax(0, 12rem) [wide-end]
|
||||
minmax(var(--space-4), 1fr) [full-end];
|
||||
}
|
||||
.page > * { grid-column: main; }
|
||||
.page > .wide { grid-column: wide; }
|
||||
.page > .full { grid-column: full; }
|
||||
```
|
||||
|
||||
이 한 번의 정의로 **본문 폭 · 넓은 블록 · 전체 폭**이 전부 정렬된다. 섹션마다 `max-width` 와 `padding` 을 다시 쓰면 반드시 어긋난다.
|
||||
|
||||
### 비대칭을 두려워하지 마라
|
||||
|
||||
12열 중앙 정렬은 안전하고 잊힌다. 실제로 기억되는 레이아웃은 **의도적으로 한쪽으로 몰려 있다**. 다만 비대칭에도 규칙이 있어야 한다 — 아무 데나 놓는 것은 비대칭이 아니라 사고다.
|
||||
|
||||
- 텍스트 블록을 그리드의 2/3 지점에서 끊고 나머지를 비워두기
|
||||
- 이미지를 화면 밖으로 흘려보내기(`full` 을 넘어 `margin-inline: calc(var(--space-4) * -1)`)
|
||||
- 제목과 본문의 시작선을 일부러 어긋내기 — 단, **같은 어긋남을 페이지 전체에서 반복**할 때만
|
||||
|
||||
### 벤토 그리드 주의
|
||||
|
||||
벤토(크기가 다른 카드들의 격자)는 **더 이상 차별화가 아니라 새 기본값**이다. 효과는 있지만(스크롤 깊이 증가) 표준화가 끝나서 "고민 안 했음"의 신호로 읽힌다. 쓰려면 최소한 다음 중 하나는 해라:
|
||||
- 카드 경계를 없애고 여백만으로 구획
|
||||
- 그리드에서 한 칸을 의도적으로 비우기
|
||||
- 카드 하나만 규칙을 깨고 밖으로 나가기
|
||||
|
||||
---
|
||||
|
||||
## 2. 시선 흐름
|
||||
|
||||
사람은 페이지를 읽지 않고 **훑는다.** 훑는 경로를 설계하는 것이 레이아웃의 본체다.
|
||||
|
||||
1. **첫 화면에서 세 가지만** — 무엇인지 / 왜 좋은지 / 다음 행동. 넷 이상이면 아무것도 안 읽힌다
|
||||
2. **위계는 크기가 아니라 대비로 만든다** — 크기·굵기·색·여백 중 **둘 이상을 동시에** 바꿔야 위계가 보인다. 크기만 조금 키우는 것은 위계가 아니다
|
||||
3. **시선을 한 번은 멈춰라** — 전부 같은 리듬으로 흐르면 아무것도 강조되지 않는다. 섹션 하나는 리듬을 깨야 한다
|
||||
4. **스크롤은 보상이어야 한다** — 다음 화면에 무언가 새로운 것이 있어야 한다. 같은 카드 그리드가 세 번 반복되면 거기서 이탈한다
|
||||
|
||||
---
|
||||
|
||||
## 3. 타이포그래피
|
||||
|
||||
토큰은 3단계에서 정했다. 여기서는 **적용**이다.
|
||||
|
||||
### 위계는 3단계면 충분하다
|
||||
디스플레이 / 섹션 제목 / 본문. h4, h5, h6 까지 시각적으로 구분하려 들면 위계가 무너진다. 필요하면 **웨이트나 색**으로 구분해라, 새 크기를 만들지 말고.
|
||||
|
||||
### 본문이 주인공이다
|
||||
헤드라인은 눈에 띄기 쉽다. **본문이 읽히는지**가 실력이다.
|
||||
- 한 줄 길이(`--measure`)를 반드시 적용해라. 전체 폭 본문은 읽을 수 없다
|
||||
- 문단 간격은 줄간격의 1.5배 이상. 들여쓰기와 문단 간격을 동시에 쓰지 마라
|
||||
- 링크는 색만으로 구분하지 마라(밑줄 또는 다른 신호 병행)
|
||||
|
||||
### 한글이 들어가면
|
||||
`references/antipatterns.md` 의 한글 조판 섹션을 읽어라. 요약:
|
||||
- `word-break: keep-all` — 없으면 단어가 아무 데서나 잘린다
|
||||
- line-height 1.6~1.8 (라틴보다 넉넉하게)
|
||||
- 음수 자간 금지
|
||||
- 한 줄 25~40자
|
||||
- 라틴 폰트를 폴백 스택 **앞**에 둔다 (숫자·영문이 한글 폰트로 렌더되면 조악해진다)
|
||||
- **한글 폰트를 지정하지 않는 것 자체가 완성도 미달 신호다**
|
||||
|
||||
---
|
||||
|
||||
## 4. 반응형
|
||||
|
||||
### 브레이크포인트가 아니라 콘텐츠에서 시작한다
|
||||
|
||||
```css
|
||||
/* 나쁨: 기기 크기를 가정 */
|
||||
@media (min-width: 768px) { }
|
||||
|
||||
/* 좋음: 콘텐츠가 깨지는 지점 */
|
||||
.cards { grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); }
|
||||
```
|
||||
|
||||
`auto-fit` + `minmax` 로 해결되는 것을 미디어쿼리로 만들지 마라. 미디어쿼리는 **레이아웃 구조 자체가 바뀔 때만** 쓴다.
|
||||
|
||||
### 모바일에서 먼저 확인해라
|
||||
데스크톱에서 아름다운 것이 모바일에서 무너지는 것이 기본이고, 그 반대는 드물다. 특히:
|
||||
- 큰 타이포는 모바일에서 반드시 줄여라 (`clamp` 의 최소값)
|
||||
- 가로 스크롤이 생기는 요소를 찾아라 (`overflow-x: hidden` 으로 덮지 말고 원인을 고쳐라)
|
||||
- 터치 타깃 44×44px 이상
|
||||
- 호버로만 접근되는 기능을 만들지 마라
|
||||
|
||||
---
|
||||
|
||||
## 5. 이 단계의 통과 조건
|
||||
|
||||
- [ ] CSS/HTML만으로 페이지가 완성됐다. JS를 꺼도 읽힌다
|
||||
- [ ] 모든 값이 3단계 토큰에서 나온다. 하드코딩된 px/색이 없다
|
||||
- [ ] 첫 화면에 메시지가 셋 이하다
|
||||
- [ ] 본문에 `--measure` 가 적용됐다
|
||||
- [ ] 모바일 폭 **320px**(iPhone SE 세로)에서 가로 스크롤이 없다
|
||||
- [ ] 키보드 Tab 만으로 모든 인터랙티브 요소에 도달한다. 포커스 링이 보인다
|
||||
- [ ] 한글이 있다면 조판 규칙이 적용됐다
|
||||
|
||||
> 근거: research/references/02-methodology.md, 03-trends-2026.md, 04-ai-slop-signatures.md (조사일 2026-08-20)
|
||||
504
packages/skill/references/motion.md
Normal file
504
packages/skill/references/motion.md
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
# motion — 움직임과 인터랙션
|
||||
|
||||
4-4에서 읽는다. **레이아웃·재질·입체가 끝난 뒤에 온다.** 마지막인 이유는 앞의 셋이 완성돼야 무엇이 움직여야 하는지 알 수 있기 때문이다.
|
||||
|
||||
## 이 문서를 읽는 법
|
||||
|
||||
| 상황 | 읽을 곳 |
|
||||
|---|---|
|
||||
| **모션을 넣을지 아직 안 정했다** | **§0만.** 절반은 여기서 "안 넣는다"로 끝나고 그게 정답이다 |
|
||||
| duration·이징만 고르면 된다 | §1 결정 표. 여기서 끝내라 |
|
||||
| 하고 싶은 게 게이트 #8에 걸린다 | §2 우회표 |
|
||||
| 스크롤에 뭔가 물려야 한다 | §3 코드 B·C — 게이트 #10의 정답이 여기 있다 |
|
||||
| 모달·페이지 전환 / 텍스트·숫자 연출 | §3 코드 D·E / F·G |
|
||||
| 라이브러리를 깔지 말지 | §4 |
|
||||
| 감사 직전 | §5 체크리스트 |
|
||||
|
||||
---
|
||||
|
||||
## 0. "넣지 않는다"를 먼저 통과시켜라
|
||||
|
||||
애니메이션을 쓰기 전에 **아래 넷 중 어디에 해당하는지 한 문장으로** 답해라. 못 답하면 넣지 않는다.
|
||||
|
||||
| 역할 | 답해야 할 질문 | 예 |
|
||||
|---|---|---|
|
||||
| **인과** | "이게 왜 여기 나타났나?" | 누른 버튼 자리에서 시트가 자라남 |
|
||||
| **연속성** | "이게 어디서 와서 어디로 갔나?" | 썸네일 → 상세 이미지 |
|
||||
| **피드백** | "내 입력이 접수됐나?" | 프레스, 토글, 검증 실패 |
|
||||
| **주의** | "지금 어디를 봐야 하나?" | 오류 필드로의 이동 |
|
||||
|
||||
다섯 번째 **"멋있어서"는 이유가 아니다.** 예외는 브랜드 표현이 브리프의 명시적 요구일 때뿐이고, 그때도 (a) 사용자가 스크롤·호버로 통제하거나 (b) 1회성이며 (c) `prefers-reduced-motion`에서 완전히 사라져야 한다.
|
||||
|
||||
**넣지 말아야 할 곳**: 고빈도 반복 작업(폼·표·필터) · 오류 복구 경로 · 결과가 이미 예측되는 전환(탭) · **첫 화면**(콘텐츠는 즉시 읽혀야 한다) · 숫자가 계속 바뀌는 곳.
|
||||
|
||||
### 즉시 실격 — 하나라도 있으면 고친다
|
||||
|
||||
| 징후 | 처방 |
|
||||
|---|---|
|
||||
| 애니메이션 때문에 콘텐츠가 **읽히기까지 지연**됨 | 첫 화면은 모션 없이 즉시 표시. 리빌은 스크롤 이후 |
|
||||
| 스크롤 리빌이 위아래로 오갈 때 **매번 재생** | `both` / `once` / `unobserve()` |
|
||||
| 애니메이션 중 **레이아웃 시프트** | 게이트 #8. `transform`/`opacity`만 |
|
||||
| **동시에 3개 이상** 독립 애니메이션 | 시선이 분산된다. 순차화하거나 통합 |
|
||||
| 400ms 넘게 **사용자를 막는** 전환, 인터럽트 불가 | 줄이고, 애니메이션 중에도 입력을 받아라 |
|
||||
| 무한 반복되는 **큰 면적** 움직임 | 전정기관 자극. 정지 수단을 주거나 제거 |
|
||||
| 이징이 전부 `ease`·`linear`거나, 진입과 퇴장 duration이 같음 | 방향을 구분하지 않았다. §1로 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 표 — 이 상황에는 이 duration·이징
|
||||
|
||||
**토큰은 3단계 `tokens.md` §4에서 이미 정해졌다. 새 duration을 만들지 마라.** 표에 없으면 가장 가까운 행을 쓴다.
|
||||
|
||||
| 상황 | duration | 이징 | 거리 |
|
||||
|---|---|---|---|
|
||||
| **마이크로 인터랙션** — 호버·포커스·프레스·토글·체크 | `--dur-instant` (100ms) | `--ease-out` | `--shift-sm` 이하 |
|
||||
| **작은 요소 등장** — 툴팁·드롭다운·토스트·칩 | `--dur-quick` (200ms) | `--ease-out` | `--shift-sm` |
|
||||
| **작은 요소 퇴장** | `calc(var(--dur-quick) * 0.65)` | `--ease-in` | 동일 |
|
||||
| **표면 등장** — 모달·시트·패널·아코디언 | `--dur-normal` (350ms) | `--ease-out` | `--shift-lg` |
|
||||
| **표면 퇴장** | `calc(var(--dur-normal) * 0.65)` | `--ease-in` | 동일 |
|
||||
| **위치 이동** — 화면 안 A→B, 탭 슬라이드, 캐러셀 | `--dur-normal` | `--ease-soft` | — |
|
||||
| **페이지·뷰 전환** — 라우트 이동, shared element | `--dur-slow` (600ms) | `--ease-soft` | — |
|
||||
| **스크롤 리빌** — 섹션 등장, 1회성 | `--dur-slow` | `--ease-out` | `--shift-lg` |
|
||||
| **스크롤에 직접 물린 것** — 진행 바·시차·스크럽 | (없음) | `linear` | — |
|
||||
| **앰비언트 루프** — 배경 드리프트, 마퀴 | 8~20s | `linear` | — |
|
||||
|
||||
**네 줄 규칙.** 이것만 지켜도 대부분 맞는다.
|
||||
|
||||
- 화면 **안으로 들어오는** 것 → `--ease-out`. 도착감. 급정거하면 충돌처럼 보인다
|
||||
- 화면 **밖으로 나가는** 것 → `--ease-in`, duration은 진입의 0.65배. 나가는 건 볼 이유가 없다
|
||||
- 화면 **안에서 이동하는** 것 → `--ease-soft`
|
||||
- **스크롤에 물린** 것 → `linear`. 스크롤 자체가 이미 이징이다. 곡선을 덧씌우면 이중 이징이 된다
|
||||
|
||||
**모션에만 필요한 토큰 세 줄.** 3단계 토큰 블록에 이것만 더한다. **duration은 더하지 마라 — `tokens.md` §4의 넷이 전부다.**
|
||||
|
||||
```css
|
||||
:root { --stagger: 60ms; --shift-sm: 8px; --shift-lg: 24px; }
|
||||
```
|
||||
|
||||
**stagger 상한**: `(항목수 − 1) × --stagger + duration ≤ 800ms`. 넘으면 `--stagger`를 줄이거나 첫 화면 항목에만 건다. 12개를 넘으면 stagger를 쓰지 않는다. 방향은 읽기 방향(좌→우, 상→하)과 일치시킨다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 하드 게이트 #8을 지키면서 하고 싶은 걸 다 하는 법
|
||||
|
||||
**`transform`/`opacity` 외의 속성을 애니메이션하면 실패다. 오버라이드 없다.** 그런데 거의 모든 요구는 우회할 수 있다.
|
||||
|
||||
| 하고 싶은 것 | 하지 마라 | 대신 |
|
||||
|---|---|---|
|
||||
| 배경색이 바뀐다 | `transition: background-color` | 목표 색을 칠한 `::before`를 깔고 그 **`opacity`** 전환 |
|
||||
| 그림자가 짙어진다 | `transition: box-shadow` | 짙은 그림자를 가진 `::after`를 미리 만들고 **`opacity`** 전환. 그림자 재계산이 사라져 더 빠르다 |
|
||||
| 테두리가 나타난다 | `transition: border-color` | `::after`에 `border`를 두고 **`opacity`** 전환. 레이아웃도 안 흔들린다 |
|
||||
| 글자색이 바뀐다 | `transition: color` | 전환하지 말고 즉시 바꿔라. 100ms짜리 색 변화는 아무도 못 본다 |
|
||||
| 진행 바가 찬다 | `transition: width` | `transform-origin: left` + **`scaleX()`** |
|
||||
| 이미지가 흐려진다 | `transition: filter` | 선명본과 미리 블러된 사본을 겹치고 **`opacity`** 크로스페이드 |
|
||||
| 그라디언트가 회전한다 | `@property`로 각도 애니메이션 | 원뿔 그라디언트 레이어를 **`rotate`** |
|
||||
| 마스크가 열린다 | `transition: clip-path` | `overflow: hidden` 래퍼 안에서 자식을 **`translate`** |
|
||||
| 숫자가 올라간다 | `@property --count` | 자릿수 스트립을 **`translate`** (§3 코드 G) |
|
||||
| 패널 높이가 자란다 | `transition: height`·`max-height` | 아래 |
|
||||
|
||||
**높이.** `transition: height`는 매 프레임 레이아웃을 돌리고 형제를 밀어 CLS를 만든다. 순서대로 시도해라.
|
||||
|
||||
1. **구조를 바꾼다.** 정말 인라인으로 자라야 하는가? 모달·팝오버로 만들면 높이 문제가 사라지고 스케일+페이드로 끝난다
|
||||
2. **높이는 즉시 확정하고 내용만 전환한다.** 래퍼를 `display: grid; grid-template-rows: 0fr` ↔ `1fr`로 만들되 **`grid-template-rows`에 transition을 걸지 않는다.** 자식의 `opacity`·`translate`만 전환하면 게이트를 완전히 통과한다
|
||||
3. `grid-template-rows`에 transition을 거는 관용구가 널리 쓰이지만 **그건 여전히 레이아웃 애니메이션이고 게이트 #8 grep에 걸린다.** `max-height`보다 정확할 뿐 합성 가능하지는 않다. 굳이 쓰겠다면 `design.md`에 예외로 적어라
|
||||
|
||||
**오해 방지 — 게이트 #8이 허용하는 것**
|
||||
|
||||
- `translate` / `rotate` / `scale` **개별 속성은 transform 계열이다. 통과다.** 축약형보다 낫다(속성끼리 안 덮어쓴다)
|
||||
- `display`·`overlay`를 `transition-behavior: allow-discrete`로 거는 것은 **통과다.** 보간되지 않고 전환 종료까지 값을 유지시킬 뿐이라 프레임당 계산이 없다. 예외는 이 둘뿐이다
|
||||
- View Transitions의 기본 애니메이션은 브라우저가 만든다. **저자 키프레임은 `opacity`와 transform 계열만** 쓴다
|
||||
|
||||
---
|
||||
|
||||
## 3. CSS 네이티브 우선 — 라이브러리를 끌어오기 전에
|
||||
|
||||
| CSS로 충분한 것 (라이브러리 금지) | CSS로 안 되는 것 (§4로) |
|
||||
|---|---|
|
||||
| 호버·포커스·프레스 상태 전환 | 속도를 이어받는 인터럽트(드래그 던지기) |
|
||||
| 모달·팝오버·툴팁 진입/퇴장 (`@starting-style`) | 복잡한 타임라인 시퀀싱 (A 끝나고 B, B 중간에 C) |
|
||||
| 스크롤 리빌·진행 바·시차·스티키 헤더 | 스크롤 **핀 고정 + 다단계 시퀀스** |
|
||||
| 페이지 전환 (View Transitions) | 레이아웃 변화 FLIP (그리드 → 리스트) |
|
||||
| 무한 루프, `linear()` 스프링 근사 | 텍스트 자동 분해, SVG 패스 모핑, 포인터 추종 |
|
||||
|
||||
**지원 현황(2026-08).** `prefers-reduced-motion`·`linear()`는 Baseline widely — 무조건 쓴다. `@starting-style`·`transition-behavior`(2024-08), same-document View Transitions(2025-10)는 Baseline newly — 폴백 두고 쓴다. **scroll-driven animations와 cross-document View Transitions는 Firefox 미지원이라 `@supports` 가드가 필수다.**
|
||||
|
||||
> **가장 흔한 사고**: 미지원 브라우저에서 `opacity: 0`이 남아 콘텐츠가 영영 안 보이는 것. **초기 상태를 `@supports` 블록 *안에* 넣어라.** 밖에 두면 Firefox에서 백지가 된다.
|
||||
|
||||
### 코드 A — 버튼·링크 마이크로 인터랙션
|
||||
|
||||
> 언제: 모든 인터랙티브 요소. 선택이 아니라 기본이다. 색과 그림자를 전부 `opacity`로 바꾼 것이 요점이다.
|
||||
|
||||
```css
|
||||
.btn {
|
||||
position: relative; isolation: isolate;
|
||||
background: var(--accent); color: var(--accent-ink);
|
||||
border: 0; padding: 0.75rem 1.5rem; border-radius: var(--radius);
|
||||
transition: translate var(--dur-instant) var(--ease-out),
|
||||
scale var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
/* 밝기도 그림자도 색이 아니라 레이어의 opacity로 바꾼다 */
|
||||
.btn::before, .btn::after {
|
||||
content: ''; position: absolute; inset: 0; border-radius: inherit; opacity: 0;
|
||||
transition: opacity var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
.btn::before { z-index: -1; background: var(--ink); } /* 밝기 */
|
||||
.btn::after { z-index: -2; box-shadow: 0 8px 24px rgb(0 0 0 / 0.18); } /* 그림자 */
|
||||
|
||||
.btn:hover, .btn:focus-visible { translate: 0 -2px; }
|
||||
.btn:hover::before, .btn:focus-visible::before { opacity: 0.12; }
|
||||
.btn:hover::after, .btn:focus-visible::after { opacity: 1; }
|
||||
.btn:active { translate: 0 0; scale: 0.98; } /* squash는 2~4%까지만 */
|
||||
|
||||
/* 포커스 링은 절대 애니메이션하지 않는다. 즉시 보여야 한다 */
|
||||
.btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
||||
|
||||
/* 링크 밑줄: width가 아니라 scaleX */
|
||||
.link { position: relative; text-decoration: none; color: inherit; }
|
||||
.link::after {
|
||||
content: ''; position: absolute; inset-inline: 0; bottom: -2px;
|
||||
height: 1px; background: currentColor;
|
||||
scale: 0 1; transform-origin: left;
|
||||
transition: scale var(--dur-quick) var(--ease-out);
|
||||
}
|
||||
.link:hover::after, .link:focus-visible::after { scale: 1 1; }
|
||||
```
|
||||
|
||||
### 코드 B — 스크롤 진입 리빌 (scroll-driven animation)
|
||||
|
||||
> 언제: **기본값.** 리빌·진행 바·시차·헤더 축소. 컴포지터 스레드에서 돌아 메인 스레드가 막혀도 끊기지 않는다. 게이트 #10의 정답이 이것이다.
|
||||
|
||||
```css
|
||||
/* 초기 상태를 @supports 안에 둔다 — 미지원 브라우저에서는 그냥 보인다 */
|
||||
@supports (animation-timeline: view()) {
|
||||
.reveal {
|
||||
animation: reveal-in linear both; /* both 필수. 없으면 범위 밖에서 깜빡인다 */
|
||||
animation-timeline: view();
|
||||
animation-range: entry 15% cover 35%;
|
||||
animation-delay: calc(var(--i, 0) * var(--stagger));
|
||||
/* animation-duration은 auto가 기본. 초를 넣으면 무시되거나 오작동한다 */
|
||||
}
|
||||
@keyframes reveal-in {
|
||||
from { opacity: 0; translate: 0 var(--shift-lg); }
|
||||
to { opacity: 1; translate: 0 0; }
|
||||
}
|
||||
}
|
||||
/* 읽기 진행 바 — width가 아니라 scaleX (게이트 #8) */
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.progress {
|
||||
position: fixed; inset-block-start: 0; inset-inline: 0; height: 3px;
|
||||
background: var(--accent); transform-origin: left; scale: 0 1;
|
||||
animation: progress-fill linear both;
|
||||
animation-timeline: scroll(root block);
|
||||
}
|
||||
@keyframes progress-fill { to { scale: 1 1; } }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal { animation: none; opacity: 1; translate: 0 0; }
|
||||
.progress { animation: none; scale: 1 1; } /* 진행 표시는 정보다. 죽이지 않는다 */
|
||||
}
|
||||
```
|
||||
|
||||
**`animation-range` 이름**: `entry`(닿기 시작 → 완전히 들어옴) · `cover`(닿기 시작 → 완전히 벗어남) · `exit`(나가기 시작 → 벗어남) · `contain`(요소가 뷰포트보다 작을 때 완전히 담긴 구간).
|
||||
|
||||
### 코드 C — 스크롤 진입 리빌 (IntersectionObserver)
|
||||
|
||||
> 언제: Firefox 지원이 **요구사항**일 때, 또는 진입 시점에 **DOM을 바꿔야** 할 때(카운트업 시작, 지연 로드, 3D 씬 기동). CSS는 스타일만 바꾼다.
|
||||
> `window.addEventListener('scroll')`은 **게이트 #10**이다. 절대 쓰지 마라.
|
||||
|
||||
```css
|
||||
/* .js가 붙기 전에는 그냥 보인다 — JS가 실패해도 콘텐츠가 사라지지 않는다 */
|
||||
.js .reveal-js {
|
||||
opacity: 0; translate: 0 var(--shift-lg);
|
||||
transition: opacity var(--dur-slow) var(--ease-out),
|
||||
translate var(--dur-slow) var(--ease-out);
|
||||
transition-delay: calc(var(--i, 0) * var(--stagger));
|
||||
}
|
||||
.js .reveal-js[data-shown] { opacity: 1; translate: 0 0; }
|
||||
/* reduced-motion 대응은 코드 H가 --shift-lg·--stagger를 0으로 만들어 전역 처리한다 */
|
||||
```
|
||||
|
||||
```js
|
||||
document.documentElement.classList.add('js');
|
||||
const targets = document.querySelectorAll('.reveal-js');
|
||||
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
targets.forEach((el) => { el.dataset.shown = ''; });
|
||||
} else {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
entry.target.dataset.shown = '';
|
||||
io.unobserve(entry.target); // 1회성. 재생 반복은 즉시 실격이다
|
||||
}
|
||||
}, { rootMargin: '0px 0px -15% 0px', threshold: 0 });
|
||||
targets.forEach((el) => io.observe(el));
|
||||
}
|
||||
```
|
||||
|
||||
### 코드 D — 모달·팝오버 진입/퇴장 (`@starting-style`)
|
||||
|
||||
> 언제: `display: none`에서 나타나거나 top layer에 올라가는 모든 것. 라이브러리가 필요 없다.
|
||||
|
||||
```html
|
||||
<button popovertarget="tip">도움말</button>
|
||||
<div id="tip" popover="auto" role="tooltip" class="tip">계정 복구에만 사용됩니다.</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.tip {
|
||||
margin: 0; padding: 0.75rem 1rem; border: 0; border-radius: var(--radius);
|
||||
background: var(--surface-raised); color: var(--ink); max-width: 18rem;
|
||||
/* 닫힌 상태 = 퇴장의 종착점 */
|
||||
opacity: 0; translate: 0 var(--shift-sm); scale: 0.96;
|
||||
transition: opacity calc(var(--dur-quick) * 0.65) var(--ease-in),
|
||||
translate calc(var(--dur-quick) * 0.65) var(--ease-in),
|
||||
scale calc(var(--dur-quick) * 0.65) var(--ease-in),
|
||||
display calc(var(--dur-quick) * 0.65) allow-discrete,
|
||||
overlay calc(var(--dur-quick) * 0.65) allow-discrete;
|
||||
}
|
||||
.tip:popover-open {
|
||||
opacity: 1; translate: 0 0; scale: 1;
|
||||
transition-duration: var(--dur-quick);
|
||||
transition-timing-function: var(--ease-out);
|
||||
}
|
||||
/* @starting-style 블록은 반드시 원본 규칙 *뒤에* 온다. 명시도가 같아 순서로 승부난다 */
|
||||
@starting-style {
|
||||
.tip:popover-open { opacity: 0; translate: 0 var(--shift-sm); scale: 0.96; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .tip { scale: 1; } } /* 이동은 --shift-sm이 0이 되며 자동 처리 */
|
||||
```
|
||||
|
||||
**팝오버는 클릭한 곳에서 나와야 한다.** 화면 정중앙에서 페이드인하는 팝오버는 "어디서 왔는지"를 버리는 것이다. `transform-origin`을 트리거 위치로 잡아라.
|
||||
|
||||
### 코드 E — 페이지·뷰 전환 (View Transitions API)
|
||||
|
||||
> 언제: 라우트 이동, 리스트 필터링, 썸네일 → 상세. 미지원 브라우저에서는 즉시 바뀐다(점진 향상).
|
||||
|
||||
```js
|
||||
/** DOM을 바꾸는 함수를 감싼다. 지원·모션감소 판정을 여기 한 곳에 모은다 */
|
||||
function transition(updateDOM) {
|
||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (!document.startViewTransition || reduced) {
|
||||
updateDOM();
|
||||
return { finished: Promise.resolve() };
|
||||
}
|
||||
return document.startViewTransition(updateDOM);
|
||||
}
|
||||
|
||||
// shared element: 목록과 상세에 같은 이름을 준다. 이름이 중복되면 즉시 에러다
|
||||
document.querySelectorAll('.card').forEach((card) => {
|
||||
card.style.viewTransitionName = `card-${card.dataset.id}`;
|
||||
});
|
||||
|
||||
// 사용: 리스트 필터링, 라우트 교체 등 DOM을 바꾸는 모든 지점
|
||||
filterBtn.addEventListener('click', () => transition(() => renderList(filterBtn.dataset.filter)));
|
||||
```
|
||||
|
||||
```css
|
||||
/* 저자 키프레임은 opacity와 transform 계열만 쓴다. 위치·크기 보간은 브라우저가 한다 */
|
||||
::view-transition-group(*) { animation-duration: var(--dur-slow); animation-timing-function: var(--ease-soft); }
|
||||
::view-transition-old(root) { animation: calc(var(--dur-slow) * 0.65) var(--ease-in) both vt-out; }
|
||||
::view-transition-new(root) { animation: var(--dur-slow) var(--ease-out) both vt-in; }
|
||||
@keyframes vt-out { to { opacity: 0; translate: 0 calc(var(--shift-lg) * -1); } }
|
||||
@keyframes vt-in { from { opacity: 0; translate: 0 var(--shift-lg); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*) { animation-name: none !important; } /* 위치·크기 보간 제거 */
|
||||
::view-transition-old(*), ::view-transition-new(*) { /* 크로스페이드만 남긴다 */
|
||||
animation-duration: var(--dur-instant) !important; animation-timing-function: linear !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
문서 간 전환(MPA)은 양쪽 문서에 `@view-transition { navigation: auto; }`를 선언하면 켜진다. Firefox 미지원이라 **점진 향상 전용**이다.
|
||||
|
||||
### 코드 F — 텍스트 등장 (줄 단위 스플릿·스태거)
|
||||
|
||||
> 언제: 히어로 헤드라인 하나. **페이지에 한 번만.** 두 곳에서 텍스트가 날아오면 둘 다 죽는다.
|
||||
|
||||
**규칙 셋. 어기면 SEO와 스크린리더가 동시에 깨진다.**
|
||||
1. **원문 텍스트를 DOM에 그대로 남긴다.** 크롤러가 읽는 것은 마크업이지 렌더 결과가 아니다
|
||||
2. **글자 단위(`chars`) 분해는 쓰지 않는다.** 스크린리더가 "ㄱ-ㅏ-ㄴ-ㅏ"로 읽는다. 줄·단어까지만
|
||||
3. `<div>`에 `aria-label`을 붙이는 방식은 **동작하지 않는다.** generic role은 author naming이 금지돼 있다
|
||||
|
||||
```html
|
||||
<!-- 줄바꿈을 직접 마크업에 넣는다. 텍스트는 온전히 DOM에 있다 -->
|
||||
<h1 class="split">
|
||||
<span class="split__line" style="--i:0"><span>여백이 말을 대신하는</span></span>
|
||||
<span class="split__line" style="--i:1"><span>스튜디오</span></span>
|
||||
</h1>
|
||||
```
|
||||
|
||||
```css
|
||||
.split__line { display: block; overflow: hidden; } /* 마스크. clip-path를 애니메이션하지 않는다 */
|
||||
.split__line > span { display: block; translate: 0 0; }
|
||||
|
||||
@supports (animation-timeline: view()) {
|
||||
.split__line > span {
|
||||
animation: line-up linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 10% entry 70%;
|
||||
animation-delay: calc(var(--i) * var(--stagger));
|
||||
}
|
||||
@keyframes line-up {
|
||||
from { translate: 0 110%; opacity: 0; }
|
||||
to { translate: 0 0; opacity: 1; }
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .split__line > span { animation: none; opacity: 1; } }
|
||||
```
|
||||
|
||||
**자동 분해가 꼭 필요하면** GSAP SplitText를 쓴다(§4, 무료). `type: 'lines,words'`까지만, `mask: 'lines'`, `autoSplit: true`. 글자 단위가 브리프의 요구라면 원문을 `.sr-only` 사본으로 남기고 시각 요소에 `aria-hidden="true"`를 건다.
|
||||
|
||||
### 코드 G — 숫자 카운트업 (자릿수 스트립, transform만)
|
||||
|
||||
> 언제: 실적 숫자 하나. **사용자가 준 진짜 숫자에만 쓴다** — 지어낸 숫자는 게이트 #11이다.
|
||||
> `@property --count` 방식은 커스텀 속성을 매 프레임 바꿔 게이트 #8에 걸린다. 자릿수를 굴려라.
|
||||
|
||||
```html
|
||||
<p class="stat">
|
||||
<span class="sr-only">1284</span>
|
||||
<span class="odo" data-value="1284" aria-hidden="true"></span>
|
||||
<span>개의 프로젝트</span>
|
||||
</p>
|
||||
```
|
||||
|
||||
```css
|
||||
.odo { display: inline-flex; font-variant-numeric: tabular-nums; }
|
||||
.odo__col { display: block; height: 1em; overflow: hidden; line-height: 1; }
|
||||
.odo__strip {
|
||||
display: block; translate: 0 0;
|
||||
transition: translate var(--dur-slow) var(--ease-out);
|
||||
transition-delay: calc(var(--i) * var(--stagger));
|
||||
}
|
||||
.odo__strip > span { display: block; height: 1em; }
|
||||
.odo[data-run] .odo__strip { translate: 0 calc(var(--d) * -1em); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px;
|
||||
overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (prefers-reduced-motion: reduce) { .odo__strip { transition: none; } }
|
||||
```
|
||||
|
||||
```js
|
||||
const DIGITS = '0123456789'.split('').map((n) => `<span>${n}</span>`).join('');
|
||||
|
||||
document.querySelectorAll('.odo').forEach((odo) => {
|
||||
odo.innerHTML = odo.dataset.value.split('').map((d, i) =>
|
||||
`<span class="odo__col"><span class="odo__strip" style="--d:${d};--i:${i}">${DIGITS}</span></span>`
|
||||
).join('');
|
||||
|
||||
const io = new IntersectionObserver(([entry]) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
odo.dataset.run = '';
|
||||
io.disconnect();
|
||||
}, { threshold: 0.6 });
|
||||
io.observe(odo);
|
||||
});
|
||||
```
|
||||
|
||||
### 코드 H — `prefers-reduced-motion` 대응
|
||||
|
||||
> 언제: 전부. **접근성은 협상하지 않는다.** 이것 없이 5단계를 통과할 수 없다.
|
||||
|
||||
**전부 끄는 것은 오답이다.** 셋으로 나눠라.
|
||||
|
||||
| 죽인다 | 남긴다 | 대체한다 |
|
||||
|---|---|---|
|
||||
| 위치 이동, 시차, 스크롤 스크럽 | `opacity` 크로스페이드 | 슬라이드 → 크로스페이드 |
|
||||
| 회전·확대, 오버슛·바운스 | 호버·포커스 상태 변화 | 시차 → 정지 배경 |
|
||||
| 자동재생 루프, 마퀴 | 진행 표시·로딩(정보다) | 스크롤 리빌 → 즉시 표시 |
|
||||
| stagger(순차 지연) | 포커스 링 | 카운트업 → 최종값 즉시 |
|
||||
|
||||
WCAG 2.3.3은 **인터랙션으로 촉발된 모션 애니메이션을 끌 수 있어야** 한다고 요구한다. 색·투명도·블러만 바뀌는 것은 해당하지 않는다 — **위치·크기·형태가 바뀌는 것**이 대상이다. 자동 재생되는 것은 별도로 2.2.2(5초 넘게 움직이면 정지 수단 필요)에 걸린다. 근거는 취향이 아니라 **어지럼·구역질·두통이라는 실제 신체 반응**이다.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
/* 이동 거리를 0으로 → 모든 슬라이드가 자동으로 크로스페이드가 된다. 컴포넌트 코드를
|
||||
한 줄도 안 고치고, animationend/transitionend 의존 코드도 안 깨진다 */
|
||||
--shift-sm: 0px; --shift-lg: 0px; --stagger: 0ms;
|
||||
/* 페이드는 남긴다. 0으로 만들면 상태 변화가 뚝뚝 끊긴다 */
|
||||
--dur-normal: var(--dur-instant); --dur-slow: var(--dur-instant);
|
||||
--ease-out: ease-out; --ease-in: ease-out; --ease-soft: ease-out; /* 오버슛 제거 */
|
||||
}
|
||||
.marquee, .ambient, [data-loop], .parallax { animation: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 반영한다: `matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => location.reload())`.
|
||||
|
||||
---
|
||||
|
||||
## 4. JS 스택 판단 — 언제 끌어오고 언제 안 끌어오나
|
||||
|
||||
```
|
||||
[1] §3의 CSS로 되는가? YES → 끝. 라이브러리 없음
|
||||
[2] React이고 레이아웃 변화(FLIP)·제스처·퇴장 관리가 핵심인가? YES → Motion
|
||||
[3] 스크롤 핀 고정+다단계 시퀀스, 텍스트 자동 분해, SVG 모핑? YES → GSAP
|
||||
[4] 명령형 제어만 필요하고 번들을 늘리기 싫은가? YES → WAAPI (0KB)
|
||||
```
|
||||
|
||||
| 스택 | 번들 (min+gzip) | 쓰는 이유 | 쓰지 않는 이유 |
|
||||
|---|---|---|---|
|
||||
| **CSS + WAAPI** | **0KB** | 기본값 | 타임라인 시퀀싱·FLIP이 안 된다 |
|
||||
| **Motion** `animate` mini | 2.3KB | 바닐라에서 명령형 애니메이션 | 스크롤 핀이 없다 |
|
||||
| **Motion** (React, `m`+`LazyMotion`+`domMax`) | 4.6KB + 25KB 지연 | `layout` prop(FLIP 최고), 스프링, `AnimatePresence` | 스크롤 핀·스크럽 등가물 없음 |
|
||||
| **Motion** 풀 | ~34KB | 전부 | 예산을 먹는다 |
|
||||
| **GSAP** core | ~24KB + 플러그인 | ScrollTrigger(핀·스크럽·스냅), SplitText, Flip, MorphSVG | 메인 스레드에서 돈다 |
|
||||
| **Lenis** | ~3KB | 스크롤 감각이 브랜드 자산일 때, WebGL↔DOM 동기화 | 아래 |
|
||||
|
||||
**GSAP과 Motion을 한 프로젝트에 둘 다 넣는 것은 대체로 실수다.** 번들이 두 배가 되고 RAF 루프가 둘 돈다. 역할이 명확히 갈릴 때만 허용하고, 그때도 티커는 `gsap.ticker` 하나로 통일한다.
|
||||
|
||||
### GSAP 라이선스 — 2025년에 바뀌었다 (gsap.com 원문 확인)
|
||||
|
||||
**발효 2025-04-30.** 상업 프로젝트에서 **전부 무료**다. 과거 Club GreenSock 유료 플러그인(SplitText, MorphSVG, DrawSVG, ScrollTrigger, ScrollSmoother, Inertia, MotionPath, Flip, Observer)이 **모두 포함**된다. 배경은 2024년 10월 Webflow의 GreenSock 인수.
|
||||
|
||||
남은 제약은 하나다. **GSAP을 "코드 없이 시각적으로 애니메이션을 만드는 도구"에 넣어 Webflow의 애니메이션 빌더와 경쟁하는 제품을 만들 수 없다.** FAQ는 AI가 생성한 코드를 명시적으로 허용한다("AI-generated code is not a 'Prohibited Use'"). **일반적인 웹사이트·앱 제작에는 아무 제약이 없다.**
|
||||
|
||||
> 판단: GSAP은 더 이상 "돈 때문에 못 쓰는 라이브러리"가 아니다. 스크롤 시퀀스나 텍스트 분해가 필요하면 주저 없이 쓴다. 다만 **필요 없으면 여전히 깔지 않는다.**
|
||||
|
||||
### Lenis (스무스 스크롤) — 논쟁이 있다. 양쪽을 알고 결정해라
|
||||
|
||||
**반대**: 스크롤은 사용자가 기대하는 기기 고유의 물리다. 바꾸는 건 시스템 관습 침해다. 관성이 붙으면 **정확한 위치에 멈추기 어렵고** 운동 장애가 있는 사용자에게 치명적이다. 지연은 모든 사용자에게 인지 비용이다.
|
||||
|
||||
**찬성**: Lenis는 구식 스크롤 재킹이 아니다. **네이티브 `scrollTop`을 이징할 뿐**이라 스크롤바·앵커·`position: sticky`·스크린리더 탐색이 그대로 작동한다. WebGL↔DOM 동기화는 스크롤을 메인 스레드에서 통제해야만 드리프트가 사라진다.
|
||||
|
||||
**판단**: 대시보드·관리도구·문서·커머스·폼 → **쓰지 않는다**(`scroll-behavior: smooth`로 충분). 콘텐츠 사이트·블로그·뉴스 → **쓰지 않는다**(읽기를 방해한다). 브랜드 랜딩·포트폴리오·캠페인 → 조건 충족 시 쓸 수 있다. WebGL↔DOM 동기화 → 사실상 필수.
|
||||
|
||||
쓴다면 전부 지켜라: `respectReducedMotion: true` 명시 · `duration` 1.2 이하 · `syncTouch: false` · 모달 열릴 때 `lenis.stop()` · 내부 스크롤 요소(코드 블록·지도)에 `data-lenis-prevent` · **키보드 스크롤(Space·PageDown·Home·End)을 실제로 눌러보고 확인**.
|
||||
|
||||
---
|
||||
|
||||
## 5. 체크리스트 — 5단계 프리플라이트에서 그대로 돌린다
|
||||
|
||||
**하나라도 실패하면 4-4 복귀다.**
|
||||
|
||||
**게이트 직결**
|
||||
- [ ] `transition`·`@keyframes`를 전부 grep했다. 애니메이션되는 속성이 `opacity`·`transform`·`translate`·`rotate`·`scale`뿐이다 (**#8**)
|
||||
- [ ] `background-color`·`box-shadow`·`filter`·`height`·`width`·`top`·`left`·`color`를 전환하는 곳이 없다. 있으면 §2 우회표로
|
||||
- [ ] `addEventListener('scroll'`이 소스에 없다 (**#10**)
|
||||
- [ ] duration·이징이 전부 `var(--dur-*)`·`var(--ease-*)`다. 인라인 ms 값이 없다
|
||||
- [ ] 이펙트를 전부 끈 상태에서 페이지가 완성돼 있다
|
||||
|
||||
**동작**
|
||||
- [ ] scroll-driven을 쓴 곳에 `@supports` 가드가 있고 **초기 상태가 그 블록 안에** 있다. Firefox에서 백지가 되지 않는다
|
||||
- [ ] 스크롤 리빌이 위아래로 오갈 때 재생되지 않는다 (`both` / `unobserve()`)
|
||||
- [ ] 첫 화면 콘텐츠가 애니메이션 없이 즉시 읽힌다
|
||||
- [ ] 퇴장 duration이 진입의 0.65배다. `(항목수 − 1) × --stagger + duration ≤ 800ms`
|
||||
- [ ] 한 시점에 움직이는 독립 요소가 3개 미만이고, 애니메이션 중에도 클릭·키 입력이 먹는다
|
||||
|
||||
**접근성 — 타협 없음**
|
||||
- [ ] `prefers-reduced-motion: reduce`를 켜고 실제로 확인했다. **전부 꺼지지 않고** 위치 이동·시차·루프만 죽고 페이드·상태 변화·진행 표시는 남는다
|
||||
- [ ] 5초 넘게 자동 재생되는 애니메이션에 정지 수단이 있다 (WCAG 2.2.2)
|
||||
- [ ] 큰 면적이 무한 회전·확대·시차 이동하지 않는다 (전정기관 장애)
|
||||
- [ ] 텍스트가 글자 단위로 쪼개져 있지 않다. 쪼갰다면 원문이 DOM에 온전히 있고 시각 사본에 `aria-hidden`이 있다
|
||||
- [ ] 카운트업의 최종값이 스크린리더에 노출된다
|
||||
- [ ] 포커스 링이 애니메이션되지 않고 즉시 보인다. Tab 순서가 시각 순서와 일치하고, 전환 후 포커스가 새 콘텐츠를 따라간다
|
||||
- [ ] 호버에만 있는 정보가 없다 (터치·키보드에서 접근 불가)
|
||||
|
||||
**성능**
|
||||
- [ ] 모션 라이브러리 추가분이 `tokens.md` §5 예산 안이다. 넘겼으면 올린 이유를 명시했다
|
||||
- [ ] `will-change`가 상시 걸린 요소가 10개 미만이다
|
||||
- [ ] Lenis를 썼다면 §4의 조건 6개를 전부 충족했고 키보드 스크롤을 실제로 테스트했다
|
||||
- [ ] 6단계 `design.md`에 **채택한 모션 · 안 쓰기로 한 모션과 그 이유**를 적었다
|
||||
|
||||
> 근거: research/motion/01~03 (조사일 2026-08-20)
|
||||
137
packages/skill/references/preflight.md
Normal file
137
packages/skill/references/preflight.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# preflight — 출시 전 감사
|
||||
|
||||
5단계에서 읽는다. **자기 결과물을 남의 것처럼 본다.** 통과 못 한 항목은 4단계로 돌아가 고친다.
|
||||
|
||||
슬롭 지문 검출과 자가 채점표는 `references/antipatterns.md` 에 있다. 이 문서는 **그 외 전부**를 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 먼저 기계로 검사한다
|
||||
|
||||
**무엇에 돌리는가부터 정해라.**
|
||||
- 정적 HTML/CSS 프로젝트 → 소스 그대로
|
||||
- **React·Vue·Astro 등 프레임워크 프로젝트 → 프로덕션 빌드 결과에 돌린다.** 소스만 보면 렌더된 DOM 이 없어 대비·가로 스크롤·LCP 를 잴 수 없다. `pnpm build && pnpm preview` 로 띄우고 그 URL 을 검사해라
|
||||
- **소스만 보고 "측정 불가"로 남기지 마라.** 미측정은 통과가 아니다
|
||||
|
||||
grep 검사는 **주석과 문자열을 제외**해라. "100vh 금지"라고 쓴 주석이 게이트 #9 에 걸리는 오탐이 실제로 나왔다.
|
||||
|
||||
사람 눈보다 정확하고 빠르다. 셋 다 돌려라.
|
||||
|
||||
### A. 이펙트 전부 끄기
|
||||
```css
|
||||
/* 임시로 주입해서 확인한다 */
|
||||
*, *::before, *::after {
|
||||
filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
canvas { display: none !important; }
|
||||
```
|
||||
이 상태에서 **페이지가 여전히 읽히고 구조가 살아 있어야 한다.** 무너지면 이펙트가 구조를 대신하고 있는 것이다. 4-1로 돌아가라.
|
||||
|
||||
### B. 슬롭 지문 grep
|
||||
`references/antipatterns.md` 의 grep 목록을 소스에 돌린다. 검출률 상위 항목(보라 CTA, 전체 대문자 헤드라인, 번호 매긴 1·2·3 단계)부터.
|
||||
|
||||
### C. 카피 경쟁사 치환
|
||||
제품명을 경쟁사 이름으로 바꿔 읽는다. **문장이 그대로 성립하면 그 카피는 아무것도 말하지 않았다.** 히어로 문구부터 검사한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 접근성 — 타협 없음
|
||||
|
||||
| 항목 | 확인 방법 | 기준 |
|
||||
|---|---|---|
|
||||
| 키보드 도달 | Tab 만으로 순회 | 모든 인터랙티브 요소에 도달, 순서가 시각 순서와 일치 |
|
||||
| 포커스 표시 | Tab 하며 눈으로 | 항상 보인다. `outline: none` 만 있고 대체가 없으면 실패 |
|
||||
| 대비 | 측정 도구 | 본문 4.5:1, 큰 글자·UI 3:1 |
|
||||
| 이미지 대체텍스트 | 소스 확인 | 의미 있는 이미지에 alt, 장식은 `alt=""` |
|
||||
| 폼 레이블 | 소스 확인 | 모든 입력에 연결된 `<label>`. placeholder 는 레이블이 아니다 |
|
||||
| 제목 구조 | 개요 확인 | h1 하나, 건너뛰지 않음 |
|
||||
| 모션 감소 | `prefers-reduced-motion` 켜고 확인 | **전부 끄는 게 정답이 아니다** — 위치 이동·시차·자동재생을 죽이고, 페이드·상태 변화는 남긴다 |
|
||||
| 자동재생 | 확인 | 5초 이상 반복되는 애니메이션에는 정지 수단이 있다 |
|
||||
| 캔버스/WebGL 실패 | JS 끄고 확인 | 콘텐츠가 여전히 보인다 |
|
||||
|
||||
**스크린리더를 실제로 켜볼 수 없다면** 최소한 소스의 읽기 순서를 확인해라. `order`·`position: absolute` 로 시각 순서를 바꿨다면 읽기 순서가 어긋난다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 성능 — 3단계에서 정한 예산과 대조
|
||||
|
||||
**예산 값은 `tokens.md` §5 를 보라. 여기서 다시 적지 않는다** — 두 곳에 적으면 갈라진다.
|
||||
이 문서가 하는 일은 **실측을 채우는 것**이다.
|
||||
|
||||
| 항목 | 실측 | 판정 |
|
||||
|---|---|---|
|
||||
| 히어로까지 JS (gzip) | | |
|
||||
| WebGL/3D 추가분 | | |
|
||||
| 총 전송량 (첫 화면) | | |
|
||||
| 첫 인터랙션 (모바일 4G) | | |
|
||||
| LCP | | |
|
||||
| CLS | | |
|
||||
| 폰트 파일 수 | | |
|
||||
|
||||
**측정하지 않았으면 통과가 아니다.** 예산을 넘겼다면 둘 중 하나다: 이펙트를 빼거나, 예산을 올린 이유를 명시하거나.
|
||||
|
||||
### 자주 걸리는 것
|
||||
- 폰트 서브셋 안 함 (한글 전체 폰트는 수 MB)
|
||||
- 첫 화면 밖 이미지에 `loading="lazy"` 누락
|
||||
- 3D/WebGL을 초기 번들에 포함 (뷰포트 진입 시 동적 import 해야 한다)
|
||||
- 애니메이션이 `top`/`left`/`width` 를 건드림 → `transform` 으로 바꿔라
|
||||
- `will-change` 남발 → 레이어가 늘어 오히려 느려진다
|
||||
|
||||
---
|
||||
|
||||
## 3. 반응형
|
||||
|
||||
- [ ] **320px** 폭에서 가로 스크롤 없음 (하드 게이트 #5 는 320~1920px 전 구간을 요구한다. 375px 만 보면 그 아래가 뚫린다)
|
||||
- [ ] 1920px 이상에서 콘텐츠가 늘어지지 않음(최대 폭 제한)
|
||||
- [ ] 중간 뷰포트(768~1024px)에서 레이아웃이 깨지지 않음 — **가장 자주 빠뜨리는 구간**
|
||||
- [ ] 터치 타깃: **버튼·아이콘·카드 등 독립 컨트롤은 44×44px 이상**(Apple/Google 권고)
|
||||
- [ ] 터치 타깃: **본문 안 인라인 텍스트 링크는 24×24px 이상**(WCAG 2.5.8 AA). 여기에 44 를 요구하면 정상적인 내비 링크가 오탐된다
|
||||
- [ ] 호버로만 접근되는 기능 없음
|
||||
- [ ] 긴 단어/URL이 넘치지 않음 (`overflow-wrap: anywhere`)
|
||||
- [ ] 가로 모드에서 첫 화면이 잠기지 않음
|
||||
|
||||
---
|
||||
|
||||
## 4. 콘텐츠
|
||||
|
||||
- [ ] 히어로가 **무엇인지** 말한다. 형용사만 있고 명사가 없으면 실패
|
||||
- [ ] 로렘입숨이 남아 있지 않다
|
||||
- [ ] 실제 콘텐츠 길이로 테스트했다 (짧은 제목만으로 맞춘 레이아웃은 긴 제목에서 깨진다)
|
||||
- [ ] 빈 상태·오류 상태·로딩 상태가 있다
|
||||
- [ ] 링크가 어디로 가는지 텍스트만 보고 알 수 있다 ("여기를 클릭" 금지)
|
||||
- [ ] 이미지가 없거나 실패했을 때 레이아웃이 무너지지 않는다(`aspect-ratio` 지정)
|
||||
|
||||
---
|
||||
|
||||
## 5. 디자인 자체
|
||||
|
||||
기계로 못 잡는 부분이다. **정직하게 답해라.**
|
||||
|
||||
1. **이 사이트를 다른 회사의 것으로 바꿔도 그대로 쓸 수 있는가?** → 그렇다면 브리프에 맞춘 것이 없다
|
||||
2. **2단계에서 정한 한 문장 컨셉이 화면에서 보이는가?** → 안 보이면 컨셉이 문서에만 있는 것이다
|
||||
3. **감수하기로 한 리스크가 실제로 들어갔는가?** → 구현 중에 안전한 쪽으로 후퇴하지 않았는지
|
||||
4. **1단계 레퍼런스 R2(다른 업종의 톤)의 흔적이 있는가?** → 없으면 결국 R1을 베낀 것이다
|
||||
5. **강조색이 하나인가?**
|
||||
6. **여백이 의도적인가, 남은 것인가?**
|
||||
|
||||
---
|
||||
|
||||
## 6. 최종 게이트
|
||||
|
||||
전부 통과해야 끝난다.
|
||||
|
||||
- [ ] 0장 기계 검사 3종 통과
|
||||
- [ ] 접근성 표 전 항목 통과
|
||||
- [ ] 성능 예산 내 (또는 초과 이유 명시)
|
||||
- [ ] 반응형 체크리스트 통과
|
||||
- [ ] 콘텐츠 체크리스트 통과
|
||||
- [ ] 5장 여섯 질문에 정직하게 답했고, 실패 항목을 고쳤다
|
||||
- [ ] `antipatterns.md` 자가 채점표 통과
|
||||
|
||||
**하나라도 실패하면 출시가 아니라 4단계 복귀다.**
|
||||
|
||||
> 근거: research/references/04-ai-slop-signatures.md, research/motion/01-principles.md, research/three/04-performance.md (조사일 2026-08-20)
|
||||
17
packages/skill/references/presets/README.md
Normal file
17
packages/skill/references/presets/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# presets — 미학 프리셋
|
||||
|
||||
2단계에서 읽는다. **하나를 고르고, 왜 그것인지 한 줄로 적는다.**
|
||||
|
||||
프리셋은 템플릿이 아니라 **결정의 출발점**이다. 그대로 쓰면 그 프리셋의 평균이 나온다.
|
||||
1단계 레퍼런스 R2(다른 업종의 톤)를 여기에 섞어야 이 브리프만의 것이 된다.
|
||||
|
||||
| 프리셋 | 한 줄 | 언제 |
|
||||
|---|---|---|
|
||||
| [editorial](editorial.md) | 잡지의 여백과 세리프 | 콘텐츠가 주인공. 브랜드·미디어·포트폴리오·럭셔리 |
|
||||
| [swiss-minimal](swiss-minimal.md) | 그리드와 침묵 | 제품이 복잡할 때. B2B·도구·문서 |
|
||||
| [anti-grid](anti-grid.md) | 의도적으로 깨진 격자 | 기억되어야 할 때. 에이전시·아트·캠페인 |
|
||||
| [dark-instrument](dark-instrument.md) | 계기판처럼 정확한 | 개발자·데이터·기술 제품 |
|
||||
| [quiet-commerce](quiet-commerce.md) | 읽히는 커머스 | 전환이 목적인 B2C. 상품 수가 적고 사양이 설득의 주체 |
|
||||
|
||||
**어느 것도 맞지 않으면 새로 정의해라.** 브리프가 프리셋보다 우선한다.
|
||||
새로 정의할 때도 이 문서들의 항목 구조(타이포/색/간격/재질/모션/실패)는 그대로 채워야 한다.
|
||||
58
packages/skill/references/presets/anti-grid.md
Normal file
58
packages/skill/references/presets/anti-grid.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# 프리셋: anti-grid — 의도적으로 깨진 격자
|
||||
|
||||
> 한 문장: **기억되는 것이 목적일 때.** 규칙을 알고 나서 깬다.
|
||||
|
||||
벤토 그리드가 표준화되자 그 반작용으로 부상한 방향이다. 2026년 현재 **실제로 작동하는 차별화 수단**이다.
|
||||
|
||||
## 언제 고르나
|
||||
|
||||
- 에이전시·스튜디오 자사 사이트, 아트/문화 프로젝트, 캠페인, 음악·패션
|
||||
- 클라이언트가 "안전한 것 말고"를 명시적으로 요구했을 때
|
||||
- **쓰지 마라**: 전환이 목적인 커머스, B2B SaaS 구매 경로, 접근성 요구가 엄격한 공공. 이 프리셋은 읽기 비용을 높인다
|
||||
|
||||
## 결정
|
||||
|
||||
### 타이포그래피
|
||||
- **모노스페이스 또는 개성 강한 그로테스크**를 주역으로
|
||||
- 크기 대비를 극단적으로 — 비율 1.618 이상, 또는 스케일을 아예 무시하고 두세 개 크기만 극단적으로 배치
|
||||
- 대문자·자간 확대·세로쓰기 같은 장치를 **하나만** 쓴다. 셋을 동시에 쓰면 아마추어
|
||||
- **전체 대문자 헤드라인은 검출률 2위 슬롭 지문**이다. 쓰려면 이유가 있어야 하고, 짧아야 한다(한 줄 이내)
|
||||
|
||||
| 슬롯 | 무료 | 유료 | 한글 |
|
||||
|---|---|---|---|
|
||||
| 디스플레이 | Karrik, Basteleur, Terminal Grotesque (Velvetyne) | Right Grotesk | 제주명조, 여기어때 잘난체 |
|
||||
| 본문 | Work Sans, Archivo | Neue Montreal | Pretendard |
|
||||
|
||||
### 색
|
||||
- 원색 또는 극단적 대비. 중간 톤을 피한다
|
||||
- 배경색 자체를 강한 색으로 쓰는 것을 검토 (흰 배경이 기본값이라고 가정하지 마라)
|
||||
- **단, 색은 두 개까지.** 무지개는 브루탈리즘이 아니라 혼돈이다
|
||||
|
||||
### 간격
|
||||
- 격자를 **정의한 뒤에 깬다.** 그리드 없이 배치하면 깨진 게 아니라 그냥 엉망이다
|
||||
- 요소를 겹치게(`z-index`, 음수 마진) 배치 — 단 겹침이 읽기를 방해하면 실패
|
||||
- 화면 밖으로 흘려보내기, 극단적 여백과 극단적 밀집을 한 페이지에 공존시키기
|
||||
|
||||
### 재질 (`svg-filters.md`)
|
||||
- 거친 질감이 어울린다: **강한 그레인, 리소그래프 인쇄, 잉크 번짐(`feTurbulence` + `feDisplacementMap`)**
|
||||
- 크로마틱 애버레이션을 호버에 얹기
|
||||
- 매끄러운 유리·글로우는 이 프리셋의 반대편이다
|
||||
|
||||
### 모션 (`motion.md`)
|
||||
- 갑작스럽게. `steps()` 이징, 짧고 각진 전환
|
||||
- 커서 인터랙션(마그네틱, 왜곡)이 잘 맞는다
|
||||
- **`prefers-reduced-motion` 대응이 특히 중요하다.** 이 프리셋의 모션은 강해서 어지러움을 유발하기 쉽다
|
||||
|
||||
## 흔한 실패
|
||||
|
||||
1. **그리드를 정의하지 않고 시작** → 깬 게 아니라 못 맞춘 것으로 보인다
|
||||
2. **읽을 수 없게 만들고 "의도"라고 함** → 브루탈리즘도 읽혀야 한다. 히어로 메시지는 반드시 읽혀야 한다
|
||||
3. **장치를 전부 동원** → 대문자 + 자간확대 + 겹침 + 회전 + 그레인 = 소음
|
||||
4. **접근성 포기** → 포커스 링과 대비는 이 프리셋에서도 지킨다
|
||||
5. **B2B 브리프에 적용** → 프리셋 선택 자체가 실패
|
||||
|
||||
## 리스크 후보
|
||||
|
||||
- 히어로 텍스트를 화면 밖으로 절반 흘려보내기
|
||||
- 스크롤에 따라 그리드 자체가 재배치되기
|
||||
- 배경을 원색 하나로 덮고 이미지를 흑백으로
|
||||
62
packages/skill/references/presets/dark-instrument.md
Normal file
62
packages/skill/references/presets/dark-instrument.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# 프리셋: dark-instrument — 계기판처럼 정확한
|
||||
|
||||
> 한 문장: **정밀함이 곧 신뢰인 화면.** 어둡되 분위기가 아니라 기능으로 어둡다.
|
||||
|
||||
## 언제 고르나
|
||||
|
||||
- 개발자 도구, 인프라, 데이터/모니터링, 보안, 금융 트레이딩
|
||||
- 사용자가 이 화면을 **오래 본다**는 전제가 있을 때
|
||||
- **쓰지 마라**: 다크가 "멋있어서" 고르는 경우. **다크 기본값은 단일 슬롭 시그니처 1위다.** 이유를 댈 수 없으면 라이트로 가라
|
||||
|
||||
### 다크를 정당화하는 이유의 예
|
||||
- 사용자가 어두운 환경(야간 운영, 스튜디오)에서 본다
|
||||
- 화면에 밝은 데이터 시각화가 있고 배경이 어두워야 대비가 산다
|
||||
- 제품 자체(터미널·에디터·모니터링)가 어둡다
|
||||
|
||||
## 결정
|
||||
|
||||
### 타이포그래피
|
||||
- 산세 + **모노스페이스 병용**. 수치·ID·코드는 반드시 모노
|
||||
- 비율 1.200~1.250. 정보 밀도가 높으므로 위계 차이를 작게
|
||||
- 숫자는 **tabular-nums** 를 켜라 (`font-variant-numeric: tabular-nums`). 수치가 흔들리면 계기판이 아니다
|
||||
|
||||
| 슬롯 | 무료 | 한글 |
|
||||
|---|---|---|
|
||||
| 본문 | IBM Plex Sans, DM Sans, Switzer | Pretendard / Spoqa Han Sans Neo |
|
||||
| 수치·코드 | JetBrains Mono, IBM Plex Mono | + Pretendard |
|
||||
|
||||
### 색
|
||||
- **순수 검정을 쓰지 마라.** `#0d0d0f`~`#14130f` 대역. 순흑은 대비가 과해 눈이 아프다
|
||||
- 텍스트도 순백 금지. `#e8e4dc`~`#e6e6e9`
|
||||
- 표면 단계를 3개로: `--surface`(가장 어두움) / `--surface-raised` / `--line`
|
||||
- 강조색은 **밝고 채도 높게, 아주 좁게.** 어두운 배경에서는 작은 면적으로도 충분히 강하다
|
||||
- 상태색(성공/경고/오류)을 강조색과 구분해서 정의해라. 여기서는 기능이 우선이다
|
||||
|
||||
### 간격
|
||||
- 밀도를 높이되 **정렬을 완벽하게.** 이 프리셋에서 어긋난 1px은 즉시 보인다
|
||||
- 데이터 영역과 설명 영역의 간격을 명확히 구분
|
||||
- 4px 그리드를 엄격히
|
||||
|
||||
### 재질 (`svg-filters.md`)
|
||||
- **거의 쓰지 않는다.** 어두운 배경에서 그레인은 노이즈로 보인다
|
||||
- 쓴다면: 아주 미세한 스캔라인, 또는 강조 요소에만 좁은 글로우(`feGaussianBlur` + `feMerge`)
|
||||
- 글래스모피즘은 성능 대비 이득이 없다. 내비게이션 바 정도까지
|
||||
|
||||
### 모션 (`motion.md`)
|
||||
- 빠르고 정확하게. `--dur-instant`~`--dur-quick`
|
||||
- 실시간 데이터의 변화는 **위치 이동이 아니라 색·굵기 변화**로 알린다(레이아웃 시프트 금지)
|
||||
- 로딩은 스피너보다 스켈레톤. 무엇이 올지 미리 보여준다
|
||||
|
||||
## 흔한 실패
|
||||
|
||||
1. **순수 검정 + 순수 흰색** → 눈이 아프고 아마추어
|
||||
2. **네온 보라/파랑 강조** → 슬롭 지문. 어두운 배경일수록 이 함정에 빠지기 쉽다
|
||||
3. **다크만 만들고 라이트를 안 만듦** → 토큰으로 짰으면 라이트도 나온다. 다크 전용은 사용자 선택권을 뺏는 것
|
||||
4. **수치에 가변폭 폰트** → 숫자가 흔들려 읽기 어렵다
|
||||
5. **대비 미달** → 어두운 배경에서 `--ink-muted` 가 4.5:1 아래로 떨어지기 쉽다. 반드시 측정해라
|
||||
|
||||
## 리스크 후보
|
||||
|
||||
- 강조색을 데이터에만 쓰고 UI 전체를 무채색으로
|
||||
- 히어로를 실제 제품 화면(터미널·차트)으로 대체
|
||||
- 모노스페이스를 본문까지 확장
|
||||
56
packages/skill/references/presets/editorial.md
Normal file
56
packages/skill/references/presets/editorial.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# 프리셋: editorial — 잡지의 여백과 세리프
|
||||
|
||||
> 한 문장: **읽는 것이 목적인 화면.** 콘텐츠가 주인공이고 UI는 뒤로 물러난다.
|
||||
|
||||
## 언제 고르나
|
||||
|
||||
- 콘텐츠(글·사진·작업물)가 실제로 있고 그것이 설득의 주체일 때
|
||||
- 브랜드·미디어·포트폴리오·럭셔리·문화 기관
|
||||
- **쓰지 마라**: 기능이 많은 제품, 전환이 목적인 랜딩, 데이터 밀도가 높은 화면
|
||||
|
||||
## 결정
|
||||
|
||||
### 타이포그래피
|
||||
- **세리프를 주역으로.** 디스플레이와 본문 중 최소 하나는 세리프
|
||||
- 비율 **1.333~1.618** — 헤드라인과 본문의 차이를 크게 벌린다
|
||||
- 본문 `--measure` 를 반드시 좁게. 라틴 60~68자, 한글 25~35자
|
||||
- 웨이트는 2개면 충분(Regular + 하나). 굵기로 소리치지 않는다
|
||||
|
||||
| 슬롯 | 무료 | 유료 | 한글 |
|
||||
|---|---|---|---|
|
||||
| 디스플레이 | Instrument Serif, Fraunces | Signifier, PP Editorial New | 마루 부리 |
|
||||
| 본문 | Spectral, Lora | Söhne, Tiempos | 마루 부리 / 리디바탕 |
|
||||
|
||||
### 색
|
||||
- 배경은 **순백이 아니다.** 종이 쪽으로 살짝 따뜻하게 (`#fbfaf8`, `#f7f5f0`)
|
||||
- 잉크도 순흑이 아니다 (`#1a1917`, `#211f1c`)
|
||||
- 강조색은 **하나, 작게.** 링크·인용부호·페이지 번호 정도. 면적을 넓히면 잡지가 아니라 전단지가 된다
|
||||
|
||||
### 간격
|
||||
- **여백이 콘텐츠다.** 섹션 간격을 본문 간격의 5~6배까지 벌려도 된다
|
||||
- 비대칭을 적극적으로 — 본문을 그리드 왼쪽 2/3에 두고 오른쪽을 비우거나, 캡션을 여백에 흘린다
|
||||
- 이미지는 본문 폭을 넘어가게(`wide`/`full`) 두어 리듬을 만든다
|
||||
|
||||
### 재질 (`svg-filters.md`)
|
||||
- **필름 그레인**이 이 프리셋의 기본 재질. `feTurbulence fractalNoise` 를 아주 약하게(opacity 0.03~0.06)
|
||||
- 인쇄물의 질감: 이미지에 미세한 듀오톤 또는 그라디언트 맵
|
||||
- 유리·네온·글로우는 이 프리셋에 어울리지 않는다
|
||||
|
||||
### 모션 (`motion.md`)
|
||||
- **거의 움직이지 않는다.** 등장은 페이드 + 짧은 상승(8~16px) 이하
|
||||
- duration 은 느리게(`--dur-slow`), 이징은 `--ease-out`
|
||||
- 스크롤 재킹 금지. 읽는 사람의 속도를 뺏지 마라
|
||||
|
||||
## 흔한 실패
|
||||
|
||||
1. **세리프를 썼는데 여백이 좁다** → 세리프는 숨 쉴 공간이 없으면 그냥 답답해진다
|
||||
2. **강조색을 넓게 칠했다** → 에디토리얼의 색은 점이지 면이 아니다
|
||||
3. **모든 이미지를 같은 크기로 넣었다** → 크기 차이가 리듬이다
|
||||
4. **본문 폭이 넓다** → 이 프리셋에서 가장 자주 나오는 실패
|
||||
5. **한글에 라틴 세리프만 지정** → 한글이 시스템 기본으로 떨어져 전부 무너진다
|
||||
|
||||
## 리스크 후보 (2단계에서 하나 고른다)
|
||||
|
||||
- 히어로에 이미지 없이 **타이포그래피만**
|
||||
- 본문 폭을 극단적으로 좁히고 여백에 주석을 흘리기
|
||||
- 페이지 번호·러닝헤드 같은 **인쇄물 장치**를 웹에 가져오기
|
||||
69
packages/skill/references/presets/quiet-commerce.md
Normal file
69
packages/skill/references/presets/quiet-commerce.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# 프리셋: quiet-commerce — 읽히는 커머스
|
||||
|
||||
> 한 문장: **콘텐츠가 설득하고, 화면은 한 번만 요구한다.**
|
||||
|
||||
`editorial` 의 타이포 규율에 전환 구조를 붙인 것이다. 콘텐츠가 주인공이되 **CTA 가 경로 위에 정확히 하나** 있다.
|
||||
|
||||
## 언제 고르나
|
||||
|
||||
- 상품 수가 적고(5개 이하) 구매자가 **이미 카테고리를 아는** D2C — 로스터리, 소규모 브랜드, 구독 서비스, 단일 제품
|
||||
- **사진보다 사양·근거가 설득의 주체**일 때. 또는 사진이 아직 없을 때
|
||||
- **쓰지 마라**: 상품 수가 많은 커머스(그리드·필터가 주인공이 된다) · 카테고리 입문자 대상(설명이 먼저다) · B2B 도구(→ `swiss-minimal`)
|
||||
|
||||
> 이 프리셋이 존재하는 이유: `editorial` 과 `anti-grid` 는 "전환이 목적인 랜딩에는 쓰지 마라"고 명시하고,
|
||||
> `swiss-minimal` 은 B2B·도구용이며, `dark-instrument` 는 다크 정당화가 필요하다.
|
||||
> **전환이 목적인 B2C 랜딩에는 넷 다 맞지 않는다.**
|
||||
|
||||
## 결정
|
||||
|
||||
### 타이포그래피
|
||||
- 디스플레이 **세리프 1** + 본문 **산세리프 1**. 세리프가 톤을, 산세리프가 가독성을 맡는다
|
||||
- 비율 **1.333**, 최대÷본문 **4배 이상**. 비율만으로 안 나오면 디스플레이를 스케일 밖에 따로 정의한다(`tokens.md` §1)
|
||||
- 단계 **4~5개**. 커머스 레퍼런스는 흔히 10단계인데 그대로 베끼면 "통제 실패"에 걸린다
|
||||
- 웨이트 2개
|
||||
|
||||
| 슬롯 | 라틴 | 한글 |
|
||||
|---|---|---|
|
||||
| 디스플레이 | Fraunces, Instrument Serif | 마루 부리 |
|
||||
| 본문·UI | Switzer, Satoshi | Pretendard Variable |
|
||||
| 수치 | 본문 + `tabular-nums` | 본문 + `tabular-nums` |
|
||||
|
||||
**한글 본문에 세리프를 쓰지 마라.** 16px 대에서 가독성이 급격히 떨어진다. 세리프는 디스플레이에만.
|
||||
|
||||
### 색
|
||||
- 배경은 순백이 아니라 **오프화이트**. 단 **크림·베이지는 피해라** — "크림 = 고급"은 보라를 대체한 신종 슬롭이다. 크림을 쓰려면 브리프와 연결할 근거를 대라
|
||||
- 잉크도 순흑이 아니다
|
||||
- **강조색 하나, 면적 5% 미만.** 좋은 커머스 레퍼런스는 강조색을 CTA 한 곳과 링크에만 쓴다
|
||||
- hue 2개 (중립 + 강조)
|
||||
|
||||
### 간격
|
||||
- 8px 그리드, 여백 값 5종
|
||||
- **섹션 간 : 요소 간 = 1:5.** 커머스는 섹션이 많아 간격이 좁으면 즉시 뭉개진다
|
||||
- 비대칭을 쓴다 — 본문을 그리드 왼쪽에 두고 오른쪽 여백에 사양·수치를 흘린다
|
||||
- `--measure` 는 좁게. 한글 25~35자
|
||||
|
||||
### 재질 (`svg-filters.md`)
|
||||
- **필름 그레인 최약(0.02~0.04).** 오프화이트 배경의 밴딩을 없애는 용도지 질감을 과시하는 게 아니다
|
||||
- 유리·글로우·굴절 없음. 이 프리셋의 표면은 종이다
|
||||
- 사진이 있다면 듀오톤·그라디언트 맵으로 톤을 통일한다
|
||||
|
||||
### 모션 (`motion.md`)
|
||||
- 거의 움직이지 않는다. 스크롤 진입 페이드 + 8px 상승 하나면 충분하다
|
||||
- **스크롤 재킹 금지.** 구매 결정 중인 사람의 속도를 뺏지 마라
|
||||
- CTA 는 호버·포커스 피드백을 확실히 준다. 여기가 유일하게 반응이 즉각적이어야 하는 곳이다
|
||||
|
||||
## 흔한 실패
|
||||
|
||||
1. **세리프 디스플레이를 쓰고 여백이 좁다** → 세리프는 숨 쉴 공간이 없으면 답답할 뿐이다
|
||||
2. **CTA 가 둘로 갈라진다** ("구매하기" + "구독하기") → 레퍼런스가 그렇더라도 베끼지 마라. 브리프가 정한 목표 하나로 통합해라
|
||||
3. **수치를 지표 배너로 만든다** → 근거 없는 숫자는 신뢰를 깎는다. 하드 게이트 #11에 걸린다
|
||||
4. **한글에 라틴 세리프만 지정** → 한글이 시스템 기본으로 떨어져 전부 무너진다
|
||||
5. **사진이 없다고 일러스트로 때운다** → 사양·수치·타이포로 채우는 쪽이 이 대상에게 더 강하다
|
||||
|
||||
## 리스크 후보
|
||||
|
||||
- 히어로에 이미지도 감성 카피도 없이 **사양 수치표**를 올린다 (대상이 카테고리를 아는 경우에만)
|
||||
- 가격을 첫 화면에 정직하게 노출한다
|
||||
- 제품 설명을 산문 한 문단으로 쓰고 불릿을 없앤다
|
||||
|
||||
> 근거: research/references/03-trends-2026.md, 04-ai-slop-signatures.md, research/dogfood-coffee/ (검증일 2026-08-20)
|
||||
56
packages/skill/references/presets/swiss-minimal.md
Normal file
56
packages/skill/references/presets/swiss-minimal.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# 프리셋: swiss-minimal — 그리드와 침묵
|
||||
|
||||
> 한 문장: **제품이 복잡하니 화면이라도 조용해야 한다.**
|
||||
|
||||
## 언제 고르나
|
||||
|
||||
- 기능이 많고 설명할 것이 많은 제품 — B2B, 개발자 도구, 문서, 인프라
|
||||
- 신뢰가 전환의 조건일 때
|
||||
- **쓰지 마라**: 기억에 남는 것이 목적일 때. 이 프리셋은 안전하지만 눈에 띄지 않는다
|
||||
|
||||
## 결정
|
||||
|
||||
### 타이포그래피
|
||||
- **그로테스크 산세 하나로 끝낸다.** 두 번째 폰트를 넣고 싶으면 모노스페이스(라벨·코드용)까지만
|
||||
- 비율 **1.200~1.250** — 위계 차이를 작게. 대신 웨이트와 색으로 구분
|
||||
- 정렬은 왼쪽. 가운데 정렬은 히어로 한 곳까지만
|
||||
- **Inter 를 기본값으로 쓰지 마라.** Switzer, Satoshi, DM Sans 가 거의 언제나 낫다
|
||||
|
||||
| 슬롯 | 무료 | 유료 | 한글 |
|
||||
|---|---|---|---|
|
||||
| 전체 | Switzer, Satoshi, DM Sans | Söhne, GT America | Pretendard Variable |
|
||||
| 라벨·코드 | JetBrains Mono, IBM Plex Mono | — | Pretendard + JetBrains Mono |
|
||||
|
||||
### 색
|
||||
- 무채색 90% + 강조색 하나
|
||||
- 배경/카드/경계선의 명도 차이를 **아주 작게**. 그림자 대신 1px 경계선
|
||||
- 강조색은 채도를 낮춰라. 형광에 가까운 색은 이 프리셋에서 즉시 싸구려가 된다
|
||||
- **보라~파랑 그라디언트 절대 금지** (검출률 1위 슬롭 지문)
|
||||
|
||||
### 간격
|
||||
- 4/8 배수 그리드를 **엄격하게** 지킨다. 여기서만큼은 예외를 만들지 마라
|
||||
- 정렬선이 전부 맞아야 한다. 이 프리셋의 품질은 정렬에서 나온다
|
||||
- 섹션 간격은 본문의 4배
|
||||
|
||||
### 재질 (`svg-filters.md`)
|
||||
- **재질을 거의 쓰지 않는다.** 이 프리셋의 표면은 평평한 종이다
|
||||
- 굳이 쓴다면 아주 약한 그레인 하나까지
|
||||
- 글래스모피즘 금지 — 실기기에서 FPS 15~30% 하락하고, 이 프리셋의 정직함과 어긋난다
|
||||
|
||||
### 모션 (`motion.md`)
|
||||
- 상태 변화 위주(`--dur-instant`, `--dur-quick`). 등장 애니메이션은 최소화
|
||||
- 모션은 **피드백**이지 연출이 아니다. 클릭했는데 아무 반응 없는 쪽이 훨씬 큰 죄다
|
||||
|
||||
## 흔한 실패
|
||||
|
||||
1. **미니멀을 "요소를 지우는 것"으로 이해** → 미니멀은 정렬과 일관성이지 빈 화면이 아니다
|
||||
2. **회색이 너무 많아 위계가 사라짐** → `--ink-muted` 는 하나면 충분하다
|
||||
3. **카드에 그림자를 겹겹이** → 1px 경계선 하나가 낫다
|
||||
4. **아이콘을 이모지로** → 즉시 아마추어. 아이콘 세트 하나를 정해 일관되게
|
||||
5. **강조색을 CTA와 링크와 배지에 다 씀** → 강조가 셋이면 강조가 없다
|
||||
|
||||
## 리스크 후보
|
||||
|
||||
- 히어로에서 이미지를 완전히 빼고 **텍스트와 여백만**
|
||||
- 색을 무채색 + 딱 한 색으로 극단적으로 제한
|
||||
- 섹션 구분선을 전부 없애고 여백만으로 구획
|
||||
254
packages/skill/references/reference-method.md
Normal file
254
packages/skill/references/reference-method.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# reference-method.md — 레퍼런스 해체와 합성
|
||||
|
||||
레퍼런스를 **원리로 분해**해서 **다른 결과물로 재조합**하는 절차다.
|
||||
|
||||
**원칙 한 줄**: 레퍼런스를 언어로 설명했을 때 그 설명이 여러 결과물로 구현될 수 있으면 원리다. 하나로만 이어지면 표현이다. **원리만 가져간다.**
|
||||
|
||||
| 가져간다 (원리) | 안 가져간다 (표현) |
|
||||
|---|---|
|
||||
| 그리드 비율 (5:7 비대칭) | 섹션 배치 그대로 |
|
||||
| 타입 스케일 비율 (1.333) | 폰트 조합 그대로 |
|
||||
| 색의 역할 배분 (60/30/10) | 색상값 |
|
||||
| 여백 리듬 (섹션 120 / 요소 24) | 섹션 순서 |
|
||||
| 모션의 의도 (스크롤=시선 유도) | 애니메이션 시퀀스 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 3-소스 합성 규칙
|
||||
|
||||
레퍼런스 1개는 모방, 2개는 절충, **3개부터 합성**이다. 단 **층위를 나눠서** 고른다.
|
||||
|
||||
| 슬롯 | 가져올 것 | 어디서 |
|
||||
|---|---|---|
|
||||
| **R1 · 구조** | 그리드, 섹션 순서, 정보 위계, 여백 리듬 | 같은 업종·같은 목적 |
|
||||
| **R2 · 톤** | 색 역할, 타입 페어링, 형태 언어, 질감 | **반드시 다른 업종** (또는 웹이 아닌 것) |
|
||||
| **R3 · 디테일** | 모션 1개, 호버 1개, 전환 1개 | Codrops / Eyecandy / Design Spells |
|
||||
|
||||
**R2가 R1과 같은 업종이면 합성이 아니다.**
|
||||
SaaS 구조 + SaaS 톤 = 또 하나의 SaaS 슬롭.
|
||||
SaaS 구조 + 독립 출판사 톤 = 차별화.
|
||||
|
||||
**충돌 해결 순위**
|
||||
1. 가독성·접근성이 언제나 이긴다. R2의 톤이 대비를 깨면 톤을 조정한다.
|
||||
2. R1 구조가 R3 모션을 이긴다. 모션 때문에 구조를 바꾸지 않는다.
|
||||
3. 브리프가 모든 레퍼런스를 이긴다. 충돌하면 레퍼런스를 버린다.
|
||||
4. **한 축은 한 소스에서만.** 그리드는 R1에서만, 팔레트는 R2에서만. 섞으면 이유 없는 절충이 된다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 6축 해체 프레임워크
|
||||
|
||||
각 레퍼런스를 6축으로 뜯는다. **형용사는 기록이 아니다. 숫자나 규칙으로 적는다.**
|
||||
|
||||
**R3 예외**: R3는 사이트가 아니라 기법이다. 축 5(모션 언어) 하나만 채우고 나머지는 비운다.
|
||||
대신 R3에는 **구현 제약**(브라우저 지원, 성능 비용, 보간 조건)을 반드시 적는다.
|
||||
|
||||
### 축 1 — 레이아웃 그리드
|
||||
컬럼 수·거터 / 콘텐츠 최대폭과 뷰포트 비 / 대칭·비대칭(비율) / 그리드를 깨는 요소 / 본문 한 줄 글자 수(영문 45–75, 국문 25–40)
|
||||
→ *"무엇을 정렬시키고 무엇을 일부러 어긋나게 했나?"*
|
||||
|
||||
### 축 2 — 타입 스케일
|
||||
실제 사용된 크기 목록 / 그 사이 비율(1.25 또는 1.333이 표준) / 단계 수(3–5가 건강, 8+ 는 통제 실패) / 패밀리 수(2개 표준) / 굵기 대비 / line-height / letter-spacing
|
||||
→ *"최대 글자와 본문의 비는 몇 배인가?"* (4배 미만이면 위계 약함)
|
||||
|
||||
### 축 3 — 컬러 역할
|
||||
색상값이 아니라 **역할**로 적는다. surface 단계 수 / text 3단계 유무 / accent 면적 %(10% 이하가 정상) / border 존재 여부 / state 색 분리 여부
|
||||
→ *"강조색을 지우면 여전히 읽히는가?"* (읽혀야 정상)
|
||||
|
||||
### 축 4 — 여백 리듬
|
||||
기본 단위(4 또는 8px) / 섹션 간 수직 여백(80–160px) / 요소 간 : 섹션 간 비율(1:4~1:6) / 여백 값 종류 수(4–6종이 건강) / 좌우 패딩
|
||||
→ *"가장 큰 빈 공간은 어디이고 왜 거기인가?"*
|
||||
|
||||
### 축 5 — 모션 언어
|
||||
트리거(로드/스크롤/호버/커서) / duration / easing / 정보 전달인가 장식인가 / reduced-motion 대응
|
||||
→ *"모션을 전부 끄면 작동하는가?"* (작동해야 정상)
|
||||
|
||||
**어떤 CSS 속성을 애니메이션하는지 반드시 적어라.** 하드 게이트 #8은 `transform`·`opacity` 만 허용한다.
|
||||
`stroke-dashoffset`·`height`·`clip-path`·`filter` 로 만든 기법을 R3로 골라놓고 4단계에서 발견하면
|
||||
그때는 이미 늦다. **1단계에서 걸러라.** 그 기법이 좋다면 `motion.md` 의 우회법 표에서
|
||||
같은 인상을 `transform`/`opacity` 로 내는 방법을 찾은 뒤에 채택해라.
|
||||
|
||||
### 축 6 — 시선 흐름
|
||||
첫 진입점 / 2·3번째 / 비즈니스 우선순위와 일치 여부 / Z·F·수직 낙하 중 무엇인가 / CTA가 경로 위에 있나
|
||||
|
||||
**회색조 위계 서술** (에이전트용 squint test 대체) — 자기가 만들 화면에 대해 문장으로 쓴다:
|
||||
> 색을 전부 제거했을 때 가장 강한 덩어리는 ___, 두 번째는 ___, 세 번째는 ___.
|
||||
> 이 순서는 비즈니스 우선순위 [1위 ___ / 2위 ___ / 3위 ___]와 일치한다 / 하지 않는다.
|
||||
|
||||
일치하지 않으면 크기·굵기·대비를 조정한다. **색으로 해결하지 마라.**
|
||||
|
||||
---
|
||||
|
||||
## 3. 실전 절차 체크리스트
|
||||
|
||||
- [ ] **1. 브리프를 한 문장으로 압축한다.** `[대상]이 [행동]하게. 느낌은 [형용사 2개].` 이 문장 없이 수집을 시작하지 않는다.
|
||||
- [ ] **2. galleries.md 라우팅 표에서 R1/R2/R3 갤러리를 정한다.** R2가 R1과 다른 업종인지 확인한다.
|
||||
- [ ] **3. 각 갤러리에서 원본 사이트 URL을 1개씩 확보한다.** 하위 페이지가 필요하면 **인덱스에서 `href`를 뽑아라. slug를 추측하지 마라** — 추측한 URL은 404가 난다.
|
||||
- [ ] **3-b. 그 URL이 정본인지 검증한다.** 사용자가 특정 제품·브랜드를 지목했고 후보 도메인이 여럿이면 필수다. **HTTP 200은 살아있다는 뜻이 아니다.**
|
||||
- `curl -sS -o /dev/null -w "%{url_effective} %{http_code} %{size_download}\n" -L <url>` 로 **최종 URL·본문 크기**를 본다. 본문이 **1KB 미만이면 파킹 도메인을 의심하라** — 전형적 파킹 페이지는 `/lander` 로 보내는 JS 한 줄뿐이다.
|
||||
- 후보들의 본문이 **바이트 단위로 동일**하면 전부 같은 파킹 서비스다.
|
||||
- 정본 판정 근거는 **콘텐츠 안에서** 찾아라: 저장소 링크, 설치 명령에 적힌 패키지·스킬 이름, 소유자 계정, 문서 제목이 로컬 파일과 일치하는지.
|
||||
- [ ] **4. 원본 사이트 3개를 §4 템플릿으로 WebFetch 한다.** 갤러리 페이지가 아니라 원본이다. → 축 6·정보 위계·카피 톤을 얻는다.
|
||||
- [ ] **5. §5로 computed style을 추출한다.** → 축 1·2·3·4를 얻는다. **이 단계를 건너뛰면 6축의 4개가 빈칸으로 남는다.**
|
||||
- [ ] **6. 텍스트 소스로 폰트 이름·무료 대체를 확정한다.** (Typewolf / Happy Hues / Eyecandy — galleries.md §3)
|
||||
- [ ] **7. 6축 해체 시트를 작성한다.** 숫자로. 6축에 안 들어가는 관측은 `6축 밖의 관측`에 적는다.
|
||||
- [ ] **8. 합성한다.** 축마다 어느 소스에서 가져왔는지 명시.
|
||||
- [ ] **9. 의도적 변형을 각 레퍼런스마다 1개씩 넣고 이유를 적는다.**
|
||||
- [ ] **10. antipatterns.md를 훑고 걸린 항목이 없는지 확인한다.**
|
||||
|
||||
---
|
||||
|
||||
## 4. WebFetch 프롬프트 템플릿 (그대로 복사해서 쓴다)
|
||||
|
||||
### 4-A. 원본 사이트 구조 분석 — 가장 자주 쓴다
|
||||
|
||||
> 이 페이지의 구조를 분석해줘. 다음을 순서대로 답해라.
|
||||
> 1. 상단부터 순서대로 섹션 목록. 각 섹션의 역할을 한 단어로 붙여라.
|
||||
> 2. h1의 정확한 문장, 그리고 h2 전체 목록.
|
||||
> 3. CTA 문구 전부와 각각이 페이지 어디에 있는지.
|
||||
> 4. 내비게이션 항목 목록.
|
||||
> 5. 이 페이지가 방문자에게 시키려는 단 하나의 행동.
|
||||
> 6. 카피의 톤 — 평균 문장 길이, 1인칭/2인칭 사용, 전문용어 밀도.
|
||||
> 7. 숫자나 지표가 사용된 위치와 그 값.
|
||||
> 추측하지 말고 페이지에 실제로 있는 것만 답해라. 없으면 "없음"이라고 써라.
|
||||
|
||||
### 4-B. 폰트 확인 (Typewolf 등)
|
||||
|
||||
> 이 페이지에서 언급된 폰트 이름을 전부 나열해라. 각각에 대해 헤드라인용인지 본문용인지, 유료인지 무료인지, 제시된 무료 대체 폰트가 있으면 그 이름까지 적어라. 페이지에 없는 정보는 지어내지 마라.
|
||||
|
||||
### 4-C. 모션 기법 확인 (Eyecandy · Codrops)
|
||||
|
||||
> 이 페이지에서 설명하는 애니메이션 기법의 이름, 그 기법이 시각적으로 무엇을 하는지, 구현에 쓰인 CSS 속성이나 JS 라이브러리를 나열해라. 코드 예시가 있으면 핵심 부분을 그대로 옮겨라.
|
||||
|
||||
### 4-D. 디자인 시스템 토큰 확보 (DesignSystems.one · 공개 시스템 문서)
|
||||
|
||||
> 이 문서에서 다음을 추출해라. 값이 명시된 것만 적고 없으면 "미공개"라고 써라.
|
||||
> 1. 컬러 토큰 이름과 값 (전체 스케일)
|
||||
> 2. 타입 스케일 — 크기 목록, line-height, 굵기
|
||||
> 3. 여백 스케일 — 기본 단위와 전체 단계
|
||||
> 4. border-radius 값 목록
|
||||
> 5. 그림자 정의
|
||||
> 6. 모션 duration과 easing
|
||||
|
||||
### 4-E. 리디자인 — 현행 사이트 감사
|
||||
|
||||
> 이 페이지를 감사해줘. 다음을 답해라.
|
||||
> 1. 섹션 순서와 각 섹션이 실제로 전달하는 정보
|
||||
> 2. 정보 위계상 가장 강조된 것과, 비즈니스적으로 가장 중요해 보이는 것 — 둘이 일치하는가
|
||||
> 3. 카피에서 구체적 사실(숫자, 고유명사, 조건)이 들어간 문장과 그렇지 않은 문장의 비율
|
||||
> 4. 내비게이션 구조와 항목 수
|
||||
> 5. 페이지에서 명백히 불필요하거나 중복인 섹션
|
||||
> 추측하지 말고 페이지에 있는 것만 답해라.
|
||||
|
||||
---
|
||||
|
||||
## 5. computed style 추출 (축 1·2·3·4를 얻는 유일한 방법)
|
||||
|
||||
WebFetch는 마크다운으로 변환하면서 CSS를 버린다. **그리드·타입스케일·컬러·여백은 WebFetch로 절대 나오지 않는다.**
|
||||
브라우저 도구(Chrome DevTools MCP / Playwright)로 원본 사이트를 열고 아래를 실행한다.
|
||||
|
||||
```js
|
||||
() => {
|
||||
const cs = (el, p) => getComputedStyle(el).getPropertyValue(p);
|
||||
const vis = el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
||||
const all = [...document.querySelectorAll('body *')].filter(vis);
|
||||
const textEls = all.filter(el => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 1));
|
||||
const tally = a => { const m={}; a.forEach(v=>{m[v]=(m[v]||0)+1;}); return Object.entries(m).sort((x,y)=>y[1]-x[1]).slice(0,12); };
|
||||
const h1 = document.querySelector('h1');
|
||||
const p = [...document.querySelectorAll('p')].filter(vis).find(el => el.innerText.trim().length > 200);
|
||||
return {
|
||||
// 반드시 첫 줄에 둔다. 페이지가 실행 중 이탈하면 엉뚱한 사이트의 값이 돌아온다.
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
viewport: innerWidth + 'x' + innerHeight,
|
||||
heading: h1 && { size: cs(h1,'font-size'), lh: cs(h1,'line-height'), ls: cs(h1,'letter-spacing'),
|
||||
weight: cs(h1,'font-weight'), family: cs(h1,'font-family').split(',')[0] },
|
||||
body: p && { size: cs(p,'font-size'), lh: cs(p,'line-height'), color: cs(p,'color'),
|
||||
family: cs(p,'font-family').split(',')[0], widthPx: Math.round(p.getBoundingClientRect().width) },
|
||||
fontSizes: tally(textEls.map(el => cs(el,'font-size'))),
|
||||
families: tally(textEls.map(el => cs(el,'font-family').split(',')[0].replace(/["']/g,''))),
|
||||
textColors: tally(textEls.map(el => cs(el,'color'))),
|
||||
backgrounds:tally(all.map(el => cs(el,'background-color')).filter(c => c !== 'rgba(0, 0, 0, 0)')),
|
||||
radii: tally(all.map(el => cs(el,'border-radius')).filter(r => r !== '0px')),
|
||||
widths: tally(all.filter(el => el.getBoundingClientRect().width > 500)
|
||||
.map(el => Math.round(el.getBoundingClientRect().width) + 'px')),
|
||||
sectionPadding: [...new Set([...document.querySelectorAll('section, main > div')].filter(vis).slice(0,20)
|
||||
.map(el => cs(el,'padding-top') + ' / ' + cs(el,'padding-bottom')))]
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**결과 읽는 법**
|
||||
|
||||
> **먼저 `url` 을 봐라. 열려고 한 사이트가 아니면 나머지 값은 전부 버려라.**
|
||||
> 페이지가 실행 중 이탈하거나 리다이렉트되면 스크립트는 **다른 사이트의 값을 조용히 반환한다.**
|
||||
> 실측에서 2회 연속 발생했다. 이건 실패가 아니라 **오염**이다 — 숫자가 6축 시트에 들어가고,
|
||||
> 3단계 토큰의 근거가 되고, `design.md` 에 남고, 어디서도 검출되지 않는다.
|
||||
|
||||
- `fontSizes` 상위 항목 = 타입 스케일. 최다 크기가 본문이다. 최대÷본문이 **4배 미만이면 위계가 약한 레퍼런스**다.
|
||||
- `textColors` 상위 3~4개 = 텍스트 위계 단계. 3단계 이상이면 잘 설계된 것.
|
||||
- `backgrounds` 최다값 = 지배 배경. 등장 횟수가 적은 채도 높은 색이 **강조색**이고, 그 횟수가 곧 면적 감각이다.
|
||||
- `radii` 종류 수 = 형태 어휘. 1~3개면 통제된 것, 그 이상이면 우연히 결정된 것.
|
||||
- `widths` 최다값 = 컨테이너 폭. `body.widthPx` = 텍스트 컬럼 폭.
|
||||
|
||||
브라우저 도구를 못 쓰는 환경이면 **6축 중 4개가 빈칸이라는 사실을 산출물에 명시**하고 넘어간다. 채운 척하지 마라.
|
||||
|
||||
---
|
||||
|
||||
## 6. 하지 말아야 할 것
|
||||
|
||||
- **갤러리 홈페이지만 fetch하고 "레퍼런스를 봤다"고 말하기.** 갤러리 홈은 카드 이미지뿐이라 정보가 0이다. **반드시 원본 사이트를 연다.**
|
||||
- 레퍼런스 이름만 나열하고 넘어가기. 6축 수치가 없으면 참고한 게 아니다.
|
||||
- **WebFetch만 하고 6축을 채웠다고 하기.** WebFetch는 축 6과 카피 톤만 준다. 나머지는 §5가 필요하다.
|
||||
- 하위 페이지 URL의 slug를 추측하기. 인덱스에서 `href`를 뽑아라.
|
||||
- R1·R2·R3를 같은 갤러리에서 고르기.
|
||||
- 6축 시트에 "세련됐다", "깔끔하다" 같은 형용사 적기.
|
||||
|
||||
---
|
||||
|
||||
## 6. 산출물 형식 (필수)
|
||||
|
||||
레퍼런스 조사 단계는 **아래 블록을 출력하기 전까지 완료되지 않는다.** 다음 단계로 넘어가지 마라.
|
||||
|
||||
```markdown
|
||||
## 브리프
|
||||
[대상]이 [행동]하게. 느낌은 [형용사 2개].
|
||||
|
||||
## R1 · 구조 — <이름> <원본 URL>
|
||||
- 가져올 축: 그리드, 여백 리듬
|
||||
- 그리드: 최대폭 1200 / 비대칭 7:5 / 거터 32
|
||||
- 여백: 8px 단위 / 섹션 간 120 / 요소 간 24 (1:5)
|
||||
- 6축 밖의 관측: (있으면) 카피 규범, 증거 제시 방식, 반복 노출 규칙 등
|
||||
- 변형: 최대폭을 1120으로 축소 — 본문 한 줄이 국문 40자를 넘었기 때문
|
||||
|
||||
## R2 · 톤 — <이름> <원본 URL> [업종: R1과 다름 ✓]
|
||||
- 가져올 축: 컬러 역할, 타입 스케일
|
||||
- 컬러: 배경 1단계 / 텍스트 3단계 / 강조 1색 면적 6%
|
||||
- 타입: 세리프 디스플레이 + 그로테스크 본문, 비율 1.333, 최대/본문 4.2배
|
||||
- 6축 밖의 관측: (있으면) 신뢰를 만드는 방식, 제품을 보여주는 방식
|
||||
- 변형: 비율을 1.25로 조정 — 정보량이 많아 중간 단계가 필요
|
||||
|
||||
## R3 · 디테일 — <이름> <출처 URL>
|
||||
- 가져올 축: 모션 언어 (R3는 이 축만 채운다)
|
||||
- 모션: 스크롤 진입 시 8px 상승 + 페이드, 320ms, ease-out
|
||||
- 구현 제약: 브라우저 지원 / JS 비용 / 보간 조건
|
||||
- 변형: 280ms로 단축 — 페이지가 길어 반복 노출이 잦음
|
||||
|
||||
## 회색조 위계
|
||||
가장 강한 덩어리는 ___, 두 번째 ___, 세 번째 ___.
|
||||
비즈니스 우선순위 [1 ___ / 2 ___ / 3 ___]와 일치한다.
|
||||
|
||||
## 안 할 것
|
||||
- (antipatterns.md에서 이 브리프에 특히 위험한 항목 3개 이상 명시)
|
||||
```
|
||||
|
||||
**검증 조건** — 하나라도 빠지면 되돌아간다.
|
||||
- R1/R2/R3 각각 **원본 사이트 URL**이 있다 (갤러리 URL 아님)
|
||||
- R2의 업종이 R1과 다르다
|
||||
- 각 슬롯에 **가져올 축**이 명시되어 있다
|
||||
- 각 슬롯에 **변형 1개와 그 이유**가 있다
|
||||
- 6축 값에 숫자가 들어 있다 — **§5를 실행했거나, 못 했다면 어느 축이 빈칸인지 명시했다**
|
||||
- R3에 **구현 제약**(지원 범위·JS 비용)이 적혀 있다
|
||||
- 회색조 위계가 비즈니스 우선순위와 일치한다
|
||||
|
||||
> 근거: research/references/02-methodology.md (조사일 2026-08-20)
|
||||
369
packages/skill/references/svg-filters.md
Normal file
369
packages/skill/references/svg-filters.md
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
# svg-filters — 재질(surface)
|
||||
|
||||
4-2 단계에서 읽는다. 결정할 것은 하나다: **이 디자인의 표면은 무엇으로 되어 있는가.**
|
||||
|
||||
SVG 필터는 장식이 아니라 재질을 만드는 도구다. "예뻐 보이니까" 얹는 순간 슬롭이 된다. 종이·유리·필름·잉크 중 무엇인지 **먼저 말로 정하고** 프리미티브를 고른다.
|
||||
|
||||
**필터는 three.js보다 먼저 검토한다.** 같은 인상을 필터로 낼 수 있으면 WebGL을 쓰지 않는다. 유리 질감·노이즈 표면·색 분산·유기적 형태는 대부분 필터로 충분하다. 4-3으로 넘어가는 것은 필터로 안 되는 것이 남았을 때뿐이다.
|
||||
|
||||
## 1. 재질 → 필터 매핑
|
||||
|
||||
이 표가 이 문서의 본체다. 원하는 재질을 찾고 해당 레시피로 간다.
|
||||
|
||||
| 재질 | 조합 | 비용 | 프리셋 |
|
||||
|---|---|---|---|
|
||||
| **필름 (그레인)** | `feTurbulence(fractalNoise)` → data URI 배경 | 상 | **editorial 기본값** · anti-grid 강하게 · swiss-minimal 최약 1개 · **dark-instrument 금지**(어두운 배경에선 노이즈로 보인다) |
|
||||
| **종이 (요철)** | `feTurbulence` → `feDiffuseLighting`+`feDistantLight` | 중 | editorial · anti-grid |
|
||||
| **인쇄물 (리소)** | CSS `mix-blend-mode:multiply` 잉크 + 그레인 + 판 어긋남 | 중 | anti-grid · editorial |
|
||||
| **잉크 (번짐·거친 가장자리)** | `feTurbulence` → `feDisplacementMap` → 블러 → 알파 대비 | 중 | anti-grid · editorial |
|
||||
| **유리 (굴절)** | `feImage` 변위맵 → `feDisplacementMap` + `backdrop-filter` | 중 | dark-instrument(내비 정도) · **swiss-minimal 금지** |
|
||||
| **금속·젤리 (광택)** | `feGaussianBlur(SourceAlpha)` → `feSpecularLighting` → `feComposite arithmetic` | 중 | dark-instrument · anti-grid |
|
||||
| **안개 (글로우)** | `feDropShadow` 체인 / CSS `drop-shadow` | 중 | dark-instrument — 강조 하나에만, 좁게 |
|
||||
| **액체 (융합)** | `feGaussianBlur` → `feColorMatrix` 알파 대비 | 중 | anti-grid |
|
||||
| **색조 (듀오톤)** | `feColorMatrix` → `feComponentTransfer` | 상 | editorial · anti-grid · dark-instrument |
|
||||
| **렌즈 분산 (수차)** | `feOffset` + 채널 분리 + `feBlend screen` | 중 | anti-grid(호버) |
|
||||
|
||||
**프리셋 한 줄 규칙** — `editorial`: 그레인이 기본 재질, 유리·네온은 어울리지 않는다 / `swiss-minimal`: 표면은 평평한 종이, 재질을 거의 안 쓴다, 글래스모피즘 금지 / `anti-grid`: 거친 재질(그레인·리소·잉크·수차), 매끄러운 유리는 반대편 / `dark-instrument`: 재질을 거의 안 쓴다, 좁은 글로우까지.
|
||||
|
||||
## 2. 공통 뼈대
|
||||
|
||||
필터 정의는 문서에 한 번만 넣는다.
|
||||
|
||||
```html
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
<defs><!-- filter들 --></defs>
|
||||
</svg>
|
||||
```
|
||||
|
||||
**텍스트는 필터 밖에 둔다.** 표면 레이어(`position:absolute; inset:0; filter:url(#x)`)와 콘텐츠 레이어(`position:relative`, 필터 없음)를 형제로 분리해라. 예외는 잉크·수차처럼 텍스트 자체가 대상인 경우뿐이다.
|
||||
|
||||
## 3. 레시피
|
||||
|
||||
### R1. 필름 그레인 — 가장 많이 쓴다
|
||||
|
||||
표면에 아날로그 입자를 얹는다. **요소에 `filter`를 걸지 말고 data URI 배경으로 넣어라.** 배경은 한 번만 래스터화되고 캐시된다.
|
||||
|
||||
```css
|
||||
.grainy { position: relative; isolation: isolate; }
|
||||
.grainy::after {
|
||||
content: ''; position: absolute; inset: 0; pointer-events: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: overlay;
|
||||
opacity: var(--grain, 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
**강도 3단계 — 이 값에서 시작해라. 그레인 과다는 가장 흔한 실패다.**
|
||||
- `--grain: 0.03` 거의 안 보인다(밴딩만 사라짐) — swiss-minimal, 넓은 그라디언트
|
||||
- `--grain: 0.06` 가까이서 보면 있다 — **editorial 기본값**
|
||||
- `--grain: 0.14` 명백히 질감이다 — anti-grid
|
||||
- `0.25` 이상은 **과하다. 슬롭 신호다.** 쓰지 마라
|
||||
|
||||
**조절**: `baseFrequency` 0.6(굵은 입자)~0.95(미세), 기본 0.8 · 밝은 배경엔 `mix-blend-mode: multiply` + opacity 절반
|
||||
**비용: 상**
|
||||
|
||||
### R2. 종이 질감
|
||||
|
||||
노이즈를 높이맵 삼아 조명한다. 종이 섬유의 요철이 나온다.
|
||||
|
||||
```html
|
||||
<filter id="paper" x="0%" y="0%" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.04" numOctaves="5" seed="3" result="noise"/>
|
||||
<feDiffuseLighting in="noise" lighting-color="#e8e0d0" surfaceScale="2" result="lit">
|
||||
<feDistantLight azimuth="45" elevation="60"/>
|
||||
</feDiffuseLighting>
|
||||
<feComposite in="lit" in2="SourceAlpha" operator="in"/>
|
||||
</filter>
|
||||
```
|
||||
```css
|
||||
.paper-surface { position: absolute; inset: 0; background: #e8e0d0;
|
||||
border-radius: 6px; filter: url(#paper); }
|
||||
```
|
||||
|
||||
**조절**: `baseFrequency` 0.02(카드지)/0.04(표준)/0.06(수제지) · `surfaceScale` 1~3(넘으면 스투코 벽) · `elevation` 낮출수록 대비 강함 · `lighting-color`는 종이 색과 같게
|
||||
**함정**: 마지막 `feComposite operator="in"`을 빼면 필터 영역 전체가 종이로 칠해진다
|
||||
**비용: 중** — 정적 배경 전용. 반복 배경이면 한 번 렌더해 PNG로 구워라
|
||||
|
||||
### R3. 리소그래프 인쇄
|
||||
|
||||
잉크는 CSS 블렌드로, 그레인만 필터로. **판 어긋남(misregistration)이 리소의 정체다.**
|
||||
|
||||
```html
|
||||
<filter id="risoGrain" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="2" stitchTiles="stitch" seed="11" result="n"/>
|
||||
<feColorMatrix in="n" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0.4 0.4 0.4 0 -0.1"/>
|
||||
</filter>
|
||||
```
|
||||
```css
|
||||
.riso { --misreg: 2px; position: relative; background: #f2ece0;
|
||||
overflow: hidden; isolation: isolate; }
|
||||
.riso .ink-a, .riso .ink-b { position: absolute; mix-blend-mode: multiply; }
|
||||
.riso .ink-a { background: #ff4f39; transform: translate(calc(var(--misreg) * -1), -1.5px); }
|
||||
.riso .ink-b { background: #2f6bff; transform: translate(var(--misreg), 1.5px); }
|
||||
.riso::after { content: ''; position: absolute; inset: 0; pointer-events: none;
|
||||
background: #fff; filter: url(#risoGrain); mix-blend-mode: multiply; opacity: .5; }
|
||||
```
|
||||
|
||||
**조절**: `--misreg` 0.5~4px · 잉크 `#FF48B0`/`#0078BF`/`#FFE800`/`#00A95C` · 종이 `#F2ECE0`
|
||||
**함정**: `isolation: isolate`가 없으면 블렌드가 바깥으로 샌다
|
||||
**비용: 중**
|
||||
|
||||
### R4. 듀오톤 / 그라디언트 맵
|
||||
|
||||
잡다한 스톡 이미지를 브랜드 2색으로 통일한다. 사진이 여러 장일 때 효과가 가장 큰 한 수.
|
||||
|
||||
```html
|
||||
<filter id="duotone" color-interpolation-filters="sRGB">
|
||||
<feColorMatrix type="matrix" values="
|
||||
0.2126 0.7152 0.0722 0 0
|
||||
0.2126 0.7152 0.0722 0 0
|
||||
0.2126 0.7152 0.0722 0 0
|
||||
0 0 0 1 0"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncR type="table" tableValues="0.05 0.98"/>
|
||||
<feFuncG type="table" tableValues="0.10 0.35"/>
|
||||
<feFuncB type="table" tableValues="0.35 0.10"/>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
```
|
||||
```css
|
||||
.duo { filter: url(#duotone); }
|
||||
```
|
||||
|
||||
위 값은 그림자 `#0D1A59` → 하이라이트 `#FA591A`. **다른 색으로 바꾸는 공식**: 채널마다 255로 나눠 `tableValues="S/255 H/255"`. 값을 3개 넣으면 트라이톤.
|
||||
|
||||
**조절**: 앞에 `<feComponentTransfer><feFuncR type="linear" slope="1.3" intercept="-0.15"/>…`를 넣어 원본 대비를 올릴 수 있다
|
||||
**함정**: **`color-interpolation-filters="sRGB"`가 없으면 지정한 hex와 다른 색이 나온다.** 여기선 필수
|
||||
**비용: 상**
|
||||
|
||||
### R5. 잉크 번짐 & 거친 가장자리
|
||||
|
||||
딱딱한 사각형을 손으로 찍은 것처럼. 변위 → 블러 → 알파 대비 순서가 잉크가 스며들고 마르는 과정이다.
|
||||
|
||||
```html
|
||||
<!-- 도형·카드용 -->
|
||||
<filter id="rough" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.03" numOctaves="4" seed="12" result="n"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="9" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
|
||||
<!-- 텍스트용 -->
|
||||
<filter id="inkbleed" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="4" seed="7" result="n"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="4"
|
||||
xChannelSelector="R" yChannelSelector="G" result="d"/>
|
||||
<feGaussianBlur in="d" stdDeviation="1.2" result="b"/>
|
||||
<feColorMatrix in="b" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 14 -6"/>
|
||||
</filter>
|
||||
```
|
||||
|
||||
**조절**: `scale` 도형 3/9/14, **텍스트는 4를 넘기지 마라** · `stdDeviation` 0.8~2.5 · 알파 행렬의 `14`를 키우면 마른 잉크
|
||||
**함정**: 채널 셀렉터 기본값이 `A`다 — **반드시 `R`/`G` 명시.** 여러 요소에 같은 필터를 쓰면 전부 똑같이 흔들린다 → `seed`만 다른 필터 3개를 돌려 써라
|
||||
**비용: 중**
|
||||
|
||||
### R6. 유리 굴절
|
||||
|
||||
배경이 실제로 굴절된다. **Chromium 계열에서만 렌더된다** — 폴백을 먼저 쓰고 그 위에 얹는다. 변위맵의 중앙을 128(중립)로 평평하게 두고 가장자리에만 변위를 몰아야 렌즈가 된다.
|
||||
|
||||
```html
|
||||
<filter id="lens" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||
<feImage preserveAspectRatio="none" x="0" y="0" width="220" height="88" result="map"
|
||||
href="data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='88'%3E%3Cdefs%3E%3ClinearGradient id='rx' x1='0' y1='0' x2='1' y2='0'%3E%3Cstop offset='0' stop-color='rgb(0,0,0)'/%3E%3Cstop offset='0.28' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='0.72' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='1' stop-color='rgb(255,0,0)'/%3E%3C/linearGradient%3E%3ClinearGradient id='gy' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='rgb(0,0,0)'/%3E%3Cstop offset='0.28' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='0.72' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='1' stop-color='rgb(0,255,0)'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='220' height='88' rx='44' fill='rgb(128,128,128)'/%3E%3Crect width='220' height='88' rx='44' fill='url(%23rx)' style='mix-blend-mode:screen'/%3E%3Crect width='220' height='88' rx='44' fill='url(%23gy)' style='mix-blend-mode:screen'/%3E%3C/svg%3E"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="map" scale="-60"
|
||||
xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
```
|
||||
```css
|
||||
/* 1단계: 모든 브라우저에서 성립하는 유리 — 이것만으로 완성돼 있어야 한다 */
|
||||
.glass {
|
||||
width: 220px; height: 88px; border-radius: 44px;
|
||||
backdrop-filter: blur(14px) saturate(1.4);
|
||||
background: rgba(255,255,255,.12);
|
||||
border: 1px solid rgba(255,255,255,.28);
|
||||
box-shadow: inset 0 1px 1px rgba(255,255,255,.7), 0 10px 30px rgba(0,0,0,.4);
|
||||
}
|
||||
/* 2단계: 굴절을 지원하면 덮어쓴다 */
|
||||
@supports (backdrop-filter: url(#lens)) {
|
||||
.glass { backdrop-filter: url(#lens) brightness(1.06) saturate(1.25); }
|
||||
}
|
||||
```
|
||||
|
||||
**조절**: `scale` −30(약함)~−90(강함), 부호 반전 시 오목 렌즈 · 그라디언트 stop `0.28`/`0.72`를 `0.15`/`0.85`로 하면 가장자리 집중 · inset 하이라이트가 유리 두께감을 만든다
|
||||
**함정 (중요)**: 맵의 `width`/`height`가 **요소 크기와 정확히 같아야** 한다 → 크기별로 맵을 따로 만들고 크기가 변하는 요소에는 쓰지 마라. 그리고 `@supports (backdrop-filter: url(…))`는 **파싱만 검사하므로 신뢰할 수 없다** — 미지원 엔진이 통과하면 `backdrop-filter`가 통째로 무시된다. 그래서 1단계를 `background`+`box-shadow`만으로도 유리처럼 보이게 설계하는 것이 핵심이다
|
||||
**비용: 중** — 페이지에 1~2개까지. 큰 면적 금지
|
||||
|
||||
### R7. 액체 융합 (구이 / 메타볼)
|
||||
|
||||
도형들이 서로 붙어 흐른다. 블러로 알파를 번지게 하고 대비로 다시 잘라낸다.
|
||||
|
||||
```html
|
||||
<filter id="goo" x="-25%" y="-25%" width="150%" height="150%" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="9" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9" result="goo"/>
|
||||
<feComposite in="SourceGraphic" in2="goo" operator="atop"/>
|
||||
</filter>
|
||||
```
|
||||
```css
|
||||
.goo-group { filter: url(#goo); padding: 24px; display: flex; gap: 6px; align-items: center; }
|
||||
.goo-group > * { width: 56px; height: 56px; border-radius: 50%; background: var(--accent); }
|
||||
```
|
||||
|
||||
**조절**: `stdDeviation` 5(가까워야 붙음)/9(표준)/20(형태 뭉개짐) · 알파 행렬의 `20`을 키우면 경계가 날카롭다(12~30) · 마무리는 `feComposite atop`(모서리 뾰족한 도형) 또는 `feBlend`(원본 색 선명)
|
||||
**함정**: **필터 안에 텍스트를 넣으면 판독 불가능한 덩어리가 된다.** 컨테이너에 여백이 없으면 블롭이 잘린다
|
||||
**비용: 중** — 400×400px 이하
|
||||
|
||||
### R8. 크로마틱 애버레이션 (RGB 분리)
|
||||
|
||||
렌즈 색수차. 호버·전환 순간에만. 상시 적용은 읽기를 방해한다.
|
||||
|
||||
```html
|
||||
<filter id="chromatic" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
|
||||
<feOffset in="SourceGraphic" dx="-3" dy="0" result="rShift"/>
|
||||
<feColorMatrix in="rShift" type="matrix" result="red"
|
||||
values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
|
||||
<feColorMatrix in="SourceGraphic" type="matrix" result="green"
|
||||
values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
|
||||
<feOffset in="SourceGraphic" dx="3" dy="0" result="bShift"/>
|
||||
<feColorMatrix in="bShift" type="matrix" result="blue"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0"/>
|
||||
<feBlend in="red" in2="green" mode="screen" result="rg"/>
|
||||
<feBlend in="rg" in2="blue" mode="screen"/>
|
||||
</filter>
|
||||
```
|
||||
```css
|
||||
.chroma { color: #fff; } /* 흰색 소스여야 분리가 선명하다 */
|
||||
.chroma:hover { filter: url(#chromatic); }
|
||||
```
|
||||
|
||||
**조절**: `dx` ±1(미묘)/±3(표준)/±8(글리치) · `dy`를 함께 주면 대각 분리
|
||||
**함정**: 유채색 소스에서는 탁해진다. 난독증·시각 피로 사용자에게 불리하므로 **본문 텍스트 금지**
|
||||
**비용: 중**
|
||||
|
||||
### R9. 부드러운 글로우
|
||||
|
||||
어두운 배경의 강조. **먼저 CSS로 되는지 확인해라.** 색을 여러 겹 쌓아야 할 때만 SVG로 온다.
|
||||
|
||||
```css
|
||||
/* 한 가지 색이면 이걸로 충분하다 — 훨씬 싸다 */
|
||||
.glow-cheap { filter: drop-shadow(0 0 6px var(--accent)) drop-shadow(0 0 16px var(--accent)); }
|
||||
```
|
||||
```html
|
||||
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="2" flood-color="#22d3ee" flood-opacity="1" result="s1"/>
|
||||
<feDropShadow in="s1" dx="0" dy="0" stdDeviation="6" flood-color="#7c3aed" flood-opacity="0.9" result="s2"/>
|
||||
<feDropShadow in="s2" dx="0" dy="0" stdDeviation="14" flood-color="#ec4899" flood-opacity="0.7"/>
|
||||
</filter>
|
||||
```
|
||||
|
||||
**조절**: `stdDeviation` 2/6/14 — 심지·중간·확산, 이 비율을 유지해라 · 바깥 레이어일수록 `flood-opacity`를 낮춘다
|
||||
**함정**: 큰 `stdDeviation`(50+)은 매우 비싸다. **넓은 소프트 글로우는 `radial-gradient` 배경이 압도적으로 싸다**
|
||||
**비용: 중** — dark-instrument에서는 강조 하나에만, 좁게
|
||||
|
||||
## 4. CSS로 되는 것은 CSS로
|
||||
|
||||
값싼 쪽을 먼저 쓴다. SVG 필터는 CSS로 표현 **불가능한** 것에만.
|
||||
|
||||
| 원하는 것 | 쓸 것 |
|
||||
|---|---|
|
||||
| 블러 · 밝기 · 대비 · 채도 · 색상 회전 · 단색 그림자 | **CSS `filter:` 함수.** 더 빠르고 잘 가속되며 Safari에서 안전하다 |
|
||||
| 반투명 배경 흐리기 | **CSS `backdrop-filter: blur()`** |
|
||||
| 스캔라인 · 줄무늬 · 격자 | **CSS `repeating-linear-gradient`.** 필터 불필요 |
|
||||
| 넓은 소프트 글로우 · 비네트 | **CSS `radial-gradient` / `box-shadow`.** 큰 블러보다 훨씬 싸다 |
|
||||
| 잉크 겹침 · 색 곱하기 | **CSS `mix-blend-mode`** |
|
||||
| 노이즈 · 절차적 텍스처 | SVG 필터 — 단, **data URI 배경으로** |
|
||||
| 변위 · 굴절 · 왜곡 / 채널 분리 · 알파 임계 · 톤커브 / 조명 · 범프맵 | SVG 필터. CSS에 대응물이 없다 |
|
||||
|
||||
## 5. 함정
|
||||
|
||||
| 증상 | 고치는 법 |
|
||||
|---|---|
|
||||
| 효과가 사각형으로 잘림 | 필터 영역 기본값은 사방 10%뿐 → `<filter x="-30%" y="-30%" width="160%" height="160%">` |
|
||||
| 색이 물빠진 듯 밝고 채도가 낮음 / 듀오톤 hex가 안 맞음 | 필터 기본 연산이 linearRGB → `<filter color-interpolation-filters="sRGB">` 명시 |
|
||||
| 변위가 엉뚱한 방향 | `xChannelSelector`/`yChannelSelector` 기본값이 `A` → `R`/`G` 명시 |
|
||||
| `stdDeviation="10"`인데 화면이 사라짐 | `primitiveUnits="objectBoundingBox"`면 값이 0~1 스케일. 기본값 `userSpaceOnUse`를 유지하거나 전 수치 재계산 |
|
||||
| `feFlood`/조명이 사각형으로 넘침 | 뒤에 `feComposite operator="in" in2="SourceAlpha"` 추가 |
|
||||
| 색이 전부 흰색으로 날아감 / 분기 배선이 무시됨 | `feColorMatrix` 값은 0~1 스케일(255로 나눠라) · `in` 생략 시 직전 출력이 들어가므로 분기 양쪽 모두 `in` 명시 |
|
||||
| 노이즈에 격자 사각형 | `seed` 514/1977/2337/4777/8032/9615 등에서 재현되는 스펙 버그 → 1~50 중 눈으로 확인한 값 고정 |
|
||||
| 요소 안 텍스트가 뭉개짐 | 표면/콘텐츠 레이어 분리(§2) |
|
||||
| 모달이 화면 밖으로 못 나감 | `filter`는 fixed/absolute 자손의 **컨테이닝 블록**을 만든다 → 필터는 표면 레이어에만, 오버레이는 `<body>` 직속 |
|
||||
| Safari에서만 안 보임 | `backdrop-filter: url()` 미지원 · `feImage` 외부 파일 참조 미지원 · 대형 요소 렌더 실패 → 폴백 설계 + `feImage`는 **data URI만** |
|
||||
| 모바일에서만 느림 | 일부 안드로이드 GPU는 필터 가속 불가 → 저사양 감지로 끄기(§7) |
|
||||
|
||||
**쓰지 말 것**: `edgeMode`(Safari 전용) · `kernelUnitLength`(엔진별 해석 상이) · `BackgroundImage`·`FillPaint`·`StrokePaint`(사실상 미구현).
|
||||
|
||||
## 6. 성능
|
||||
|
||||
**비용 = 필터 영역 픽셀 수 × 프리미티브 비용 × 재계산 빈도.** 세 항이 곱해진다.
|
||||
|
||||
- 거의 공짜: `feOffset` `feFlood` `feMerge` `feTile` / 싸다: `feColorMatrix` `feComponentTransfer` `feBlend` `feComposite`
|
||||
- 보통: `feGaussianBlur` `feDropShadow`(반경 비례) / 비싸다: `feMorphology` `feDisplacementMap`
|
||||
- **매우 비싸다: `feTurbulence` `feConvolveMatrix` `feDiffuseLighting` `feSpecularLighting`**
|
||||
|
||||
`feTurbulence`는 하드웨어 가속에 친화적이지 않고 큰 면적에서 CPU를 포화시킨다. **`numOctaves`는 4를 넘기지 마라** — 그 이상은 시각적 이득 없이 비용만 는다.
|
||||
|
||||
**필터 영역이 곱해진다**: 120%(기본)=1.44배 · 160%=2.56배 · 180%=3.24배. 그레인처럼 확장이 불필요한 필터는 `x="0%" y="0%" width="100%" height="100%"`로 줄여라.
|
||||
|
||||
### 필터를 애니메이션하지 마라
|
||||
|
||||
필터 값이 바뀌면 매 프레임 전체가 재계산된다. **SKILL.md 하드 게이트 8(`transform`/`opacity` 외 애니메이션 금지)에도 걸린다.** 필터는 정적으로 쓴다.
|
||||
|
||||
| 하고 싶은 것 | 대신 할 것 |
|
||||
|---|---|
|
||||
| 그레인이 지글거리게 | 안 한다. 정적 그레인으로 충분하다 |
|
||||
| 노이즈가 흐르게 | CSS `background-position` 이동 또는 포기 |
|
||||
| 호버 시 재질 세기 변화 | 필터를 건 **오버레이 레이어의 `opacity`**를 바꿔라. `filter` 값을 트랜지션하지 마라 |
|
||||
| 호버 시 수차 켜기 | 상태 전환(on/off)으로만. 값 보간 금지 |
|
||||
| 필터 걸린 요소를 이동 | 바깥 래퍼를 `transform`으로 움직이고 안쪽이 필터를 갖게 분리 |
|
||||
|
||||
**그 외** — `will-change: filter` 금지(레이어만 점유하고 필터 연산은 그대로다). 큰 면적 `backdrop-filter`는 실기기에서 **FPS 15~30% 하락**하므로 뷰포트 전체에 깔지 말고 칩·툴바·모달 헤더까지만. 종이 질감·리소 그레인처럼 변하지 않는 텍스처는 **PNG/WebP로 구워라**(런타임 비용 0). 검증은 **CPU throttling 4×**에서 한다.
|
||||
|
||||
## 7. 접근성 · 폴백
|
||||
|
||||
**규칙: 필터를 전부 끈 상태에서 디자인이 성립해야 한다.** 4단계 통과 조건이다. 감사용으로 `*, *::before, *::after { filter: none !important; backdrop-filter: none !important; }`를 주입해 확인하고, 무너지면 4-1로 돌아간다.
|
||||
|
||||
**prefers-reduced-motion** — 정적 필터는 모션이 아니므로 끌 필요 없다. 움직이는 것만 처리한다. SMIL(`<animate>`)은 CSS 미디어쿼리로 못 끄니 **애초에 쓰지 마라**(§6).
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) { .goo-group { filter: none; } }
|
||||
```
|
||||
|
||||
**저사양 감지**
|
||||
```js
|
||||
function reduceEffects() {
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return true;
|
||||
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency <= 4) return true;
|
||||
if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory <= 4) return true; // Chromium 전용
|
||||
if (navigator.connection && navigator.connection.saveData) return true;
|
||||
return matchMedia('(max-width: 640px)').matches;
|
||||
}
|
||||
if (reduceEffects()) document.documentElement.classList.add('reduce-effects');
|
||||
```
|
||||
```css
|
||||
.reduce-effects .paper-surface { filter: none; background: #e8e0d0; }
|
||||
.reduce-effects .glass { backdrop-filter: blur(10px) saturate(1.4); }
|
||||
.reduce-effects .grainy::after { opacity: .03; }
|
||||
```
|
||||
|
||||
**필터가 실패하면 무엇이 보이나**
|
||||
- 존재하지 않는 필터를 참조하면 현행 스펙상 **필터만 무시되고 요소는 정상 렌더**된다. 다만 구 엔진에서는 요소가 사라질 수 있다 — **ID 오타는 치명적**
|
||||
- `backdrop-filter: url()` 미지원 엔진은 해당 선언 전체를 무시한다 → 배경과 테두리만 남는다. **그 상태가 이미 유리처럼 보여야 한다**
|
||||
- 텍스트에 건 필터는 DOM을 바꾸지 않는다(선택·검색·스크린리더 유지). 단 듀오톤은 **대비를 낮추므로** 텍스트 위라면 렌더 결과로 대비비를 다시 측정해라
|
||||
|
||||
## 8. 채택 체크리스트
|
||||
|
||||
5단계 프리플라이트에서 그대로 돌린다. 하나라도 실패하면 4-2로 복귀.
|
||||
|
||||
- [ ] 이 필터가 만드는 **재질을 한 단어로 말할 수 있다**(종이/유리/필름/잉크/금속). 못 하면 장식이다 — 뺀다
|
||||
- [ ] 프리셋과 재질이 어긋나지 않는다 (§1)
|
||||
- [ ] 같은 인상을 **CSS로 낼 수 없음**을 확인했다 (§4). 그리고 필터로 낼 수 있어서 three.js를 쓰지 않았다
|
||||
- [ ] 모든 필터에 `color-interpolation-filters="sRGB"`를 의도적으로 설정했다
|
||||
- [ ] `feDisplacementMap`의 채널 셀렉터를 명시했다
|
||||
- [ ] 필터 영역이 최소 크기이고, 잘리는 곳이 없다. `numOctaves` ≤ 4
|
||||
- [ ] 그레인 opacity ≤ 0.15 (anti-grid 제외)
|
||||
- [ ] `feImage`를 썼다면 data URI다
|
||||
- [ ] **애니메이션되는 필터가 없다**(하드 게이트 8). `will-change: filter`도 없다
|
||||
- [ ] `backdrop-filter`가 뷰포트 전체를 덮지 않는다
|
||||
- [ ] 필터를 전부 끈 상태에서 페이지가 완성돼 있다
|
||||
- [ ] 필터 건 요소 안에 본문 텍스트나 `position: fixed` 자손이 없다
|
||||
- [ ] Safari·모바일 실기기, CPU throttling 4×에서 확인했다
|
||||
|
||||
> 근거: research/svg/01~03 (조사일 2026-08-20)
|
||||
598
packages/skill/references/three.md
Normal file
598
packages/skill/references/three.md
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
# three — 입체와 공간
|
||||
|
||||
4-3에서 읽는다. **레이아웃과 재질이 끝난 뒤에 온다.** three는 세 번째 도구지 첫 번째 아이디어가 아니다.
|
||||
|
||||
## 이 문서를 읽는 법
|
||||
|
||||
전부 읽지 마라. 필요한 절만 열어라.
|
||||
|
||||
| 상황 | 읽을 곳 |
|
||||
|---|---|
|
||||
| **three를 쓸지 아직 안 정했다** | **§0만.** 대부분 여기서 "안 쓴다"로 끝나고, **그게 가장 흔한 정답이다** |
|
||||
| 쓰기로 했다 | §1 버전 핀 + §2 함정 + **§3 코드 A(지연 로드)** — 이 셋이 최소 필수 |
|
||||
| 배경 이펙트가 필요하다 | + §4 코드 B (메시 그라디언트) |
|
||||
| 모바일·저사양을 만난다 | + §5 코드 C (품질 티어) + §8 성능 |
|
||||
| 이미지 그리드를 강화한다 | + §6 코드 D |
|
||||
| 다른 패턴을 찾는다 | §7 표에서 고르고 `research/three/02-patterns.md`를 열어라 |
|
||||
| 감사 직전 | §9 Lighthouse + §10 폴백 + §11 체크리스트 |
|
||||
|
||||
§0을 통과하지 못하면 나머지는 읽을 필요가 없다. **"안 쓴다"는 실패가 아니라 이 스킬의 규칙이다.**
|
||||
|
||||
---
|
||||
|
||||
## 0. 채택 게이트 — "쓰지 않는다"를 먼저 통과시켜라
|
||||
|
||||
| 원하는 것 | 먼저 시도 | three가 필요한 순간 |
|
||||
|---|---|---|
|
||||
| 유동적 그라디언트 배경 | `radial-gradient` 겹치기 + `filter: blur()` | 마우스/스크롤에 유기적으로 반응해야 |
|
||||
| 유리·굴절 | `backdrop-filter` + SVG `feDisplacementMap` | 뒤 오브젝트가 3D로 왜곡돼야 |
|
||||
| 그레인·노이즈 | SVG `feTurbulence` | 노이즈가 시간에 따라 흘러야 |
|
||||
| 이미지 왜곡 | `clip-path` + `transform` | 픽셀 단위 변형·색수차가 필요 |
|
||||
| 떠다니는 입자 | CSS 애니메이션 20개 | 1,000개 이상이거나 서로 반응해야 |
|
||||
| 제품 회전 | 스프라이트 시퀀스(36장 WebP) | 사용자가 자유롭게 돌려야 |
|
||||
|
||||
**왼쪽 칸으로 되면 왼쪽이 정답이다.** 4-2에서 SVG 필터를 이미 썼다면 대부분 여기서 끝난다.
|
||||
|
||||
### 무거운 씬 임포트보다 셰이더 플레인 하나
|
||||
|
||||
| 방식 | 비용(gzip) | 언제 |
|
||||
|---|---|---|
|
||||
| **셰이더 플레인 1장** (§4) | **~95KB** | 기본값. 배경·재질·분위기 |
|
||||
| GLTF 모델 + 환경광 | ~180KB + 모델 | 제품이 주인공일 때만 |
|
||||
| Spline / 씬 임포트 | **800KB ~ 2MB** | 예산을 즉시 깬다. 사실상 금지 |
|
||||
|
||||
**"3D를 넣자"가 아니라 "이 인상에 필요한 최소 GPU 작업은 무엇인가"를 물어라.**
|
||||
|
||||
### 채택 조건 — 넷 다 만족해야 한다
|
||||
|
||||
1. CSS/SVG로 같은 인상이 안 나온다
|
||||
2. 추가분이 **+200KB(gzip) 이내**다 (데모·포트폴리오 브리프면 +600KB, 단 올렸다고 명시)
|
||||
3. **폴백을 같이 만든다.** 폴백 없이 채택하지 않는다
|
||||
4. 2단계 한 문장 컨셉을 **강화**한다. "있으면 멋있어서"는 이유가 아니다
|
||||
|
||||
---
|
||||
|
||||
## 1. 버전 핀 — 지금 안전한 조합
|
||||
|
||||
```json
|
||||
{ "three": "0.185.1", "@react-three/fiber": "^9.7.0", "@react-three/drei": "^10.7.8",
|
||||
"@react-three/postprocessing": "^3.0.5", "postprocessing": "^6.39.4",
|
||||
"overrides": { "three": "0.185.1" } }
|
||||
```
|
||||
|
||||
| 경고 | 내용 |
|
||||
|---|---|
|
||||
| **three 0.186 금지** | `postprocessing`의 peer가 `three >=0.168.0 <0.186.0`. 올리면 포스트프로세싱이 깨진다 → **0.185.1에 핀** |
|
||||
| **R3F v9 = React 19 전용** | peer `react >=19 <19.3`. 19.3+는 아직 지원 없음(v10 alpha) |
|
||||
| **three 중복 설치 금지** | 두 벌이면 `instanceof` 검사가 전부 깨진다. `overrides`로 단일화 |
|
||||
| **바닐라 우선** | 페이지가 React가 아니면 R3F를 쓰지 마라. React+R3F만 +75KB다 |
|
||||
| **drei는 named import** | 배럴 임포트는 +100KB다 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 오늘 사람 잡는 함정
|
||||
|
||||
| 함정 | 증상 | 고치는 법 |
|
||||
|---|---|---|
|
||||
| **sRGB 자동 지정 제거** [R3F v9] | 이미지가 어둡고 채도가 죽는다 | 컬러 텍스처에 `tex.colorSpace = THREE.SRGBColorSpace`. 노멀/러프니스맵엔 **지정 금지** |
|
||||
| **`Clock` deprecated** [r183+] | 곧 제거됨 | `renderer.setAnimationLoop((t) => …)`의 `t`(ms)를 쓴다 |
|
||||
| **`RGBELoader` 개명** [r180+] | import 실패 | `import { HDRLoader } from 'three/addons/loaders/HDRLoader.js'` |
|
||||
| **ACES 톤매핑 기본값** | 플랫 그라디언트가 뿌옇게 죽는다 | 2D 셰이더면 `toneMapping = THREE.NoToneMapping` (R3F는 `<Canvas flat>`) |
|
||||
| **셰이더 색이 틀림** | hex를 `vec3`에 하드코딩 | 셰이더 안 `vec3`는 **linear 값**이다. 색은 JS에서 `THREE.Color`로 넘겨라 |
|
||||
| **WebGL1 미지원** [r163+] | 구형 기기 백지 | `webgl2` 컨텍스트로 게이팅 (§3) |
|
||||
| **drei `Environment preset`** | 외부 CDN에서 HDRI를 받는다 | `files="/hdri/studio_1k.hdr"` 자체 호스팅 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 코드 A — 지연 로드 부트스트랩 (**이 문서에서 가장 중요**)
|
||||
|
||||
**언제**: three를 쓰기로 한 모든 경우. 예외 없다. three를 초기 번들에서 빼는 유일한 방법이다.
|
||||
|
||||
```js
|
||||
// src/gl/boot.js — 계약: 모든 씬은 default export 로 mount(canvas, opts) => disposeFn 을 노출한다
|
||||
export async function boot(canvas, loader) {
|
||||
if (!canvas || !gate()) { canvas?.remove(); return null } // 폴백 배경이 그대로 남는다
|
||||
await inViewport(canvas) // 진입 전엔 다운로드도 안 한다
|
||||
const { default: mount } = await loader()
|
||||
const css = getComputedStyle(document.documentElement)
|
||||
const dispose = mount(canvas, { css })
|
||||
const dur = css.getPropertyValue('--dur-normal').trim() || '350ms'
|
||||
const ease = css.getPropertyValue('--ease-out').trim() || 'ease'
|
||||
canvas.style.transition = `opacity ${dur} ${ease}`
|
||||
requestAnimationFrame(() => { canvas.style.opacity = '1' })
|
||||
addEventListener('pagehide', dispose, { once: true })
|
||||
return dispose
|
||||
}
|
||||
|
||||
function gate() { // WebGL2 + 사용자 선호 + 하드웨어를 한 번에 판정
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return false
|
||||
if (navigator.connection?.saveData) return false
|
||||
if ((navigator.deviceMemory ?? 8) < 4 || (navigator.hardwareConcurrency ?? 8) < 4) return false
|
||||
try {
|
||||
const gl = document.createElement('canvas').getContext('webgl2')
|
||||
if (!gl) return false
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext() // 프로브 컨텍스트 즉시 반납
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
const inViewport = (el) => new Promise((res) => {
|
||||
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); res() } },
|
||||
{ rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
})
|
||||
```
|
||||
|
||||
```js
|
||||
// src/main.js — three 는 여기 어디에도 import 되지 않는다
|
||||
import { boot } from './gl/boot.js'
|
||||
boot(document.getElementById('gl'), () => import('./gl/scenes/hero.js'))
|
||||
```
|
||||
|
||||
```html
|
||||
<div class="hero__bg" aria-hidden="true"><canvas id="gl" style="opacity:0"></canvas></div>
|
||||
```
|
||||
|
||||
```css
|
||||
.hero__bg {
|
||||
position: absolute; inset: 0; z-index: -1;
|
||||
aspect-ratio: 16 / 9; /* CLS 차단 */
|
||||
background: /* WebGL 실패 시 이것이 최종 결과물이다 */
|
||||
radial-gradient(90% 70% at 20% 0%, var(--accent) 0%, transparent 60%),
|
||||
linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%);
|
||||
}
|
||||
.hero__bg canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
@media (prefers-reduced-motion: reduce) { .hero__bg canvas { display: none; } }
|
||||
```
|
||||
|
||||
**폴백 배경은 셰이더의 정지 프레임과 같은 인상이어야 한다.** 완성 후 `renderer.domElement.toDataURL('image/webp', .85)`로 한 프레임을 캡처해 대조해라.
|
||||
|
||||
### 공용 Stage — 모든 씬이 얹히는 최소 컨테이너
|
||||
|
||||
```js
|
||||
// src/gl/stage.js
|
||||
import * as THREE from 'three'
|
||||
|
||||
export function createStage(canvas, { dprMax = 1.5, alpha = false } = {}) {
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, alpha, antialias: false, stencil: false,
|
||||
powerPreference: 'high-performance' })
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.toneMapping = THREE.NoToneMapping // 플랫 2D 셰이더 기준
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.z = 5
|
||||
const size = { w: 0, h: 0, dpr: 1 }, updaters = new Set()
|
||||
let running = false, last = 0, t0 = performance.now()
|
||||
|
||||
const draw = (t, dt) => { for (const f of updaters) f(t, dt); renderer.render(scene, stage.camera) }
|
||||
const tick = (now) => { const dt = Math.min((now - last) / 1000, 1 / 20); last = now; draw((now - t0) / 1000, dt) }
|
||||
const start = () => { if (!running) { running = true; last = performance.now(); renderer.setAnimationLoop(tick) } }
|
||||
const stop = () => { running = false; renderer.setAnimationLoop(null) }
|
||||
|
||||
function resize() {
|
||||
const r = canvas.getBoundingClientRect()
|
||||
const w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height))
|
||||
const dpr = Math.min(devicePixelRatio || 1, dprMax)
|
||||
if (w === size.w && h === size.h && dpr === size.dpr) return
|
||||
Object.assign(size, { w, h, dpr })
|
||||
renderer.setPixelRatio(dpr); renderer.setSize(w, h, false)
|
||||
stage.camera.aspect = w / h; stage.camera.updateProjectionMatrix()
|
||||
stage.onResize?.(w, h, dpr)
|
||||
if (!running) draw(0, 0)
|
||||
}
|
||||
const ro = new ResizeObserver(resize); ro.observe(canvas)
|
||||
const io = new IntersectionObserver(([e]) => e.isIntersecting ? start() : stop(), { rootMargin: '10%' })
|
||||
io.observe(canvas)
|
||||
const onVis = () => document.hidden ? stop() : start()
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
const onLost = (e) => { e.preventDefault(); stop() } // 없으면 복구 이벤트가 안 온다
|
||||
canvas.addEventListener('webglcontextlost', onLost)
|
||||
|
||||
const stage = { renderer, scene, camera, size, start, stop, renderOnce: () => draw(0, 0),
|
||||
add(f) { updaters.add(f); return () => updaters.delete(f) },
|
||||
setDprMax(v) { dprMax = v; size.dpr = -1; resize() },
|
||||
dispose() {
|
||||
stop(); ro.disconnect(); io.disconnect()
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
canvas.removeEventListener('webglcontextlost', onLost)
|
||||
scene.traverse((o) => {
|
||||
o.geometry?.dispose()
|
||||
for (const m of [o.material].flat().filter(Boolean)) {
|
||||
for (const u of Object.values(m.uniforms ?? {})) if (u.value?.isTexture) u.value.dispose()
|
||||
m.dispose()
|
||||
}
|
||||
})
|
||||
scene.clear(); renderer.dispose(); renderer.forceContextLoss(); updaters.clear()
|
||||
} }
|
||||
resize()
|
||||
return stage
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 코드 B — 메시 그라디언트 셰이더 플레인
|
||||
|
||||
**언제**: 랜딩 히어로 배경의 기본 무기. three를 쓰기로 했다면 90%는 이것으로 끝난다. 드로우콜 1개, 텍스처 0장.
|
||||
**색과 속도는 3단계 토큰에서 읽는다. 셰이더에 hex를 쓰지 마라** (하드 게이트 #1).
|
||||
|
||||
```js
|
||||
// src/gl/scenes/hero.js
|
||||
import * as THREE from 'three'
|
||||
import { createStage } from '../stage.js'
|
||||
import { resolveQuality, watchdog } from '../quality.js'
|
||||
import frag from '../shaders/hero.frag'
|
||||
|
||||
const VERT = /* glsl */`void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }` // 이미 클립 공간
|
||||
|
||||
export default function mount(canvas, { css }) {
|
||||
const q = resolveQuality()
|
||||
const stage = createStage(canvas, { dprMax: q.dpr })
|
||||
|
||||
const color = (n) => { // 하드코딩 폴백을 두지 마라
|
||||
const v = css.getPropertyValue(n).trim()
|
||||
if (!v) throw new Error(`designpaca: 토큰 ${n} 이 없다. 3단계로 돌아가라`)
|
||||
return new THREE.Color(v) // THREE.Color 는 linear 로 저장된다
|
||||
}
|
||||
const durSlow = parseFloat(css.getPropertyValue('--dur-slow')) || 600 // ms
|
||||
const speed = 1000 / (durSlow * 20) // 셰이더도 같은 시간 문법: 1주기 = --dur-slow × 20
|
||||
|
||||
// 풀스크린 트라이앵글. 플레인(2 tri)보다 싸고 대각선 이음매가 없다
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position',
|
||||
new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3))
|
||||
|
||||
const u = {
|
||||
uTime: { value: 0 }, uSpeed: { value: speed },
|
||||
uResolution: { value: new THREE.Vector2(1, 1) },
|
||||
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
uSurface: { value: color('--surface') },
|
||||
uRaised: { value: color('--surface-raised') },
|
||||
uAccent: { value: color('--accent') },
|
||||
}
|
||||
const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
|
||||
vertexShader: VERT, fragmentShader: frag, uniforms: u,
|
||||
defines: { OCTAVES: q.octaves }, depthTest: false, depthWrite: false,
|
||||
}))
|
||||
mesh.frustumCulled = false
|
||||
stage.scene.add(mesh)
|
||||
|
||||
stage.onResize = (w, h, dpr) => u.uResolution.value.set(w * dpr, h * dpr)
|
||||
stage.onResize(stage.size.w, stage.size.h, stage.size.dpr)
|
||||
|
||||
const target = new THREE.Vector2(0.5, 0.5)
|
||||
const onMove = (e) => target.set(e.clientX / innerWidth, 1 - e.clientY / innerHeight)
|
||||
addEventListener('pointermove', onMove, { passive: true })
|
||||
|
||||
const guard = watchdog(q, stage)
|
||||
stage.add((t, dt) => {
|
||||
u.uTime.value = t
|
||||
u.uPointer.value.lerp(target, 1 - Math.pow(0.002, dt)) // 프레임레이트 독립 보간
|
||||
guard(dt)
|
||||
})
|
||||
stage.start()
|
||||
return () => { removeEventListener('pointermove', onMove); stage.dispose() }
|
||||
}
|
||||
```
|
||||
|
||||
```glsl
|
||||
/* src/gl/shaders/hero.frag */
|
||||
precision mediump float;
|
||||
uniform float uTime, uSpeed;
|
||||
uniform vec2 uResolution, uPointer;
|
||||
uniform vec3 uSurface, uRaised, uAccent;
|
||||
#ifndef OCTAVES
|
||||
#define OCTAVES 3
|
||||
#endif
|
||||
|
||||
float hash(vec2 p) { p = fract(p * vec2(233.34, 851.73)); p += dot(p, p + 23.45); return fract(p.x * p.y); }
|
||||
float vnoise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
|
||||
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
float fbm(vec2 p) {
|
||||
float s = 0.0, a = 0.5;
|
||||
for (int i = 0; i < OCTAVES; i++) { s += vnoise(p) * a; p *= 2.03; a *= 0.5; }
|
||||
return s;
|
||||
}
|
||||
|
||||
void main() {
|
||||
/* 짧은 축 기준 정규화 — 안 하면 노이즈가 가로로 늘어진다 */
|
||||
vec2 p = (gl_FragCoord.xy * 2.0 - uResolution) / min(uResolution.x, uResolution.y);
|
||||
float t = uTime * uSpeed;
|
||||
|
||||
/* 도메인 워프: 노이즈로 좌표를 흔든 뒤 다시 노이즈 = "유기적"의 정체 */
|
||||
float w = fbm(p * 1.1 + t);
|
||||
float field = fbm(p * 1.7 + w * 0.9 + vec2(0.0, t * 1.3));
|
||||
|
||||
vec2 pc = (uPointer * 2.0 - 1.0) * vec2(uResolution.x / uResolution.y, 1.0);
|
||||
field += exp(-dot(p - pc, p - pc) * 1.6) * 0.28; /* 포인터 근처를 부풀린다 */
|
||||
|
||||
vec3 col = mix(uSurface, uRaised, smoothstep(0.18, 0.62, field));
|
||||
col = mix(col, uAccent, smoothstep(0.55, 0.95, field));
|
||||
col *= 1.0 - dot(p, p) * 0.16; /* 비네트 */
|
||||
col += (hash(gl_FragCoord.xy) - 0.5) / 255.0; /* 밴딩 제거. 사실상 필수 */
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
```
|
||||
|
||||
**조절**: `uSpeed`(0.03~0.15, 낮을수록 고급) · `OCTAVES`(2~4) · `p * 1.1`의 배율(작을수록 큰 덩어리) · 비네트 0.16.
|
||||
`#include <…>`는 `ShaderMaterial`에서만 동작한다. `RawShaderMaterial`은 컴파일 에러다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 코드 C — 품질 티어 + 런타임 강등
|
||||
|
||||
**언제**: three를 쓰는 모든 씬. 한 번 판정해 씬 전체 설정을 결정한다. 컴포넌트마다 각자 판단하면 조합이 폭발한다.
|
||||
|
||||
```js
|
||||
// src/gl/quality.js
|
||||
const TIERS = {
|
||||
low: { tier: 'low', dpr: 1, octaves: 2, particles: 4000, post: false, targetFps: 30 },
|
||||
mid: { tier: 'mid', dpr: 1.5, octaves: 3, particles: 15000, post: 'minimal', targetFps: 60 },
|
||||
high: { tier: 'high', dpr: 2, octaves: 4, particles: 40000, post: 'full', targetFps: 60 },
|
||||
}
|
||||
const ORDER = ['high', 'mid', 'low']
|
||||
|
||||
/** 동기 판정. detect-gpu(+12KB)는 GLTF 모델 씬에서만 추가로 쓴다 */
|
||||
export function resolveQuality() {
|
||||
const cached = sessionStorage.getItem('gl-tier')
|
||||
if (cached && TIERS[cached]) return TIERS[cached]
|
||||
const mobile = matchMedia('(max-width: 768px)').matches
|
||||
const mem = navigator.deviceMemory ?? 8, cores = navigator.hardwareConcurrency ?? 8
|
||||
const t = (mobile || mem < 6 || cores < 6) ? 'low'
|
||||
: (mem >= 8 && cores >= 8) ? 'high' : 'mid'
|
||||
sessionStorage.setItem('gl-tier', t)
|
||||
return TIERS[t]
|
||||
}
|
||||
|
||||
/** 4초 창에서 목표 fps의 75%를 30% 넘게 놓치면 한 단계 강등. low 에서도 실패하면 캔버스를 버린다 */
|
||||
export function watchdog(quality, stage) {
|
||||
let cur = quality, elapsed = 0, bad = 0, total = 0
|
||||
return function update(dt) {
|
||||
elapsed += dt; total++
|
||||
if (1 / dt < cur.targetFps * 0.75) bad++
|
||||
if (elapsed < 4) return
|
||||
const failing = bad / total > 0.3
|
||||
elapsed = 0; bad = 0; total = 0
|
||||
if (!failing) return
|
||||
const next = ORDER[ORDER.indexOf(cur.tier) + 1]
|
||||
if (!next) { // 더 내릴 곳이 없다 → CSS 폴백만 남긴다
|
||||
sessionStorage.setItem('gl-tier', 'low')
|
||||
stage.renderer.domElement.remove(); stage.dispose(); return
|
||||
}
|
||||
cur = TIERS[next]
|
||||
sessionStorage.setItem('gl-tier', next)
|
||||
stage.setDprMax(cur.dpr) // 가장 효과가 큰 레버를 먼저 당긴다
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 코드 D — DOM 동기화 이미지 hover 왜곡
|
||||
|
||||
**언제**: 이미지 그리드·포트폴리오. **폴백이 공짜인 유일한 패턴**이다 — 실패하면 원래 `<img>`가 그대로 남는다.
|
||||
장식을 얹는 게 아니라 이미 있는 콘텐츠를 강화하므로 "레이아웃 → 재질 → 입체" 순서와도 맞는다.
|
||||
캔버스는 `position: fixed; inset: 0; pointer-events: none;` 이어야 한다.
|
||||
|
||||
```js
|
||||
// src/gl/scenes/gallery.js
|
||||
import * as THREE from 'three'
|
||||
import { createStage } from '../stage.js'
|
||||
import { resolveQuality } from '../quality.js'
|
||||
import frag from '../shaders/image.frag'
|
||||
|
||||
const VERT = /* glsl */`
|
||||
uniform float uHover, uVelocity;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec3 p = position;
|
||||
p.y += sin(uv.x * 3.14159265) * uVelocity * 0.14; /* 스크롤 속도로 활처럼 휜다 */
|
||||
p.z += sin(uv.y * 3.14159265) * uHover * 0.08;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||||
}`
|
||||
|
||||
export default function mount(canvas, { selector = 'img[data-gl]' } = {}) {
|
||||
const q = resolveQuality()
|
||||
const stage = createStage(canvas, { dprMax: q.dpr, alpha: true })
|
||||
stage.camera = new THREE.PerspectiveCamera(45, 1, 100, 3000) // CSS 픽셀에 맞춘다
|
||||
stage.camera.position.z = 800 // → 1 world unit = 1 px
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(1, 1, 20, 20)
|
||||
const loader = new THREE.TextureLoader()
|
||||
const items = [], scroll = { cur: scrollY, vel: 0 }
|
||||
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
const u = { uTexture: { value: null }, uCover: { value: new THREE.Vector2(1, 1) },
|
||||
uMouse: { value: new THREE.Vector2(.5, .5) },
|
||||
uHover: { value: 0 }, uVelocity: { value: 0 }, uShift: { value: .006 } }
|
||||
const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({
|
||||
vertexShader: VERT, fragmentShader: frag, uniforms: u, transparent: true }))
|
||||
mesh.visible = false
|
||||
stage.scene.add(mesh)
|
||||
const it = { el, mesh, u, hoverTarget: 0, box: null, ratio: 1 }
|
||||
items.push(it)
|
||||
|
||||
loader.load(el.currentSrc || el.src, (tex) => {
|
||||
tex.colorSpace = THREE.SRGBColorSpace // ← 빠뜨리면 이미지가 어두워진다
|
||||
tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter
|
||||
u.uTexture.value = tex
|
||||
it.ratio = tex.image.width / tex.image.height
|
||||
cover(it); mesh.visible = true
|
||||
el.style.opacity = '0' // 텍스처 도착 후에만 DOM 이미지를 숨긴다
|
||||
})
|
||||
// 캔버스가 pointer-events:none 이므로 이벤트는 DOM 요소에서 받는다
|
||||
el.addEventListener('pointerenter', () => { it.hoverTarget = 1 })
|
||||
el.addEventListener('pointerleave', () => { it.hoverTarget = 0 })
|
||||
el.addEventListener('pointermove', (e) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
u.uMouse.value.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height)
|
||||
})
|
||||
}
|
||||
|
||||
const cover = (it) => { // object-fit: cover 를 JS 에서 계산 → 셰이더가 짧아진다
|
||||
const pr = it.box ? it.box.w / it.box.h : 1
|
||||
it.u.uCover.value.set(Math.min(pr / it.ratio, 1), Math.min(it.ratio / pr, 1))
|
||||
}
|
||||
const measure = () => { // 리플로우를 유발한다. 리사이즈 때만 부른다
|
||||
for (const it of items) {
|
||||
const r = it.el.getBoundingClientRect()
|
||||
it.box = { top: r.top + scrollY, left: r.left + scrollX, w: r.width, h: r.height }
|
||||
it.mesh.scale.set(r.width, r.height, 1); cover(it)
|
||||
}
|
||||
}
|
||||
stage.onResize = (w, h) => {
|
||||
stage.camera.fov = 2 * Math.atan(h / 2 / stage.camera.position.z) * (180 / Math.PI)
|
||||
stage.camera.aspect = w / h; stage.camera.updateProjectionMatrix(); measure()
|
||||
}
|
||||
stage.onResize(stage.size.w, stage.size.h)
|
||||
|
||||
stage.add((t, dt) => {
|
||||
// 스크롤 값은 rAF 에서 직접 읽는다. `scroll` 리스너는 하드 게이트 #10 위반이고,
|
||||
// 어차피 매 프레임 필요한 값이다.
|
||||
const k = 1 - Math.pow(.001, dt), hk = 1 - Math.pow(.0005, dt), prev = scroll.cur
|
||||
scroll.cur += (scrollY - scroll.cur) * k
|
||||
scroll.vel = THREE.MathUtils.clamp((scroll.cur - prev) / Math.max(dt, 1e-4) / 2500, -1, 1)
|
||||
for (const it of items) {
|
||||
if (!it.box) continue
|
||||
it.mesh.position.x = it.box.left - stage.size.w / 2 + it.box.w / 2
|
||||
it.mesh.position.y = -(it.box.top - scroll.cur) + stage.size.h / 2 - it.box.h / 2
|
||||
it.u.uVelocity.value += (scroll.vel - it.u.uVelocity.value) * k
|
||||
it.u.uHover.value += (it.hoverTarget - it.u.uHover.value) * hk
|
||||
it.mesh.visible = !!it.u.uTexture.value &&
|
||||
Math.abs(it.mesh.position.y) < stage.size.h / 2 + it.box.h
|
||||
}
|
||||
})
|
||||
stage.start()
|
||||
return () => {
|
||||
items.forEach((it) => { it.el.style.opacity = '' })
|
||||
geometry.dispose(); stage.dispose()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glsl
|
||||
/* src/gl/shaders/image.frag */
|
||||
precision mediump float;
|
||||
uniform sampler2D uTexture;
|
||||
uniform vec2 uCover, uMouse;
|
||||
uniform float uHover, uVelocity, uShift;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv * uCover + (1.0 - uCover) * 0.5; /* cover */
|
||||
uv = (uv - 0.5) * mix(1.0, 0.94, uHover) + 0.5; /* hover 줌 */
|
||||
vec2 dir = normalize(vUv - uMouse + 1e-5);
|
||||
uv += dir * exp(-distance(vUv, uMouse) * 4.0) * uHover * 0.016; /* 마우스 방향 밀림 */
|
||||
|
||||
float s = uShift * (uHover * 0.6 + abs(uVelocity) * 1.4); /* 색수차 */
|
||||
vec3 col = vec3(texture2D(uTexture, uv + vec2(s, s * 0.35)).r,
|
||||
texture2D(uTexture, uv).g,
|
||||
texture2D(uTexture, uv - vec2(s, s * 0.35)).b);
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
```
|
||||
|
||||
**원본 이미지를 그대로 쓰면 VRAM이 터진다.** 1920×1080 RGBA = 8.3MB/장. 표시 크기 × 2까지만 서버에서 리사이즈해라.
|
||||
|
||||
---
|
||||
|
||||
## 7. 패턴 카탈로그 — 코드는 `research/three/02-patterns.md`에
|
||||
|
||||
| 효과 | 언제 | 비용 | 문서 |
|
||||
|---|---|---|---|
|
||||
| SDF 도트 그리드 + 마우스 트레일 | 텍스트가 위에 올라가는 배경. 가독성을 안 해친다 | +0KB · 낮음 | P2 |
|
||||
| 파티클 필드 (Points) | 먼지·별 레이어. 5만 개 이하 | +0KB · 중간 | P3 |
|
||||
| GPGPU 플로우필드 파티클 | 10만 개 이상, 유체 같은 흐름 | +0KB · 높음 | P4 |
|
||||
| InstancedMesh 그리드 웨이브 | 입체 격자 인터랙션. 드로우콜 1 | +0KB · 중간 | P5 |
|
||||
| 스크롤 카메라 리그 (Lenis+ScrollTrigger) | 스크롤텔링. WebGL이 페이지를 지배할 때 | **+45KB** · 낮음 | P6 |
|
||||
| drei ScrollControls | R3F에서 페이지 전체가 캔버스일 때 | +0KB · 낮음 | P7 |
|
||||
| 이미지 전환 (displacement) | 슬라이더·캐러셀 | +0KB · 낮음 | P9 |
|
||||
| 유리·굴절 (MeshTransmissionMaterial) | 중심 오브제. **가장 비싸다** | +0KB · **매우높음** | P10 |
|
||||
| 3D 텍스트 (troika SDF) | WebGL 안의 대형 타이포 | **+55KB** · 낮음 | P11 |
|
||||
| 포스트프로세싱 (bloom/CA/grain) | 발광·필름 룩 | **+32KB** · 중간~높음 | P12 |
|
||||
| GLTF 제품 씬 (DRACO/KTX2) | 제품이 주인공 | **+15KB** + 모델 · 중간 | P13 |
|
||||
| 노이즈 디졸브 리빌 | 등장·전환 | +0KB · 낮음 | P15 |
|
||||
|
||||
**+45KB 이상 항목은 예산 계산에 반드시 넣어라.** 스크롤 리그(GSAP+Lenis)는 **한 줄도 안 썼는데 +200KB 예산의 22%를 먹고 시작한다.** 여기에 troika(+55KB)와 포스트프로세싱(+32KB)을 얹으면 +132KB — 예산의 66%가 라이브러리로만 사라지고 실제 씬에 쓸 몫은 68KB뿐이다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 성능 — 효과 순서대로
|
||||
|
||||
1. **DPR 클램프가 1위다.** `min(devicePixelRatio, 2)` — 3→2면 픽셀 **55% 감소**, 육안 차이 없음. 배경 셰이더는 1.25~1.5까지 내려도 된다
|
||||
2. **안 보일 때 안 그린다.** `visibilitychange` + `IntersectionObserver`. 정적 씬은 온디맨드(R3F `frameloop="demand"`). 배터리 80% 절약
|
||||
3. **VRAM이 모바일의 진짜 한계다.** 4K 텍스처 = 67MB/장. **WebP는 다운로드만 줄이고 VRAM은 그대로**다. 줄이는 유일한 수단은 KTX2(ETC1S 1/8). 컨텍스트 로스의 최대 원인도 VRAM 고갈
|
||||
4. **드로우콜 예산**: 데스크톱 150 / 모바일 60. `renderer.info.render.calls`로 측정. 넘으면 `InstancedMesh`
|
||||
5. **`MeshTransmissionMaterial`은 매 프레임 씬을 재렌더한다.** `resolution`을 기본(풀스크린)으로 두지 마라 — 512 이하, 모바일은 `samples={2} resolution={256}`. 2개 이상이면 `transmissionSampler` 공유
|
||||
6. **투명 파티클은 오버드로우가 병목**이다. 개수보다 `gl_PointSize`를 먼저 줄여라
|
||||
7. **셰이더는 `mediump` 기본.** 모바일에서 ~2배 빠르다. 좌표·GPGPU만 `highp`
|
||||
8. **실시간 그림자는 끈다.** `ContactShadows frames={1}` 또는 baked AO로 대체
|
||||
|
||||
**병목 격리**: 캔버스를 200×200으로 줄여 회복되면 프래그먼트 병목, 그대로면 드로우콜·CPU 병목이다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Lighthouse — 캔버스를 LCP 요소로 만들지 마라
|
||||
|
||||
WebGL 랜딩페이지가 42점에서 98점으로 간 실측 사례의 핵심은 이 한 줄이다.
|
||||
|
||||
| 규칙 | 방법 |
|
||||
|---|---|
|
||||
| **LCP는 DOM 텍스트가 잡는다** | 히어로 `<h1>`이 LCP가 되게 하고 캔버스는 `z-index:-1` 배경 레이어로 |
|
||||
| **CLS 차단** | 캔버스 컨테이너에 `aspect-ratio` 또는 `min-height`를 미리 준다 |
|
||||
| **three는 LCP 이후에 요청된다** | 코드 A의 동적 import + IntersectionObserver. Network 탭에서 순서를 확인해라 |
|
||||
| **셰이더 컴파일 분산** | 머티리얼 10개를 한 프레임에 처음 그리면 100ms 롱태스크다. `renderer.compileAsync(scene, camera)` [r170+] |
|
||||
| **디코더 preload 금지** | DRACO·basis WASM(각 200~250KB)은 실제로 필요할 때만 받게 한다 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 폴백과 접근성
|
||||
|
||||
| 상황 | 대체 |
|
||||
|---|---|
|
||||
| WebGL2 미지원 · 컨텍스트 생성 실패 | 캔버스 제거 → CSS 그라디언트 폴백 (코드 A) |
|
||||
| 저사양(메모리<4GB, 코어<4) · `saveData` | 아예 로드하지 않는다 |
|
||||
| **`prefers-reduced-motion: reduce`** | 캔버스 `display:none` + CSS 폴백. 굳이 띄워야 하면 `renderOnce()` 한 장만 |
|
||||
| 런타임 프레임 저하 | 티어 강등 → 최종적으로 캔버스 제거 (코드 C) |
|
||||
| 배터리 부족 · 3분 이상 실행 | DPR을 1로 내린다 (써멀 스로틀링 방지) |
|
||||
| 컨텍스트 로스 | `e.preventDefault()`로 복구 신호 후 정지. **three는 리소스를 자동 재생성하지 않는다** — 씬을 통째로 다시 만들어라 |
|
||||
|
||||
- 장식 캔버스는 `aria-hidden="true"` + `pointer-events: none`
|
||||
- **WebGL 안의 텍스트는 검색·선택·스크린리더가 안 된다.** 히어로 문구는 반드시 DOM에 둔다
|
||||
- 의미 있는 3D 뷰는 `role="img"` + `aria-label` + 정적 이미지 대안
|
||||
- 캔버스 위 텍스트는 뒤에 반투명 레이어를 깔아 대비 4.5:1을 보장한다
|
||||
- 캔버스를 여러 개 만들지 마라. 컨텍스트 상한은 8~16개다 (drei `<View>` 또는 씬 전환)
|
||||
|
||||
---
|
||||
|
||||
## 11. 채택 체크리스트
|
||||
|
||||
5단계 프리플라이트에서 그대로 돌린다. **하나라도 실패하면 채택 취소이거나 4-3 복귀다.**
|
||||
|
||||
- [ ] §0 채택 조건 4개를 통과했다 (CSS/SVG로 안 되고, 예산 내이고, 폴백이 있고, 컨셉을 강화한다)
|
||||
- [ ] WebGL 추가분을 **실측**했다(build 후 gzip). 예산 내이거나, 올린 이유를 명시했다
|
||||
- [ ] `three`가 **초기 번들에 없다.** Network 탭에서 LCP 이후 요청됨을 확인했다
|
||||
- [ ] 셰이더의 색·시간이 3단계 토큰(`--accent`, `--dur-*`)에서 파생됐다. hex 하드코딩이 없다
|
||||
- [ ] 컬러 텍스처에 `colorSpace = SRGBColorSpace`를 지정했다
|
||||
- [ ] DPR을 클램프했다. `IntersectionObserver` + `visibilitychange`로 렌더를 멈춘다
|
||||
- [ ] `renderer.info.render.calls` ≤ 150(데스크톱) / 60(모바일)
|
||||
- [ ] 캔버스를 `display:none`으로 껐을 때 페이지가 완성돼 있다 (프리플라이트 0-A와 동일)
|
||||
- [ ] `prefers-reduced-motion`에서 애니메이션이 멈추고 폴백이 보인다
|
||||
- [ ] 캔버스에 `aria-hidden="true"`, 히어로 텍스트는 DOM에 있다
|
||||
- [ ] 컨텍스트 로스를 강제(`WEBGL_lose_context.loseContext()`)했을 때 크래시 없이 폴백이 뜬다
|
||||
- [ ] 라우트 이탈 시 `dispose()`가 호출된다. 3분 실행 후 `renderer.info.memory`가 증가하지 않는다
|
||||
- [ ] 실기기(중저가 안드로이드)에서 30fps 이상이다
|
||||
- [ ] 6단계 `design.md`에 **채택한 이펙트 · 실측 추가분(KB) · 그 폴백**을 적었다
|
||||
|
||||
하드 게이트 중 셋이 여기서 자주 걸린다: **#1**(셰이더 hex 하드코딩) · **#8**(`transform`/`opacity` 외 애니메이션 — 캔버스 페이드는 `opacity`로) · **#10**(`scroll` 리스너 — rAF에서 `scrollY`를 읽어라).
|
||||
|
||||
> 근거: research/three/01~04 (조사일 2026-08-20)
|
||||
245
packages/skill/references/tokens.md
Normal file
245
packages/skill/references/tokens.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# tokens — 디자인 토큰과 성능 예산
|
||||
|
||||
3단계에서 읽는다. **코드를 쓰기 전에 숫자를 정한다.** 구현하면서 색을 고르면 매번 다른 색이 나온다.
|
||||
|
||||
토큰은 취향이 아니라 **계약**이다. 한번 정하면 이후 모든 값은 여기서 나온다. 하드코딩된 값이 하나라도 있으면 수정 요청 한 번에 무너진다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 타입 스케일
|
||||
|
||||
### 비율을 먼저 고른다
|
||||
|
||||
| 비율 | 값 | 인상 | 언제 |
|
||||
|---|---|---|---|
|
||||
| Minor Third | 1.200 | 차분, 조밀 | 정보 밀도가 높은 페이지, 문서, 대시보드 |
|
||||
| Major Third | 1.250 | 안정 | 기본값. 대부분의 마케팅 사이트 |
|
||||
| Perfect Fourth | 1.333 | 또렷한 위계 | 랜딩 페이지, 제품 소개 |
|
||||
| Golden | 1.618 | 극적 | 포트폴리오, 에디토리얼. 중간 단계가 비어 본문이 외로워진다 |
|
||||
|
||||
**한 페이지에 비율은 하나다.** 헤드라인만 다른 비율을 쓰고 싶으면 그건 비율이 아니라 **디스플레이 사이즈를 따로 정의**하는 것이다.
|
||||
|
||||
### 실제 값으로 적는다
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Perfect Fourth (1.333), 본문 16px 기준 */
|
||||
--step--1: 0.75rem; /* 12 — 캡션, 레이블 */
|
||||
--step-0: 1rem; /* 16 — 본문 */
|
||||
--step-1: 1.333rem; /* 21 — 리드 문단, 소제목 */
|
||||
--step-2: 1.777rem; /* 28 — h3 */
|
||||
--step-3: 2.369rem; /* 38 — h2 */
|
||||
--step-4: 3.157rem; /* 51 — h1 */
|
||||
--step-5: 4.209rem; /* 67 — 디스플레이 */
|
||||
}
|
||||
```
|
||||
|
||||
### 반응형은 clamp 로, 미디어쿼리로 하지 마라
|
||||
|
||||
```css
|
||||
--step-4: clamp(2.25rem, 1.5rem + 3.75vw, 3.157rem);
|
||||
```
|
||||
`clamp(최소, 기준+vw, 최대)`. 최소값은 **모바일에서 읽히는 크기**, 최대값은 데스크톱 기준. 중간이 매끄럽게 이어져 중간 뷰포트에서 깨지지 않는다.
|
||||
|
||||
### 함께 정해야 하는 것
|
||||
|
||||
| 토큰 | 규칙 |
|
||||
|---|---|
|
||||
| `--leading-tight` | 1.1~1.2 — 디스플레이/헤드라인 |
|
||||
| `--leading-normal` | 1.5~1.6 — 본문 (라틴) / **1.6~1.8 (한글)** |
|
||||
| `--measure` | 한 줄 길이. 라틴 60~75자, **한글 25~40자** |
|
||||
| 자간 | 큰 글자에만 음수(`-0.02em` 정도). **한글에는 음수 자간 금지** |
|
||||
|
||||
**폰트는 최대 2종.** 세 번째 폰트를 넣고 싶다면 그건 위계를 웨이트로 못 만들고 있다는 신호다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 색
|
||||
|
||||
### 역할로 정의한다. 팔레트로 정의하지 마라
|
||||
|
||||
"파랑 5단계"가 아니라 **무엇에 쓰이는 색인지**로 정의한다.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--surface: /* 페이지 바탕 */
|
||||
--surface-raised: /* 카드·패널 (바탕과 구분되되 튀지 않게) */
|
||||
--ink: /* 본문 텍스트 */
|
||||
--ink-muted: /* 보조 텍스트 — 대비 4.5:1 유지 */
|
||||
--line: /* 경계선 */
|
||||
--accent: /* 강조 — 페이지당 하나 */
|
||||
--accent-ink: /* 강조 위에 올라가는 글자색 */
|
||||
}
|
||||
```
|
||||
|
||||
### 규칙
|
||||
|
||||
1. **강조색은 하나다.** 두 개가 필요하다고 느끼면 위계 설계가 실패한 것이다. 예외: 상태색(성공/경고/오류)은 강조색이 아니라 기능색이다
|
||||
2. **채도가 높은 색은 면적을 좁게.** 넓은 면적에 쓰면 눈이 피로하고 싸구려로 보인다
|
||||
3. **중성색도 색이다.** 순수 회색(`#808080`) 대신 강조색 쪽으로 약간 기운 중성색을 쓰면 화면 전체가 하나로 묶인다
|
||||
4. **대비를 측정해라.** 본문 4.5:1, 큰 글자 3:1. 눈으로 판단하지 마라
|
||||
|
||||
### 다크 모드
|
||||
|
||||
**다크가 슬롭인 게 아니라 고르지 않은 다크가 슬롭이다.** (`antipatterns.md` §1 — bun.sh 는 다크인데도 슬롭 항목을 거의 전부 회피한다)
|
||||
|
||||
다크를 기본으로 하려면 **근거를 한 줄로 대라.** 정당화되는 이유의 목록은 `presets/dark-instrument.md` 에 있다(야간 운영 환경, 밝은 데이터 시각화의 대비, 제품 자체가 어두움). 댈 수 없으면 라이트로 간다.
|
||||
|
||||
어느 쪽을 기본으로 하든 **두 테마를 동등하게 정의한다.** 다크만 만들고 라이트를 빼는 것은 사용자 선택권을 뺏는 것이다.
|
||||
|
||||
지원할 때는 색을 뒤집는 게 아니라 **역할별로 다시 정의**한다. 다크에서 순수 검정(`#000`)은 대비가 너무 세서 눈이 아프고, 순수 흰색 텍스트도 마찬가지다.
|
||||
|
||||
```css
|
||||
:root { --surface: #fbfaf8; --ink: #1a1917; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --surface: #14130f; --ink: #e8e4dc; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 간격
|
||||
|
||||
### 하나의 리듬에서 파생시킨다
|
||||
|
||||
```css
|
||||
:root {
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 1rem; /* 16 */
|
||||
--space-4: 1.5rem; /* 24 */
|
||||
--space-5: 2.5rem; /* 40 */
|
||||
--space-6: 4rem; /* 64 */
|
||||
--space-7: 6rem; /* 96 */
|
||||
--space-8: 10rem; /* 160 — 섹션 간격 */
|
||||
}
|
||||
```
|
||||
|
||||
**중간 값을 즉석에서 만들지 마라.** `--space-4` 와 `--space-5` 사이가 필요하다면 스케일이 잘못된 것이다.
|
||||
|
||||
### 여백이 위계를 만든다
|
||||
|
||||
- 관련 있는 것끼리는 **가깝게**, 다른 그룹과는 **확실히 멀게**. 애매한 중간 간격이 가장 나쁘다
|
||||
- 섹션 간격은 **본문 간격의 4배 이상**. 좁으면 페이지가 뭉개진다
|
||||
- 요소를 정렬할 때 **간격이 아니라 정렬선**을 먼저 맞춰라
|
||||
|
||||
---
|
||||
|
||||
## 3-b. 형태 (radius·선)
|
||||
|
||||
하드 게이트 #3 이 검사하는 대상이다. **정의하지 않으면 검사할 수 없다.**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius-sm: 2px; /* 입력, 배지 */
|
||||
--radius-md: 8px; /* 카드, 패널 */
|
||||
--radius-pill: 999px;
|
||||
--line-width: 1px;
|
||||
}
|
||||
```
|
||||
|
||||
**서로 다른 radius 값은 3종 미만으로.** 어휘가 많을수록 우연히 결정된 것으로 보인다.
|
||||
1종만 쓰는 것도 정당한 선택이다 — `swiss-minimal`·`anti-grid` 에서는 오히려 그쪽이 맞다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 모션 토큰
|
||||
|
||||
```css
|
||||
:root {
|
||||
--dur-instant: 100ms; /* 상태 변화 — 호버, 포커스 */
|
||||
--dur-quick: 200ms; /* 작은 요소 등장/퇴장 */
|
||||
--dur-normal: 350ms; /* 패널, 모달 */
|
||||
--dur-slow: 600ms; /* 페이지 전환, 큰 이동 */
|
||||
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* 들어오는 것 — 기본값 */
|
||||
--ease-in: cubic-bezier(0.64, 0, 0.78, 0); /* 나가는 것 */
|
||||
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1); /* 위치 이동 */
|
||||
}
|
||||
```
|
||||
|
||||
상세는 `references/motion.md`. 여기서는 **값을 고정**하는 것이 목적이다. 컴포넌트마다 다른 duration 을 쓰면 페이지가 불안해 보인다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 성능 예산
|
||||
|
||||
**여기서 정한다. 구현 후에 재면 이미 늦었다.**
|
||||
|
||||
**이 표가 성능 예산의 유일한 원본이다.** SKILL.md 도 `preflight.md` 도 여기를 가리킨다. 다른 문서에 예산 표를 만들면 값이 갈라지고, 갈라진 순간 아무도 어느 쪽이 맞는지 모른다.
|
||||
|
||||
| 항목 | 기본 | 데모/포트폴리오 | 5단계에서 |
|
||||
|---|---|---|---|
|
||||
| 히어로까지 JS (gzip) | 150KB | 400KB | 측정 |
|
||||
| WebGL/3D 추가분 | +200KB | +600KB | 측정 |
|
||||
| 총 전송량 (첫 화면) | 1MB | 2MB | 측정 |
|
||||
| 첫 인터랙션 (모바일 4G) | 3초 | 5초 | 측정 |
|
||||
| LCP | 2.5초 | 3.5초 | 측정 |
|
||||
| CLS | 0.1 | 0.1 | 측정 |
|
||||
| 폰트 **패밀리** | 2개 이하 | 3개 | 개수 확인 |
|
||||
| 폰트 전송량(첫 화면) | 100KB | 200KB | 측정 |
|
||||
| 애니메이션 속성 | transform/opacity 만 | 동일 (타협 없음) | grep |
|
||||
|
||||
예산을 올렸다면 **올렸다는 사실과 이유를 명시**해라. 조용히 넘기는 것이 가장 나쁘다.
|
||||
|
||||
> **폰트는 파일 개수가 아니라 패밀리 수와 전송량으로 센다.** 한글 웹폰트를 유니코드 범위별로
|
||||
> 수십 개 파일로 쪼개는 것은 **올바른 최적화**다(Pretendard `dynamic-subset` 은 14개 이상).
|
||||
> 브라우저는 페이지에 실제로 쓰인 글자 범위만 받는다. 파일 개수를 줄이라고 요구하면
|
||||
> 한글 프로젝트를 단일 대용량 파일이라는 잘못된 방향으로 몬다.
|
||||
|
||||
### LCP·CLS 를 어떻게 재나
|
||||
|
||||
`preflight.md` 가 이 값을 요구한다. 재는 법이 없으면 "미측정"으로 남고, 미측정은 통과가 아니다.
|
||||
|
||||
```html
|
||||
<!-- 페이지에 임시로 넣고 콘솔을 본다. 측정 후 반드시 제거한다 -->
|
||||
<script>
|
||||
new PerformanceObserver((l) => {
|
||||
const e = l.getEntries().at(-1);
|
||||
console.log('LCP', Math.round(e.startTime), e.element);
|
||||
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
|
||||
let cls = 0;
|
||||
new PerformanceObserver((l) => {
|
||||
for (const e of l.getEntries()) if (!e.hadRecentInput) cls += e.value;
|
||||
console.log('CLS', cls.toFixed(3));
|
||||
}).observe({ type: 'layout-shift', buffered: true });
|
||||
</script>
|
||||
```
|
||||
|
||||
**프로덕션 빌드에 돌려라.** 개발 서버는 번들이 다르고 HMR 스크립트가 섞여 값이 의미 없다.
|
||||
Lighthouse 를 쓸 수 있으면 그쪽이 더 정확하다 — 단 모바일 프로파일로.
|
||||
|
||||
### 예산을 지키는 기본 수단
|
||||
|
||||
- 폰트: `font-display: swap`, 서브셋(한글은 필수 — 전체 한글 폰트는 수 MB다), `preload` 는 실제로 첫 화면에 쓰는 것만
|
||||
- 이미지: 실제 표시 크기의 2배까지만, AVIF/WebP, 첫 화면 밖은 `loading="lazy"`
|
||||
- JS: 첫 화면에 필요 없는 것은 전부 지연 로드. 3D는 뷰포트 진입 시 동적 import
|
||||
- **측정하지 않은 최적화는 하지 마라.** 대신 예산을 넘겼는지는 반드시 측정해라
|
||||
|
||||
---
|
||||
|
||||
## 6. 산출물 형식
|
||||
|
||||
3단계를 마치면 아래가 실제 값으로 채워져 있어야 한다. 이것이 4단계의 입력이다.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 타입 — 비율 ____ , 디스플레이는 스케일 밖 별도 정의 여부 ____ */
|
||||
--step--1 ~ --step-5, --leading-*, --measure
|
||||
/* 색 — 역할별. 다크 테마도 함께 */
|
||||
--surface, --surface-raised, --ink, --ink-muted, --line, --accent, --accent-ink
|
||||
/* 간격 */
|
||||
--space-1 ~ --space-8
|
||||
/* 형태 — 하드 게이트 #3 이 이걸 검사한다 */
|
||||
--radius-*, --line-width
|
||||
/* 모션 */
|
||||
--dur-*, --ease-*
|
||||
}
|
||||
```
|
||||
|
||||
그리고 한 줄로: **성능 예산 = JS ___KB / 첫 인터랙션 ___초 / 폰트 패밀리 ___개**
|
||||
|
||||
**정의하지 않은 토큰은 5단계에서 검사할 수 없다.** 쓰지 않을 항목은 "쓰지 않음"이라고 적어라 — 빈칸과 "없음"은 다르다.
|
||||
|
||||
> 근거: research/references/03-trends-2026.md, 04-ai-slop-signatures.md (조사일 2026-08-20)
|
||||
4522
pnpm-lock.yaml
generated
Normal file
4522
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
packages:
|
||||
- "packages/*"
|
||||
- "apps/*"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
397
research/canvas/01-api-spec.md
Normal file
397
research/canvas/01-api-spec.md
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
# 01. HTML-in-Canvas API 정확한 스펙
|
||||
|
||||
> 조사 기준일: 2026-08-20
|
||||
> 1차 출처: WICG 공식 explainer(living document), WHATWG HTML PR, Chrome Platform Status, Chrome for Developers 블로그, blink-dev Intent 스레드
|
||||
> **이 문서의 모든 시그니처는 WICG explainer의 IDL 블록 원문에서 그대로 옮긴 것이다. 추측한 부분은 명시적으로 "미확인"으로 표시했다.**
|
||||
|
||||
---
|
||||
|
||||
## 0. 한 줄 요약
|
||||
|
||||
`<canvas layoutsubtree>` 안에 실제 HTML을 넣고, `paint` 이벤트 안에서 `ctx.drawElementImage(el, x, y)`(2D) / `gl.texElementImage2D(...)`(WebGL) / `device.queue.copyElementImageToTexture(...)`(WebGPU)를 호출하면, 그 HTML의 **살아있는 렌더링 결과**가 캔버스 픽셀(또는 GPU 텍스처)로 들어온다. 원본 요소는 DOM에 그대로 남아 클릭·포커스·접근성·find-in-page가 계속 동작한다.
|
||||
|
||||
## 1. 공식 명칭과 저장소
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 기능 이름 | **HTML-in-canvas** (Chrome Platform Status 등록명) |
|
||||
| 인큐베이션 | W3C WICG |
|
||||
| Explainer 저장소 | `https://github.com/WICG/html-in-canvas` |
|
||||
| Explainer 렌더링 | `https://wicg.github.io/html-in-canvas/` (형식 스펙이 아니라 explainer 그 자체) |
|
||||
| 스펙 PR | `https://github.com/whatwg/html/pull/11588` — "Add HTML-in-Canvas APIs", 2025-08-21 개설, **2026-08 현재 open / 미머지** |
|
||||
| chromestatus | `https://chromestatus.com/feature/5172548013916160` |
|
||||
| Chromium 버그 | `https://crbug.com/500967896` (Blink 컴포넌트: `Blink>Canvas`) |
|
||||
| 저자 | Philip Rogers, Stephen Chenney(Igalia), Chris Harrelson, Philip Jägenstedt, Khushal Sagar, Vladimir Levin, Fernando Serboncini |
|
||||
|
||||
### 1.1 이름 변천사 — 영상/구 자료의 이름이 지금과 다른 이유
|
||||
|
||||
노마드코더 영상 및 상당수의 블로그가 쓰는 `canvas place element` / `drawElement` / `setHitTestRegions()`는 **모두 옛 이름**이다. WICG 저장소 커밋 로그로 확인한 실제 변천:
|
||||
|
||||
| 날짜 | 변경 |
|
||||
|---|---|
|
||||
| ~2025-08 이전 | 제안 이름 `canvas place element`, 메서드 `drawElement()`, 히트테스트는 `setHitTestRegions()` |
|
||||
| 2025-08-22 | 메서드 rename (`drawElement` → `drawHTMLElement`) |
|
||||
| 2025-09-05 | `drawHTMLElement` → **`drawHTML`** |
|
||||
| 2025-09-11 | `drawHTML` → **`drawElementImage`** ← **현재 이름** |
|
||||
| 2025-10-08 | explainer에서 "place element" 표현 전부 제거 (저장소도 `WICG/canvas-place-element` → `WICG/html-in-canvas`. 옛 저장소는 현재 404) |
|
||||
| 2025-11-08 | **`setHitTestRegions()` 폐기** → "Switch to CSS transforms for hit testing" (반환된 `DOMMatrix`를 `element.style.transform`에 넣는 방식으로 대체) |
|
||||
| 2026-02-10~25 | `paint` 이벤트 / `onpaint` 설계 확정, 이벤트에서 `time` 인자 제거 |
|
||||
| 2026-03-17~31 | `captureElementImage()` + `ElementImage` (OffscreenCanvas 지원) 추가 |
|
||||
| 2026-03-20 | `drawElementImage()` 소스 사각형(sx/sy/swidth/sheight) 오버로드 추가 |
|
||||
| 2026-04-16 / 06-01 | WebGL/WebGPU IDL 대폭 변경 (**아래 4·5절의 "구 시그니처" 주의사항 참조**) |
|
||||
| 2026-06-16 | "privacy-preserving painting" → **"read-back-allowed rendering"** 으로 개념 이름 변경 |
|
||||
| 2026-07-13/14 | 중첩 canvas 허용, `paint` 이벤트가 **역트리 순서**로 발화함을 명시 |
|
||||
|
||||
> ⚠️ WHATWG 스펙 PR #11588 본문에는 아직 `setHitTestRegions()`와 `drawable` 속성이 남아 있다. **explainer(=구현 기준)와 스펙 PR이 아직 동기화되지 않은 상태**이므로, 구현 기준은 explainer를 따라야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. `layoutsubtree` 속성
|
||||
|
||||
**철자: 전부 소문자 `layoutsubtree`** (HTML 속성). IDL 반사 프로퍼티는 camelCase `layoutSubtree`.
|
||||
|
||||
```html
|
||||
<canvas id="canvas" style="width:400px; height:200px" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
```
|
||||
|
||||
- `boolean` 타입 → **불리언 속성**. 존재하기만 하면 true다. `layoutsubtree="true"`도 되고(WICG 예제가 이 형태를 쓴다) `layoutsubtree=""`도 된다. `layoutsubtree="false"`라고 써도 **true로 취급**되니 끄려면 속성 자체를 제거해야 한다.
|
||||
- 효과 (explainer 원문 기준):
|
||||
- 캔버스 자손이 **레이아웃에 참여**하고 **히트테스트에 참여**한다.
|
||||
- `<canvas>`의 **직계 자식**은 stacking context를 생성하고, 모든 자손의 containing block이 되며, **paint containment**를 갖는다.
|
||||
- 자식은 "보이는 것처럼" 동작하지만, `drawElementImage()`로 명시적으로 그려지기 전까지 **사용자에게는 보이지 않는다**.
|
||||
- 이 속성이 없으면 캔버스 자식은 종전대로 fallback 콘텐츠일 뿐이고, `drawElementImage()`는 예외를 던진다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 2D 컨텍스트 — `drawElementImage()`
|
||||
|
||||
### 3.1 IDL (explainer 원문 그대로)
|
||||
|
||||
```webidl
|
||||
interface mixin CanvasDrawElementImage {
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double dx, unrestricted double dy);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double dx, unrestricted double dy,
|
||||
unrestricted double dwidth, unrestricted double dheight);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double sx, unrestricted double sy,
|
||||
unrestricted double swidth, unrestricted double sheight,
|
||||
unrestricted double dx, unrestricted double dy);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double sx, unrestricted double sy,
|
||||
unrestricted double swidth, unrestricted double sheight,
|
||||
unrestricted double dx, unrestricted double dy,
|
||||
unrestricted double dwidth, unrestricted double dheight);
|
||||
};
|
||||
|
||||
CanvasRenderingContext2D includes CanvasDrawElementImage;
|
||||
OffscreenCanvasRenderingContext2D includes CanvasDrawElementImage;
|
||||
```
|
||||
|
||||
오버로드는 인자 개수 **3 / 5 / 7 / 9개** 네 가지다 — `(el, dx, dy)`, `(el, dx, dy, dw, dh)`, `(el, sx, sy, sw, sh, dx, dy)`, `(el, sx, sy, sw, sh, dx, dy, dw, dh)`. 기존 `CanvasRenderingContext2D.drawImage()`와 정확히 같은 모양이고, 첫 인자만 이미지 소스 대신 `Element`(또는 `ElementImage`)로 바뀐 것이다.
|
||||
|
||||
### 3.2 반환값
|
||||
|
||||
**`DOMMatrix`** — 이 행렬을 `element.style.transform = returned.toString()` 으로 적용하면, DOM 상의 요소 위치가 캔버스에 그려진 위치와 일치한다. 히트테스트·포커스 링·IntersectionObserver·접근성 좌표가 이 DOM 위치를 쓰기 때문에 **이 동기화를 하지 않으면 클릭이 엉뚱한 곳에 떨어진다.**
|
||||
|
||||
### 3.3 요구사항·제약 (explainer 원문 항목)
|
||||
|
||||
- 최근 렌더링 업데이트 시점에 `<canvas>`에 `layoutsubtree`가 지정되어 있어야 한다.
|
||||
- `element`는 최근 렌더링 업데이트 시점에 `<canvas>`의 **직계 자식(direct child)** 이어야 한다.
|
||||
- `element`가 **박스를 생성**해야 한다 (즉 `display:none` 이면 안 된다).
|
||||
- **변환(Transforms)**: 캔버스의 현재 변환 행렬(CTM)은 그리기에 적용된다. 반면 **소스 `element`에 걸린 CSS transform은 그리기에서 무시된다.** (다만 히트테스트/접근성에는 계속 영향을 준다 — 그래서 3.2의 동기화가 성립한다.)
|
||||
- **클리핑**: 넘치는 콘텐츠(layout overflow, ink overflow 모두)는 요소의 **border box로 클리핑**된다.
|
||||
- **크기**: `width`/`height` 인자는 캔버스 좌표계의 목적지 사각형이다. 생략하면 **캔버스 밖에 있을 때와 같은 화면상 크기·비율**이 되도록 자동 사이징된다.
|
||||
→ 실전 의미: `canvas.width`를 device pixel로 잡으면 x/y 좌표도 device pixel 단위여야 한다 (`x * devicePixelRatio`).
|
||||
|
||||
### 3.4 스냅샷 타이밍 (중요)
|
||||
|
||||
- 캔버스 모든 자식의 렌더링 스냅샷은 **`paint` 이벤트 직전**에 기록된다.
|
||||
- `paint` 이벤트 **안에서** 호출하면 → **현재 프레임**의 모습으로 그려진다.
|
||||
- `paint` 이벤트 **밖에서** 호출하면 → **직전 프레임**의 스냅샷이 쓰인다.
|
||||
- 최초 스냅샷이 기록되기 전에 호출하면 **예외**가 발생한다 (Chromium 구현에서는 `InvalidStateError`).
|
||||
|
||||
---
|
||||
|
||||
## 4. WebGL — `texElementImage2D()`
|
||||
|
||||
### 4.1 현재 IDL (explainer 원문)
|
||||
|
||||
```webidl
|
||||
dictionary WebGLCopyElementImageConfig {
|
||||
GLfloat sx;
|
||||
GLfloat sy;
|
||||
GLfloat swidth;
|
||||
GLfloat sheight;
|
||||
GLsizei width;
|
||||
GLsizei height;
|
||||
};
|
||||
|
||||
partial interface WebGLRenderingContext {
|
||||
void texElementImage2D(GLenum target, GLenum internalformat,
|
||||
(Element or ElementImage) element,
|
||||
optional WebGLCopyElementImageConfig config = {});
|
||||
};
|
||||
```
|
||||
|
||||
호출:
|
||||
```js
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, element);
|
||||
```
|
||||
|
||||
### 4.2 ⚠️ 시그니처가 2026-04~06에 바뀌었다
|
||||
|
||||
- **구 시그니처**: `texElementImage2D(target, level, internalformat, format, type, element)` — `texImage2D`와 똑같은 6인자.
|
||||
- **신 시그니처**: `texElementImage2D(target, internalformat, element, config?)` — `level`, `format`, `type`이 사라졌다.
|
||||
- Chrome for Developers 블로그(2026-05-19 최종 수정)의 코드 예제는 **아직 구 시그니처**(`gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, form_element)`)를 보여준다. 반면 WICG 공식 예제 `Examples/webGL.html`은 try/catch로 신·구 양쪽을 지원한다:
|
||||
|
||||
```js
|
||||
try {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, draw_element); // 신
|
||||
} catch (e) {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, // 구
|
||||
gl.UNSIGNED_BYTE, draw_element);
|
||||
}
|
||||
```
|
||||
→ **실전 코드에서는 이 try/catch 패턴을 그대로 쓰는 게 안전하다.**
|
||||
|
||||
### 4.3 알려진 미해결 이슈
|
||||
|
||||
- Jake Archibald가 blink-dev Intent 스레드에서 지적: **WebGL 경로에서 텍스처 크기를 지정할 방법이 없다.** `config`의 `width`/`height`가 추가된 배경이지만 논의는 진행 중.
|
||||
|
||||
---
|
||||
|
||||
## 5. WebGPU — `copyElementImageToTexture()`
|
||||
|
||||
### 5.1 현재 IDL (explainer 원문)
|
||||
|
||||
```webidl
|
||||
dictionary GPUCopyElementImageDestination {
|
||||
required GPUImageCopyTextureTagged destination;
|
||||
GPUIntegerCoordinate width;
|
||||
GPUIntegerCoordinate height;
|
||||
};
|
||||
|
||||
dictionary GPUCopyElementImageSource {
|
||||
required (Element or ElementImage) source;
|
||||
float sx;
|
||||
float sy;
|
||||
float swidth;
|
||||
float sheight;
|
||||
};
|
||||
|
||||
partial interface GPUQueue {
|
||||
void copyElementImageToTexture(GPUCopyElementImageSource source,
|
||||
GPUCopyElementImageDestination destination);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 실제 호출 형태 (WICG 젤리 슬라이더 데모 `Examples/webgpu-jelly-slider/src/index.ts` 원문)
|
||||
|
||||
```js
|
||||
canvas.onpaint = () => {
|
||||
const sourceDict = { source: valueElement };
|
||||
const destDict = {
|
||||
destination: { texture: valueRawTexture },
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
try {
|
||||
device.queue.copyElementImageToTexture(sourceDict, destDict); // 신
|
||||
} catch (e) {
|
||||
device.queue.copyElementImageToTexture(valueElement, width, height, // 구
|
||||
{ texture: valueRawTexture });
|
||||
console.log('Note: using old copyElementImageToTexture API');
|
||||
}
|
||||
// ... transform 동기화
|
||||
};
|
||||
```
|
||||
|
||||
> ⚠️ Chrome 블로그는 `device.queue.copyElementImageToTexture(valueElement, { texture: targetTexture })` 라는 **또 다른(간략화된/구) 형태**를 보여준다. IDL과 WICG 데모 소스가 서로 일치하므로 **위 dictionary 2개 형태가 현재 기준**이다.
|
||||
|
||||
`copyExternalImageToTexture()`의 DOM 요소 버전이라고 생각하면 된다.
|
||||
|
||||
---
|
||||
|
||||
## 6. `paint` 이벤트
|
||||
|
||||
### 6.1 IDL
|
||||
|
||||
```webidl
|
||||
[Exposed=Window]
|
||||
interface PaintEvent : Event {
|
||||
constructor(DOMString type, optional PaintEventInit eventInitDict);
|
||||
readonly attribute FrozenArray<Element> changedElements;
|
||||
};
|
||||
|
||||
dictionary PaintEventInit : EventInit {
|
||||
sequence<Element> changedElements = [];
|
||||
};
|
||||
```
|
||||
|
||||
`canvas.onpaint = fn` 또는 `canvas.addEventListener('paint', fn)` 둘 다 가능.
|
||||
|
||||
### 6.2 동작 규칙 (explainer 원문 기준)
|
||||
|
||||
- 캔버스 자식들의 **렌더링이 변했을 때** 발화한다 (포커스, 호버, 입력, CSS 애니메이션 등).
|
||||
- 발화 시점: [update-the-rendering](https://html.spec.whatwg.org/#update-the-rendering) 중 **IntersectionObserver 단계가 실행된 직후**. 설계 문서상으로는 "Paint 단계 직후, 루프 없이 프레임당 1회"(explainer의 Option C).
|
||||
- 이벤트는 **바뀐 자식들의 목록**(`changedElements`)을 담는다.
|
||||
- 캔버스 자식의 **CSS transform 변경은 렌더링에서 무시**되므로, transform만 바꿔서는 다음 프레임에 `paint`가 발화하지 않는다. (→ 3.2의 동기화 코드가 무한 루프를 만들지 않는 이유)
|
||||
- `paint` 안에서 한 **캔버스 드로잉 명령은 현재 프레임에 반영**되지만, `paint` 안에서 한 **DOM 변경은 다음 프레임부터** 반영된다.
|
||||
- `<canvas>`가 여러 개면 `paint`는 **역트리 순서(reverse tree order)** 로 발화한다 → 자손이 조상보다 먼저 발화한다 (중첩 canvas 지원, 2026-07 추가).
|
||||
|
||||
### 6.3 `requestPaint()`
|
||||
|
||||
```webidl
|
||||
void requestPaint();
|
||||
```
|
||||
|
||||
자식이 하나도 안 바뀌어도 `paint`를 **한 번** 강제로 발화시킨다. explainer 표현으로 "`requestAnimationFrame()`과 유사". 매 프레임 갱신이 필요한 앱은 `onpaint` 핸들러 끝에서 `requestPaint()`를 다시 호출해 루프를 만든다. 또한 **최초 1회 호출해서 렌더 파이프라인을 킥스타트**해야 한다(첫 스냅샷 확보).
|
||||
|
||||
---
|
||||
|
||||
## 7. OffscreenCanvas / Worker — `captureElementImage()` & `ElementImage`
|
||||
|
||||
```webidl
|
||||
partial interface HTMLCanvasElement {
|
||||
[CEReactions, Reflect] attribute boolean layoutSubtree;
|
||||
attribute EventHandler onpaint;
|
||||
void requestPaint();
|
||||
ElementImage captureElementImage(Element element);
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
};
|
||||
|
||||
partial interface OffscreenCanvas {
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
};
|
||||
|
||||
[Exposed=(Window,Worker), Transferable]
|
||||
interface ElementImage {
|
||||
readonly attribute double width;
|
||||
readonly attribute double height;
|
||||
undefined close();
|
||||
};
|
||||
```
|
||||
|
||||
- `canvas.captureElementImage(element)` → 요소 렌더링의 **전송 가능한(Transferable) 스냅샷**.
|
||||
- `postMessage(msg, [elementImage])`로 워커에 넘기고, 워커의 `OffscreenCanvasRenderingContext2D.drawElementImage(elementImage, x, y)`로 그린다.
|
||||
- 워커에서 계산된 transform은 `postMessage`로 메인 스레드에 돌려보내 `element.style.transform`에 적용해야 한다. 위치가 동적이면 메인 스레드에서 미리 계산해 `ElementImage` 전송과 동시에 적용하는 편이 낫다.
|
||||
|
||||
---
|
||||
|
||||
## 8. `getElementTransform()` — 3D 컨텍스트용 동기화 헬퍼
|
||||
|
||||
```webidl
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
```
|
||||
|
||||
- **`HTMLCanvasElement`와 `OffscreenCanvas`에 있다.** (Chrome 블로그 본문 산문이 `element.getElementTransform()`이라고 쓴 곳이 있는데, **블로그 자체의 코드 예제와 IDL 모두 `canvas.getElementTransform(el, matrix)`** 이므로 산문 쪽이 오기다.)
|
||||
- WebGL/WebGPU에서는 요소의 최종 화면 위치를 셰이더가 결정하므로 `drawElementImage()`처럼 자동으로 알 수 없다. 그래서 개발자가 MVP 행렬을 스크린 스페이스 행렬로 변환해 넘겨주면, 이 메서드가 `style.transform`에 넣을 `DOMMatrix`를 돌려준다.
|
||||
|
||||
### 8.1 변환 공식 (explainer 원문)
|
||||
|
||||
```
|
||||
T_origin⁻¹ · S_css→grid⁻¹ · T_draw · S_css→grid · T_origin
|
||||
```
|
||||
- `T_draw`: 캔버스 그리드 좌표계에서 요소를 그린 변환. `drawElementImage`의 경우 `CTM · T_(x,y) · S_(destScale)`.
|
||||
- `T_origin`: 요소의 계산된 `transform-origin` 평행이동 행렬.
|
||||
- `S_css→grid`: CSS 픽셀 → 캔버스 그리드 픽셀 스케일 행렬.
|
||||
|
||||
### 8.2 WebGL에서 screenSpaceTransform 만드는 절차 (Chrome 블로그 원문 코드)
|
||||
|
||||
```js
|
||||
// 1. WebGL MVP → DOMMatrix
|
||||
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
|
||||
|
||||
// 2. HTML 요소 정규화 (px → 1x1 단위 사각형, Y축 뒤집기)
|
||||
const width = targetHTMLElement.offsetWidth;
|
||||
const height = targetHTMLElement.offsetHeight;
|
||||
const cssToUnitSpace = new DOMMatrix()
|
||||
.scale(1 / width, -1 / height, 1)
|
||||
.translate(-width / 2, -height / 2);
|
||||
|
||||
// 3. 클립 공간 → 캔버스 뷰포트
|
||||
const clipToCanvasViewport = new DOMMatrix()
|
||||
.translate(canvas.width / 2, canvas.height / 2)
|
||||
.scale(canvas.width / 2, -canvas.height / 2, 1);
|
||||
|
||||
// 4. 합성: (Clip→Pixels) * MVP * (px→unit)
|
||||
const screenSpaceTransform = clipToCanvasViewport.multiply(mvpDOM).multiply(cssToUnitSpace);
|
||||
|
||||
// 5. 적용
|
||||
const computedTransform = canvas.getElementTransform(targetHTMLElement, screenSpaceTransform);
|
||||
if (computedTransform) targetHTMLElement.style.transform = computedTransform.toString();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 접근성·히트테스트가 유지되는 원리
|
||||
|
||||
핵심은 **"같은 요소가 두 곳에 존재"가 아니라 "요소는 DOM에 한 번만 존재하고, 캔버스에는 그 픽셀 복사본이 있다"** 는 것이다.
|
||||
|
||||
- 히트테스트, 포커스, 키보드 탭 순서, 텍스트 선택, 복사/붙여넣기, 우클릭 컨텍스트 메뉴, find-in-page, 번역, 리더 모드, 확장 프로그램, 브라우저 줌, 자동완성 — **전부 DOM 쪽이 처리한다.** 캔버스는 픽셀만 담당한다.
|
||||
- `layoutsubtree`가 자식들을 **접근성 트리에 노출**시킨다. 기존 canvas fallback 콘텐츠와 달리, 그려진 내용과 접근성 트리가 **구조적으로 일치함이 보장**된다 (explainer가 명시한 주요 동기 중 하나).
|
||||
- 단 **DOM 위치와 그려진 위치가 어긋나면 전부 어긋난다.** → `drawElementImage()`의 반환 `DOMMatrix`(또는 `getElementTransform()`)를 매 프레임 `style.transform`에 반영하는 것이 이 API의 필수 계약이다.
|
||||
- DevTools에서 캔버스 안 HTML을 그대로 인스펙트/스타일 수정할 수 있고, 수정 즉시 텍스처에 반영된다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안·프라이버시: "Read-back-allowed rendering"
|
||||
|
||||
(구 명칭 "privacy-preserving painting", 2026-06-16 개칭)
|
||||
|
||||
캔버스 픽셀은 `getImageData()`로 읽을 수 있고, WebGL/WebGPU에서는 항상 읽을 수 있다. 따라서 **저자 코드가 원래 볼 수 없던 정보는 애초에 그려지지 않는다.** 페인팅(픽셀 읽기·타이밍 공격)과 무효화(`onpaint` 발화 여부) **양쪽 모두**에서 민감 정보를 배제한다.
|
||||
|
||||
### 그려지지 않는(=민감) 정보
|
||||
|
||||
- **cross-origin 데이터**: `<iframe>`·`<img>` 등 embedded content의 교차 출처 콘텐츠, `url()` 참조(`background-image`, `clip-path`), 교차 출처로 오염(tainted)된 `<canvas>`, SVG의 `<use>`/`<pattern>`/`<feImage>`.
|
||||
→ **same-origin iframe은 그려진다.** 그 안의 cross-origin 콘텐츠만 안 그려진다.
|
||||
- 시스템 색상 / 테마 / 사용자 환경설정
|
||||
- 맞춤법·문법 검사 밑줄 마커
|
||||
- **방문한 링크 정보(`:visited`)** — 히스토리 스니핑 방지
|
||||
- JS로 접근 불가한 대기 중 폼 자동완성 정보
|
||||
- 서브픽셀 텍스트 안티에일리어싱
|
||||
- 캡션/자막 선택 및 외형에 대한 사용자 설정
|
||||
- IME 팝업 및 IME 고유 텍스트 서식
|
||||
|
||||
### 민감하지 않다고 판정된(=그려지는) 새 정보
|
||||
|
||||
- find-in-page 검색어 하이라이트, text-fragment(URL 프래그먼트) 마커
|
||||
- 스크롤바 및 폼 컨트롤 외형 (Blink/WebKit에서 이미 `foreignObject`로 탐지 가능)
|
||||
- 캐럿 깜빡임 속도
|
||||
- `forced-colors` (이미 미디어 쿼리 + 시스템 색상으로 JS에서 알 수 있음)
|
||||
|
||||
> blink-dev Intent에 명시된 잔여 리스크: "이 API는 그라디언트 픽셀, 폼 컨트롤 렌더링 등 **소량의 새 정보를 노출**하며 이는 상호운용성 리스크를 만든다." Mozilla의 우려도 주로 이 핑거프린팅 지점이다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 기타 확인된 제약
|
||||
|
||||
| 제약 | 내용 | 출처 |
|
||||
|---|---|---|
|
||||
| cross-origin iframe | 지원 안 함 (위 10절) | Chrome 블로그 "Limitations" |
|
||||
| 메인 스레드 스크롤 | 캔버스 내부 콘텐츠는 JS로 그려지므로 **스크롤·애니메이션이 JS와 독립적으로 갱신될 수 없다.** 컴포지터 스레드 스크롤의 이점을 잃는다. 캔버스 안에 스크롤 콘텐츠를 넣을지, 캔버스 전체를 스크롤시킬지 신중히 판단하라. | Chrome 블로그 "Limitations" |
|
||||
| 중첩 canvas | explainer는 자손을 조상 캔버스에 그리는 것을 허용하지만, **Chromium Canary 구현은 가장 가까운 canvas 조상으로 제한**한다 (스펙 PR #11588에서 미해결 논의 중) | whatwg/html#11588 |
|
||||
| ElementImage 교차 캔버스 | `ElementImage`를 만든 캔버스 외의 캔버스에 그리는 것을 제한할지 논의 중 (접근성·래스터화 복잡도 vs 오래된 스냅샷 문제) | whatwg/html#11588 |
|
||||
| GC 압박 | dictionary 기반 API 설계 때문에 애니메이션 중 잦은 GC가 발생한다는 리뷰 지적 | whatwg/html#11588 |
|
||||
| 크기/리사이즈 | 커뮤니티 공통 지적: "이 API에서 크기와 리사이즈가 유일하게 덜 익은 부분". `<canvas>`는 div처럼 `width:100%`가 기본이 아니고 콘텐츠에 따라 높이가 자라지도 않는다. | Frontend Masters (Amit Sheen, 2026-04-21) |
|
||||
| 첫 스냅샷 전 호출 | `InvalidStateError` throw. 반드시 `onpaint` 안에서 그리고, `requestPaint()`로 킥스타트 | explainer + Matt Rothenberg |
|
||||
| 캔버스 자식 크기 | 캔버스 자식의 크기가 캔버스 CSS 크기와 불일치하면 텍스처가 늘어나고 좌표가 페이지 아래로 갈수록 누적 오차 | Matt Rothenberg 실전 노트 |
|
||||
| 반투명 배경 | 반투명 input 배경은 셰이더 효과가 비쳐 나오므로 불투명 색을 쓸 것 | Matt Rothenberg 실전 노트 |
|
||||
| WebGL Y축 | 텍스처는 top-down, WebGL은 bottom-up → UV에서 Y 뒤집기 필요 | Matt Rothenberg 실전 노트 |
|
||||
| 전체 화면 후처리 | 페이지 전체에 후처리를 걸면 오히려 접근성 이점을 훼손할 수 있다 | Codrops |
|
||||
|
||||
---
|
||||
|
||||
## 12. 향후 방향 (explainer "Future considerations")
|
||||
|
||||
**Auto-updating canvas 모드**: `drawElementImage`가 "최신 렌더링을 가리키는 플레이스홀더"를 기록하고, 캔버스가 커맨드 버퍼를 보관해 스크롤/애니메이션 갱신마다 자동 재생하는 모드. 스크립트를 블로킹하지 않고 컴포지터 스레드 스크롤·애니메이션과 완벽히 동기화되는 효과를 가능하게 한다. 2D 컨텍스트에는 실현 가능, WebGPU도 소폭 API 추가로 가능할 것으로 보고 있다. **WebGL은 `getError()` 등 플러시가 필요한 API 때문에 이 모델이 근본적으로 불가**하다고 explainer가 명시.
|
||||
144
research/canvas/02-availability.md
Normal file
144
research/canvas/02-availability.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# 02. 가용성 — 버전 / 플래그 / Origin Trial / 타 브라우저 입장
|
||||
|
||||
> 조사 기준일: **2026-08-20**
|
||||
> 이 시점의 Chrome Stable은 **151** (152는 2026-08-25 릴리스 예정). 출처: chromiumdash 마일스톤 스케줄 API
|
||||
|
||||
---
|
||||
|
||||
## 1. 결론 먼저
|
||||
|
||||
### ❌ 지금 프로덕션에 쓸 수 없다 — 단, "점진적 향상"으로는 오늘 넣을 수 있다
|
||||
|
||||
| 질문 | 답 |
|
||||
|---|---|
|
||||
| Stable Chrome에서 기본 켜져 있나? | **아니다.** chromestatus 상태는 여전히 `In development`이고, desktop/android/webview 출시 마일스톤이 전부 `null`이다. |
|
||||
| 일반 사용자가 볼 수 있나? | **Origin Trial 토큰을 등록한 오리진에 한해** Chrome 148~154 사용자에게 보인다. 그 외에는 `chrome://flags` 수동 활성화 필요. |
|
||||
| Chromium 계열 외 브라우저는? | **전혀 지원 없음.** Firefox·Safari 모두 "No signal"(입장 미표명), 구현 계획 없음. |
|
||||
| 스펙은 확정됐나? | **아니다.** WHATWG PR #11588은 2025-08-21 개설 후 **여전히 open/미머지**. 2026년 상반기에만 메서드 시그니처(WebGL/WebGPU)가 두 번 바뀌었다. |
|
||||
| 안정화 예상 | **공식 예상 없음.** Chrome 팀이 OT를 M150 → M154로 연장하며 밝힌 사유가 "상당한 피드백을 받았고 (WebGL/WebGPU API, 프라이버시에) 중대한 변경을 했다"이므로, 최소 M155(2026-10-06) 이후에나 Intent to Ship이 가능하다. Firefox/Safari 신호가 없는 한 진짜 Baseline까지는 수년 단위. |
|
||||
|
||||
### 실무 권고
|
||||
|
||||
1. **핵심 UX를 이 API에 의존시키지 마라.** 기능 감지 후 미지원이면 평범한 HTML로 폴백되는 구조여야 한다 (CanvasUI가 채택한 모델: "런타임에 지원을 감지하고 우아하게 degrade — API가 없으면 콘텐츠는 그냥 일반 HTML로 렌더되고, 여전히 돌 수 있는 효과 부분은 계속 돈다").
|
||||
2. **API 이름을 코드 전반에 흩뿌리지 마라.** 2025-08 이후 메서드명이 3번(`drawElement`→`drawHTMLElement`→`drawHTML`→`drawElementImage`), 3D 시그니처가 2번 바뀌었다. 얇은 어댑터 레이어 하나로 감싸라 (`03-code-examples.md` §7 참조).
|
||||
3. **데모·포트폴리오·실험·사내 도구에는 지금 써도 된다.** 특히 Chrome 사용자가 절대다수인 크리에이티브 포트폴리오라면 OT 토큰 + 폴백 조합으로 실전 투입 가능하다.
|
||||
4. **폴백이 필요하면 `three-html-render` 폴리필**(`foreignObject` 기반)이 같은 API 표면을 제공한다 → `04-fallbacks.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Chrome 타임라인
|
||||
|
||||
| 단계 | 마일스톤 | 플랫폼 | 비고 |
|
||||
|---|---|---|---|
|
||||
| **DevTrial (플래그)** | **M138부터** | Desktop / Android / WebView 전부 | `chrome://flags/#canvas-draw-element` |
|
||||
| **Origin Trial (최초)** | **M148 ~ M150** | Desktop / Android / WebView | Intent to Experiment 승인 |
|
||||
| **Origin Trial (연장 1회차)** | **~ M154** | Desktop | Intent to Extend Experiment, Mike Taylor LGTM **2026-06-11** |
|
||||
| Intent to Ship | — | — | **아직 없음** |
|
||||
|
||||
### 마일스톤 → 실제 날짜 (chromiumdash 공식 스케줄)
|
||||
|
||||
| 마일스톤 | Branch | Beta | **Stable** |
|
||||
|---|---|---|---|
|
||||
| M148 | 2026-04-06 | 2026-04-08 | **2026-05-05** |
|
||||
| M150 | 2026-06-01 | 2026-06-03 | **2026-06-30** |
|
||||
| M152 | 2026-07-27 | 2026-07-29 | **2026-08-25** |
|
||||
| M153 | 2026-08-17 | 2026-08-19 | **2026-09-08** |
|
||||
| M154 | 2026-08-31 | 2026-09-02 | **2026-09-22** |
|
||||
| M155 | 2026-09-14 | 2026-09-16 | **2026-10-06** |
|
||||
|
||||
→ **Origin Trial은 2026-08-20 현재 진행 중이며, M154(2026-09-22 Stable)까지 유효하다.** 실무적으로 M155가 Stable에 도달하는 **2026-10-06 무렵 만료**된다고 보면 된다. (추가 연장 가능성 있음 — 1차 연장 전례가 있다.)
|
||||
|
||||
### OT 연장 사유 (Intent to Extend Experiment 원문 요지)
|
||||
|
||||
> "상당한 피드백을 받았고 중대한 변경(WebGL/WebGPU API, 프라이버시)을 했기 때문에, 이 단계에서 개발자 입력을 계속 수집하고자 한다."
|
||||
|
||||
---
|
||||
|
||||
## 3. 지금 당장 켜는 방법
|
||||
|
||||
### 3.1 개발자 본인 브라우저 (플래그)
|
||||
|
||||
```
|
||||
chrome://flags/#canvas-draw-element
|
||||
```
|
||||
→ **Enabled** 로 설정하고 브라우저 재시작.
|
||||
|
||||
- 플래그 이름: **`canvas-draw-element`** (옛 메서드명 `drawElement` 시절에 붙은 이름이라 현재 API명과 다르다. 이름은 바뀌지 않았다.)
|
||||
- Chrome **Canary 149 이상**이 Chrome 공식 권장 (Chrome for Developers 블로그).
|
||||
- 커맨드라인 대안: `--enable-blink-features=CanvasDrawElement` (three.js PR #31233이 안내하는 구버전용 방법)
|
||||
- 서드파티 보고: **Brave Stable(Chromium 147+)** 및 기타 Chromium 계열에서도 같은 플래그로 켜진다 (html-in-canvas.dev). Google 1차 출처는 아님.
|
||||
|
||||
### 3.2 실제 사용자에게 노출 (Origin Trial 토큰)
|
||||
|
||||
등록: `https://developer.chrome.com/origintrials/#/view_trial/3478467762190286849`
|
||||
|
||||
토큰을 받아 다음 중 하나로 주입:
|
||||
|
||||
```html
|
||||
<meta http-equiv="origin-trial" content="TOKEN_HERE">
|
||||
```
|
||||
```
|
||||
Origin-Trial: TOKEN_HERE (HTTP 응답 헤더)
|
||||
```
|
||||
|
||||
- 대상: Desktop / Android / WebView
|
||||
- 3rd-party origin trial 지원 여부는 **미확인** (트라이얼 등록 페이지가 로그인 벽 뒤에 있어 확인 불가). 서드파티 스크립트로 배포할 계획이라면 등록 시 확인 필요.
|
||||
|
||||
### 3.3 기능 감지
|
||||
|
||||
```js
|
||||
const HAS_HIC =
|
||||
typeof HTMLCanvasElement !== 'undefined' &&
|
||||
'requestPaint' in HTMLCanvasElement.prototype &&
|
||||
'drawElementImage' in CanvasRenderingContext2D.prototype;
|
||||
```
|
||||
three.js 공식 예제가 쓰는 판정은 `'requestPaint' in HTMLCanvasElement.prototype` 하나다. 2D만 쓸 거면 `drawElementImage`까지, WebGL이면 `'texElementImage2D' in WebGL2RenderingContext.prototype`, WebGPU면 `'copyElementImageToTexture' in GPUQueue.prototype`를 추가로 본다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 타 브라우저 입장 (standards positions)
|
||||
|
||||
| 엔진 | 입장 | 트래커 | 비고 |
|
||||
|---|---|---|---|
|
||||
| **Gecko / Firefox** | **No signal** (미표명) | [mozilla/standards-positions#1076](https://github.com/mozilla/standards-positions/issues/1076) | 2024-09-25 개설, **여전히 open, 라벨은 "Needs proposed position"**. Mozilla Graphics 팀(nical) 배정. chromestatus의 Chrome 팀 주석: "Mozilla는 spec을 stage 2(대략적 API 모양에 대한 합의)로 진행시키는 데 반대하지 않았고, 우리는 핑거프린팅·호환성에 대한 그들의 우려를 해소하기 위해 적극적으로 작업 중이다." |
|
||||
| **WebKit / Safari** | **No signal** (미표명) | [WebKit/standards-positions#630](https://github.com/WebKit/standards-positions/issues/630) | open. `@annevk`, `@smfr`, `@shallawa`, `@cookiecrook` 태그됨. 이슈 본문에 WebKit 엔지니어 코멘트나 공식 라벨 없음. 배경 메모: 이전 제안(canvas place element #403)에서 **retained-mode 캔버스에 대한 우려** 때문에 **immediate-mode API 설계로 회귀**했다고 기재. |
|
||||
| **웹 개발자** | **Positive** | [whatwg/html#10650 코멘트](https://github.com/whatwg/html/issues/10650#issuecomment-3324124682) | DevTrial 사용자들의 긍정 신호. 커뮤니티 데모가 폭발적으로 나오는 중. |
|
||||
| **W3C TAG** | 리뷰 진행 | [w3ctag/design-reviews#1204](https://github.com/w3ctag/design-reviews/issues/1204) | — |
|
||||
|
||||
> 정리: **Chromium 단독 구현이고, 다른 두 엔진 어느 쪽도 "구현하겠다"고 말한 적이 없다.** Mozilla의 "stage 2 진행에 반대 안 함"은 지지가 아니라 논의 진행 허용에 가깝다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 표준화 상태
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| WHATWG HTML PR | [#11588](https://github.com/whatwg/html/pull/11588) "Add HTML-in-Canvas APIs" — **open, 미머지** (2025-08-21 개설, 저자 foolip) |
|
||||
| 성숙도 (chromestatus) | "Specification currently under development in a Working Group" (Working draft) |
|
||||
| Explainer | living document, 계속 갱신 중 (2026-07-14이 마지막 주요 갱신) |
|
||||
| 미해결 스펙 이슈 | ① dictionary 기반 API의 GC 압박, ② `ElementImage`의 교차 캔버스 사용 허용 여부, ③ **중첩 canvas — explainer는 허용하나 Chromium Canary는 가장 가까운 canvas 조상으로 제한(구현 불일치)**, ④ paint 타이밍과 paint-timing 스펙의 조율 |
|
||||
| **스펙 PR과 explainer 불일치** | PR #11588 본문에는 아직 폐기된 `setHitTestRegions()`와 `drawable` 속성이 남아 있다. **구현 기준은 explainer** |
|
||||
|
||||
---
|
||||
|
||||
## 6. 프레임워크/라이브러리 지원 현황 (2026-08 기준)
|
||||
|
||||
| 라이브러리 | API | 상태 |
|
||||
|---|---|---|
|
||||
| **three.js** | `THREE.HTMLTexture(element)` + `three/addons/interaction/InteractionManager.js` | **r184에 정식 포함** (dev 브랜치 2026-04-10 머지). WebGLRenderer / WebGPURenderer 양쪽 지원 |
|
||||
| **PlayCanvas** | `device.supportsHtmlTextures`, `texture.setSource(el)` | 지원. **WebGL 백엔드만**, WebGPU는 대기 중 |
|
||||
| **PixiJS** | `rendering.HTMLSource` | 지원 (WebGL & WebGPU) |
|
||||
| **Babylon.js** | HTML Texture | 지원 |
|
||||
| **CanvasUI** (canvasui.dev, David Haz) | 40+ 이펙트 컴포넌트, shadcn 레지스트리 방식 | React/Solid/Preact/Vue/Svelte/vanilla TS. **런타임 지원 감지 + graceful degradation 내장** |
|
||||
| **three-html-render** (repalash) | `installHtmlInCanvasPolyfill()` | **폴리필**. 네이티브 있으면 fast path, 없으면 `foreignObject` 래스터화. MIT |
|
||||
| **Remotion** | `custom-html-in-canvas` 트랜지션 프레젠테이션 | 지원 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 영상의 "아직 못 쓴다"는 말과 실제의 차이
|
||||
|
||||
노마드코더 영상은 "Chrome Canary의 플래그 뒤에 있다"고만 말하는데, 정확히는:
|
||||
|
||||
- 영상 시점 기준으로도 **Origin Trial이 이미 열려 있었다** (M148 = 2026-05-05 Stable). 즉 사이트 소유자가 토큰을 등록하면 **일반 Chrome Stable 사용자에게도 동작**시킬 수 있다.
|
||||
- 영상의 "시그니처와 API의 기본 모양이 아직 바뀔 수 있다"는 경고는 **정확하다.** 실제로 2026-04~06에 WebGL/WebGPU 시그니처가 바뀌었고, WICG 공식 데모조차 try/catch로 신·구 양쪽을 지원하고 있다.
|
||||
- 영상의 API 이름(`canvas place element`, `drawElement`)은 **구 명칭**이다. 현재는 `html-in-canvas` / `drawElementImage`.
|
||||
700
research/canvas/03-code-examples.md
Normal file
700
research/canvas/03-code-examples.md
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
# 03. 동작하는 코드 예제
|
||||
|
||||
> 전제: Chrome Canary 149+ 에서 `chrome://flags/#canvas-draw-element` = **Enabled**, 또는 Origin Trial 토큰 주입.
|
||||
> 모든 예제는 `01-api-spec.md`의 IDL 기준(2026-08 explainer)이다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 모든 예제가 지키는 4가지 규칙
|
||||
|
||||
1. **`<canvas>`에 `layoutsubtree`**, 그리고 그리려는 요소는 **직계 자식**.
|
||||
2. **그리기는 `onpaint` 안에서만.** 밖에서 그리면 직전 프레임 스냅샷이 쓰이고, 첫 스냅샷 전이면 `InvalidStateError`.
|
||||
3. **`canvas.requestPaint()`로 킥스타트.** 매 프레임 루프가 필요하면 `onpaint` 끝에서 다시 호출.
|
||||
4. **반환된 `DOMMatrix`를 `element.style.transform`에 반영.** 안 하면 클릭이 엉뚱한 데 떨어진다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 최소 예제 (WICG explainer 원문)
|
||||
|
||||
```html
|
||||
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const ctx = document.getElementById('canvas').getContext('2d');
|
||||
|
||||
canvas.onpaint = () => {
|
||||
ctx.reset();
|
||||
const transform = ctx.drawElementImage(form_element, 100, 0);
|
||||
form_element.style.transform = transform.toString();
|
||||
};
|
||||
|
||||
// 흐릿함 방지: 캔버스 그리드를 device scale factor에 맞춘다.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
</script>
|
||||
```
|
||||
|
||||
`devicePixelContentBoxSize`를 못 쓰는 환경까지 챙기는 Chrome 블로그 버전:
|
||||
|
||||
```js
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const dpc = entry.devicePixelContentBoxSize;
|
||||
canvas.width = dpc ? dpc[0].inlineSize
|
||||
: Math.round(entry.contentRect.width * devicePixelRatio);
|
||||
canvas.height = dpc ? dpc[0].blockSize
|
||||
: Math.round(entry.contentRect.height * devicePixelRatio);
|
||||
});
|
||||
const supportsDPCB = typeof ResizeObserverEntry !== 'undefined'
|
||||
&& 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
|
||||
observer.observe(canvas, supportsDPCB ? { box: 'device-pixel-content-box' } : {});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 2D 반사(reflection) 이펙트 — 영상 데모의 정확한 재구성
|
||||
|
||||
버튼은 **진짜 클릭 가능한 HTML 버튼**이고, 아래쪽 뒤집힌 반사는 캔버스가 그린 픽셀이다. 완성 단일 파일.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>HTML-in-Canvas: reflection</title>
|
||||
<style>
|
||||
body { margin: 0; background: #0b0b10; display: grid; place-items: center; height: 100vh; }
|
||||
canvas { width: 480px; height: 300px; }
|
||||
|
||||
/* 캔버스 자식은 평범한 HTML/CSS 그대로 */
|
||||
#btn {
|
||||
font: 600 20px/1 system-ui, sans-serif;
|
||||
padding: 16px 32px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #ff5fa2; /* 반투명 배경은 피할 것 (셰이더/블렌딩이 비쳐 나온다) */
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background .2s, scale .12s;
|
||||
}
|
||||
#btn:hover { background: #a05cff; scale: 1.05; }
|
||||
#btn:active { scale: .95; }
|
||||
</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');
|
||||
|
||||
// 캔버스 좌표(= device pixel) 기준 배치 위치. CSS px 로 정의하고 dpr 로 곱한다.
|
||||
const X_CSS = 100, Y_CSS = 90;
|
||||
|
||||
canvas.onpaint = () => {
|
||||
// 캔버스 그리드 / CSS 크기 비율 = 실효 dpr
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const s = canvas.width / rect.width;
|
||||
|
||||
const x = X_CSS * s;
|
||||
const y = Y_CSS * s;
|
||||
const h = btn.offsetHeight * s; // 버튼 높이(캔버스 좌표)
|
||||
|
||||
ctx.reset();
|
||||
|
||||
// ── 1) 반사본: y = (Y+H) 축을 기준으로 뒤집고 흐리게
|
||||
ctx.save();
|
||||
ctx.translate(0, 2 * (y + h));
|
||||
ctx.scale(1, -1);
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.drawElementImage(btn, x, y); // 반환값은 버리는 게 맞다 (이건 "복사본")
|
||||
ctx.restore();
|
||||
|
||||
// ── 2) 실제 본체: 이 호출의 반환 transform 만 DOM 에 반영한다
|
||||
const t = ctx.drawElementImage(btn, x, y);
|
||||
btn.style.transform = t.toString();
|
||||
|
||||
// ── 3) 매 프레임 루프 (rAF 대응). 정적이면 이 줄을 빼라.
|
||||
canvas.requestPaint();
|
||||
};
|
||||
|
||||
// 최초 스냅샷 확보 + 파이프라인 킥스타트
|
||||
canvas.requestPaint();
|
||||
|
||||
// 캔버스 그리드를 device pixel 에 맞춤 (흐림 방지)
|
||||
new ResizeObserver(([entry]) => {
|
||||
const dpc = entry.devicePixelContentBoxSize;
|
||||
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * devicePixelRatio);
|
||||
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * devicePixelRatio);
|
||||
canvas.requestPaint();
|
||||
}).observe(canvas, { box: 'device-pixel-content-box' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### 왜 `save()/restore()`와 두 번의 `drawElementImage`인가
|
||||
|
||||
- `drawElementImage`는 **캔버스의 현재 CTM을 적용**한다. 그래서 flip/alpha를 CTM+`globalAlpha`로 걸고 한 번 그리면 반사본이, 원복 후 한 번 더 그리면 본체가 나온다.
|
||||
- 소스 요소에 걸린 CSS transform은 **그리기에서 무시**되므로, `btn.style.transform`을 매 프레임 덮어써도 캔버스 그림은 영향받지 않는다 → **무한 루프가 생기지 않는다.** (그리고 explainer가 "transform 변경은 `paint`를 발화시키지 않는다"고 명시)
|
||||
- 반사 좌표 검산: 반사 변환은 `y' = 2(y+h) − y`. 요소 상단 `y` → `y+2h`, 하단 `y+h` → `y+h`. 즉 본체 바로 아래에 위아래 뒤집혀 붙는다.
|
||||
|
||||
### 클릭 리플 추가 (영상의 확장분)
|
||||
|
||||
```js
|
||||
let ripples = [];
|
||||
btn.addEventListener('click', (e) => {
|
||||
const r = btn.getBoundingClientRect();
|
||||
ripples.push({ x: e.clientX - r.left, y: e.clientY - r.top, t: performance.now() });
|
||||
});
|
||||
```
|
||||
`onpaint` 안에서 본체를 그린 뒤 `ctx.getImageData()`로 픽셀을 읽어 반경 기반으로 UV를 밀어내면 된다. 이 부분은 이 API와 무관한 **평범한 캔버스 픽셀 수학**이다 (영상의 표현 그대로). 다만 **`getImageData`는 느리므로 실전에서는 §4의 WebGL 경로가 정답**이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. `paint` 이벤트 루프 — 두 가지 패턴
|
||||
|
||||
### 패턴 A: 이벤트 구동 (기본값, 저비용)
|
||||
|
||||
호버·포커스·입력 등 **HTML이 실제로 바뀔 때만** 다시 그린다. 대부분의 UI 이펙트에 이게 맞다.
|
||||
|
||||
```js
|
||||
canvas.onpaint = (event) => {
|
||||
ctx.reset();
|
||||
for (const el of event.changedElements) { // 바뀐 요소만 알려준다
|
||||
const t = ctx.drawElementImage(el, 0, 0);
|
||||
el.style.transform = t.toString();
|
||||
}
|
||||
};
|
||||
canvas.requestPaint(); // 최초 1회
|
||||
```
|
||||
|
||||
`PaintEvent.changedElements`는 `FrozenArray<Element>`다. 캔버스에 자식이 여럿일 때 부분 갱신에 쓴다.
|
||||
|
||||
### 패턴 B: 매 프레임 루프 (애니메이션/셰이더)
|
||||
|
||||
```js
|
||||
canvas.onpaint = () => {
|
||||
const now = performance.now();
|
||||
ctx.reset();
|
||||
drawEverything(now);
|
||||
canvas.requestPaint(); // 다음 프레임 예약 → requestAnimationFrame 과 같은 역할
|
||||
};
|
||||
canvas.requestPaint();
|
||||
```
|
||||
|
||||
> `requestAnimationFrame`으로 루프를 돌리면서 그 안에서 `drawElementImage`를 부르면 **직전 프레임 스냅샷**이 쓰여 1프레임 지연이 생기고, 첫 프레임에 예외가 난다. **루프는 `requestPaint()`로 도는 게 맞다.**
|
||||
|
||||
### 중첩 canvas 주의
|
||||
|
||||
`paint`는 **역트리 순서**로 발화한다 (자손 → 조상). 조상 캔버스가 자손 캔버스의 결과에 의존하는 합성을 짤 때 이 순서를 전제할 수 있다. 단, **Chromium Canary는 아직 "요소는 가장 가까운 canvas 조상에만 그릴 수 있다"로 제한**하고 있으므로 (스펙과 구현 불일치) 중첩 구조는 신중히.
|
||||
|
||||
---
|
||||
|
||||
## 4. WebGL 텍스처화 — 전체 화면 셰이더 왜곡
|
||||
|
||||
라이브 HTML을 텍스처로 올려 프래그먼트 셰이더로 왜곡한다. 완성 단일 파일.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>HTML-in-Canvas: WebGL distortion</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #07070c; }
|
||||
#gl { display: block; width: 100vw; height: 100vh; }
|
||||
#ui {
|
||||
width: 100vw; height: 100vh; box-sizing: border-box;
|
||||
padding: 12vh 10vw;
|
||||
font: 16px/1.6 system-ui, sans-serif; color: #eaeaf2;
|
||||
background: #12121b; /* 불투명하게 */
|
||||
}
|
||||
#ui h1 { font-size: 56px; margin: 0 0 24px; letter-spacing: -.03em; }
|
||||
#ui input, #ui button {
|
||||
font: inherit; padding: 12px 16px; border-radius: 10px; border: 1px solid #3a3a52;
|
||||
background: #1c1c29; color: inherit;
|
||||
}
|
||||
#ui input:focus { outline: 2px solid #7b6cff; }
|
||||
</style>
|
||||
|
||||
<canvas id="gl" layoutsubtree>
|
||||
<div id="ui">
|
||||
<h1>Real HTML, real shader.</h1>
|
||||
<p>이 텍스트는 선택·복사·find-in-page가 되고, 아래 입력창은 진짜 입력됩니다.</p>
|
||||
<p><input id="name" placeholder="이름"> <button>보내기</button></p>
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<script type="module">
|
||||
const canvas = document.getElementById('gl');
|
||||
const ui = document.getElementById('ui');
|
||||
const gl = canvas.getContext('webgl2', { antialias: true, premultipliedAlpha: false });
|
||||
|
||||
// ── 셰이더 ────────────────────────────────────────────────
|
||||
const VS = `#version 300 es
|
||||
in vec2 aPos;
|
||||
out vec2 vUv;
|
||||
void main() {
|
||||
// 텍스처는 top-down, WebGL 클립공간은 bottom-up → Y 뒤집기
|
||||
vUv = vec2(aPos.x * 0.5 + 0.5, 0.5 - aPos.y * 0.5);
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const FS = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 vUv;
|
||||
out vec4 outColor;
|
||||
uniform sampler2D uTex;
|
||||
uniform vec2 uMouse; // 0..1
|
||||
uniform float uTime;
|
||||
uniform float uAspect;
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
|
||||
// 커서 주변을 가우시안 감쇠로 끌어당기는 자기장 왜곡
|
||||
vec2 d = (uv - uMouse) * vec2(uAspect, 1.0);
|
||||
float dist = length(d);
|
||||
float pull = exp(-dist * dist * 90.0) * 0.06;
|
||||
vec2 warped = uv - normalize(d + 1e-6) * pull * vec2(1.0 / uAspect, 1.0);
|
||||
|
||||
// 살짝 흐르는 물결
|
||||
warped.x += sin(uv.y * 30.0 + uTime * 1.6) * 0.0015;
|
||||
|
||||
// 경계에서 색수차
|
||||
float ca = pull * 0.35;
|
||||
vec4 c;
|
||||
c.r = texture(uTex, warped + vec2( ca, 0.0)).r;
|
||||
c.g = texture(uTex, warped).g;
|
||||
c.b = texture(uTex, warped - vec2( ca, 0.0)).b;
|
||||
c.a = texture(uTex, warped).a;
|
||||
|
||||
outColor = c;
|
||||
}`;
|
||||
|
||||
function compile(type, src) {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, src); gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||||
return s;
|
||||
}
|
||||
const prog = gl.createProgram();
|
||||
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
||||
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
|
||||
gl.useProgram(prog);
|
||||
|
||||
// ── 풀스크린 삼각형 2개 ────────────────────────────────────
|
||||
const vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
const vbo = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
|
||||
const loc = gl.getAttribLocation(prog, 'aPos');
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
// ── 텍스처 ────────────────────────────────────────────────
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
// 텍스트에는 mipmap 보다 LINEAR 가 결과가 낫다 (WICG 예제 주석 그대로)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
/** 신/구 texElementImage2D 시그니처를 모두 지원 (WICG 공식 예제 패턴) */
|
||||
function uploadElement(el) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
try {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, el); // 현재 IDL
|
||||
} catch (e) {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, // 구 시그니처
|
||||
gl.UNSIGNED_BYTE, el);
|
||||
}
|
||||
}
|
||||
|
||||
const uTex = gl.getUniformLocation(prog, 'uTex');
|
||||
const uMouse = gl.getUniformLocation(prog, 'uMouse');
|
||||
const uTime = gl.getUniformLocation(prog, 'uTime');
|
||||
const uAspect = gl.getUniformLocation(prog, 'uAspect');
|
||||
|
||||
let mouse = [0.5, 0.5];
|
||||
addEventListener('pointermove', (e) => {
|
||||
mouse = [e.clientX / innerWidth, e.clientY / innerHeight];
|
||||
});
|
||||
|
||||
// ── paint 루프 ────────────────────────────────────────────
|
||||
canvas.onpaint = () => {
|
||||
uploadElement(ui); // ← 반드시 paint 안에서
|
||||
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.useProgram(prog);
|
||||
gl.bindVertexArray(vao);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.uniform1i(uTex, 0);
|
||||
gl.uniform2f(uMouse, mouse[0], mouse[1]);
|
||||
gl.uniform1f(uTime, performance.now() * 0.001);
|
||||
gl.uniform1f(uAspect, canvas.width / canvas.height);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
|
||||
canvas.requestPaint(); // 매 프레임
|
||||
};
|
||||
canvas.requestPaint();
|
||||
|
||||
new ResizeObserver(([entry]) => {
|
||||
const dpc = entry.devicePixelContentBoxSize;
|
||||
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * devicePixelRatio);
|
||||
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * devicePixelRatio);
|
||||
canvas.requestPaint();
|
||||
}).observe(canvas, { box: 'device-pixel-content-box' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### 이 예제의 히트테스트
|
||||
|
||||
풀스크린 쿼드가 요소를 1:1로 매핑하므로 **DOM 위치가 그려진 위치와 이미 일치**한다 → `style.transform` 조작이 필요 없다. 다만 셰이더가 픽셀을 왜곡하는 만큼 **클릭 지점과 보이는 지점이 왜곡량만큼 어긋난다.** 왜곡을 작게 유지하거나, 왜곡이 큰 순간(전환 애니메이션)에는 포인터 이벤트를 잠깐 무시하는 식으로 다뤄야 한다. (Codrops가 지적한 트레이드오프)
|
||||
|
||||
### 두 텍스처 블렌딩 — 이 API의 진짜 킬러 패턴
|
||||
|
||||
Matt Rothenberg의 "Burn Transition"(영상의 다크모드 불타는 전환)이 쓰는 구조:
|
||||
|
||||
```html
|
||||
<canvas layoutsubtree>
|
||||
<div id="lightPage">...</div>
|
||||
<div id="darkPage">...</div>
|
||||
</canvas>
|
||||
```
|
||||
```js
|
||||
canvas.onpaint = () => {
|
||||
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texLight);
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, lightPage);
|
||||
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texDark);
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, darkPage);
|
||||
// 프래그먼트 셰이더에서 FBM 노이즈로 burn front 를 만들어 두 텍스처를 픽셀 단위 합성
|
||||
...
|
||||
};
|
||||
```
|
||||
**두 개의 라이브 렌더를 임의의 GLSL 함수로 합성하는 것** — View Transitions는 두 개의 *스냅샷*을 CSS 애니메이션으로 넘기는 게 전부라 이건 CSS에 등가물이 없다.
|
||||
|
||||
---
|
||||
|
||||
## 5. three.js 연동 — `THREE.HTMLTexture` (r184+)
|
||||
|
||||
**가장 실용적인 3D 경로.** three.js가 `layoutsubtree` 설정, 요소 부모 관리, 매 프레임 `matrix3d` 계산, 히트테스트 위임까지 다 해준다. 레이캐스팅이 필요 없다.
|
||||
|
||||
```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 { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
|
||||
|
||||
// 네이티브 API 없으면 foreignObject 폴리필로 대체 (three.js 공식 예제와 동일한 판정)
|
||||
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);
|
||||
renderer.toneMapping = THREE.NeutralToneMapping;
|
||||
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(0xaaaaaa);
|
||||
scene.environment = new THREE.PMREMGenerator(renderer)
|
||||
.fromScene(new RoomEnvironment(), 0.02).texture;
|
||||
|
||||
// ── 텍스처가 될 HTML. document 에 붙일 필요 없다 — HTMLTexture 가 캔버스 자식으로 넣어준다.
|
||||
const element = document.createElement('div');
|
||||
element.style.cssText = 'width:600px;padding:30px;background:#aaa;color:#000;'
|
||||
+ 'font:30px/1.5 sans-serif;text-align:center';
|
||||
element.innerHTML = `
|
||||
Hello world! <b>formatted</b> 텍스트, 이모지 😀, RTL <span dir="rtl">من فارسی</span>
|
||||
<br><input type="text" placeholder="입력해 보세요">
|
||||
<button>Click me</button>`;
|
||||
|
||||
const material = new THREE.MeshStandardMaterial({ roughness: 0, metalness: 0.5 });
|
||||
material.map = new THREE.HTMLTexture(element); // ← 핵심 한 줄
|
||||
|
||||
const mesh = new THREE.Mesh(new RoundedBoxGeometry(200, 200, 200, 10, 10), material);
|
||||
scene.add(mesh);
|
||||
|
||||
// ── 네이티브 포인터 상호작용 (raycast 불필요, 브라우저 히트테스트에 위임)
|
||||
const interactions = new InteractionManager();
|
||||
interactions.connect(renderer, camera);
|
||||
interactions.add(mesh);
|
||||
|
||||
element.querySelector('button').addEventListener('click', function () {
|
||||
this.textContent = 'Clicked!'; // 3D 표면 위에서 그대로 동작
|
||||
});
|
||||
|
||||
renderer.setAnimationLoop((t) => {
|
||||
mesh.rotation.x = Math.sin(t * 0.0005) * 0.5;
|
||||
mesh.rotation.y = Math.cos(t * 0.0008) * 0.5;
|
||||
interactions.update(); // 매 프레임 transform 동기화
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### `HTMLTexture`가 내부에서 하는 일 (three.js 소스 원문 요지)
|
||||
|
||||
```js
|
||||
class HTMLTexture extends Texture {
|
||||
constructor(element, ...) {
|
||||
super(element, ...);
|
||||
this.isHTMLTexture = true;
|
||||
this.generateMipmaps = false;
|
||||
this.needsUpdate = true;
|
||||
|
||||
const parent = element ? element.parentNode : null;
|
||||
if (parent !== null && 'requestPaint' in parent) {
|
||||
parent.onpaint = () => { this.needsUpdate = true; }; // paint 마다 갱신 플래그
|
||||
parent.requestPaint(); // 킥스타트
|
||||
}
|
||||
}
|
||||
dispose() { /* onpaint 해제 후 super.dispose() */ }
|
||||
}
|
||||
```
|
||||
→ `Texture`를 거의 그대로 쓰되 **`paint` 이벤트를 구독해 `needsUpdate`를 세우는 것**이 전부다. 업로드 자체는 렌더러가 `texElementImage2D` / `copyElementImageToTexture`로 처리한다. **WebGLRenderer와 WebGPURenderer 양쪽 지원.**
|
||||
|
||||
### React Three Fiber 조합 (Codrops 패턴)
|
||||
|
||||
```jsx
|
||||
const texture = new HTMLTexture(document.getElementById('computer_screen'));
|
||||
material.uniforms.map.value = texture;
|
||||
material.map = texture;
|
||||
|
||||
const interactions = new InteractionManager();
|
||||
interactions.connect(gl, camera); // useThree() 의 gl, camera
|
||||
interactions.add(screenMeshRef.current);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
material.uniforms.uTime.value = clock.elapsedTime;
|
||||
interactions.update();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. WebGPU — `copyElementImageToTexture()`
|
||||
|
||||
WICG 젤리 슬라이더 데모의 실제 구조.
|
||||
|
||||
```js
|
||||
const canvas = document.getElementById('canvas'); // <canvas layoutsubtree>
|
||||
const element = document.getElementById('value'); // 캔버스 직계 자식
|
||||
|
||||
const targetTexture = device.createTexture({
|
||||
size: [width, height, 1],
|
||||
format: 'rgba8unorm',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING
|
||||
| GPUTextureUsage.COPY_DST
|
||||
| GPUTextureUsage.RENDER_ATTACHMENT,
|
||||
});
|
||||
|
||||
canvas.onpaint = () => {
|
||||
const source = { source: element }; // GPUCopyElementImageSource
|
||||
const dest = { // GPUCopyElementImageDestination
|
||||
destination: { texture: targetTexture },
|
||||
width, height,
|
||||
};
|
||||
|
||||
try {
|
||||
device.queue.copyElementImageToTexture(source, dest); // 현재 IDL
|
||||
} catch (e) {
|
||||
device.queue.copyElementImageToTexture(element, width, height, // 구 시그니처
|
||||
{ texture: targetTexture });
|
||||
}
|
||||
|
||||
// 히트테스트 동기화 — 3D 배치면 canvas.getElementTransform() 사용
|
||||
element.style.transform = `translate(${x}px, ${y}px)`;
|
||||
};
|
||||
canvas.requestPaint();
|
||||
```
|
||||
|
||||
**소스 사각형 크롭**이 필요하면 `source`에 `sx / sy / swidth / sheight`를 추가한다.
|
||||
|
||||
> ⚠️ WICG 젤리 슬라이더 소스에는 `// TODO(pdr): Calculate this correctly using getElementTransform. For now, the transform is just hard-coded.` 라는 주석이 남아 있다. **공식 WebGPU 데모조차 transform 동기화를 하드코딩 중**이라는 뜻으로, 이 경로가 아직 가장 덜 다듬어진 부분이다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 기능 감지 + 어댑터 레이어 (권장 래퍼)
|
||||
|
||||
시그니처가 계속 바뀌므로 호출부를 한 곳에 격리한다.
|
||||
|
||||
```js
|
||||
// hic.js
|
||||
export const HIC = {
|
||||
get supported2D() {
|
||||
return 'requestPaint' in HTMLCanvasElement.prototype
|
||||
&& 'drawElementImage' in CanvasRenderingContext2D.prototype;
|
||||
},
|
||||
get supportedGL() {
|
||||
return 'requestPaint' in HTMLCanvasElement.prototype
|
||||
&& (('texElementImage2D' in WebGL2RenderingContext.prototype) ||
|
||||
('texElementImage2D' in WebGLRenderingContext.prototype));
|
||||
},
|
||||
get supportedGPU() {
|
||||
return typeof GPUQueue !== 'undefined'
|
||||
&& 'copyElementImageToTexture' in GPUQueue.prototype;
|
||||
},
|
||||
|
||||
/** 2D: 그리고 transform 을 동기화. 반환 DOMMatrix. */
|
||||
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: 신/구 시그니처 흡수 */
|
||||
uploadGL(gl, el, internalformat = gl.RGBA8) {
|
||||
try {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, internalformat, el);
|
||||
} catch (_) {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el);
|
||||
}
|
||||
},
|
||||
|
||||
/** WebGPU: 신/구 시그니처 흡수 */
|
||||
uploadGPU(device, el, texture, width, height) {
|
||||
try {
|
||||
device.queue.copyElementImageToTexture(
|
||||
{ source: el },
|
||||
{ destination: { texture }, width, height });
|
||||
} catch (_) {
|
||||
device.queue.copyElementImageToTexture(el, width, height, { texture });
|
||||
}
|
||||
},
|
||||
|
||||
/** 캔버스 그리드를 device pixel 에 맞추고 리사이즈마다 repaint */
|
||||
observeSize(canvas) {
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const dpc = entry.devicePixelContentBoxSize;
|
||||
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * devicePixelRatio);
|
||||
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * devicePixelRatio);
|
||||
canvas.requestPaint?.();
|
||||
});
|
||||
const supportsDPCB = typeof ResizeObserverEntry !== 'undefined'
|
||||
&& 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
|
||||
ro.observe(canvas, supportsDPCB ? { box: 'device-pixel-content-box' } : {});
|
||||
return ro;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
사용 시 **미지원이면 캔버스를 아예 만들지 말고 HTML을 그대로 노출**하는 것이 폴백의 기본형이다:
|
||||
|
||||
```js
|
||||
if (HIC.supported2D) {
|
||||
canvas.setAttribute('layoutsubtree', '');
|
||||
mountEffect(canvas);
|
||||
} else {
|
||||
canvas.replaceWith(...canvas.childNodes); // 자식 HTML 을 그대로 문서에 승격
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. OffscreenCanvas + Worker (explainer 원문)
|
||||
|
||||
무거운 2D 합성을 워커로 넘기는 경로.
|
||||
|
||||
```html
|
||||
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
<script>
|
||||
const workerCode = `
|
||||
let ctx;
|
||||
self.onmessage = (e) => {
|
||||
if (e.data.canvas) ctx = e.data.canvas.getContext('2d');
|
||||
if (e.data.width && e.data.height) {
|
||||
ctx.canvas.width = e.data.width;
|
||||
ctx.canvas.height = e.data.height;
|
||||
}
|
||||
if (e.data.elementImage) {
|
||||
ctx.reset();
|
||||
const transform = ctx.drawElementImage(e.data.elementImage, 100, 0);
|
||||
self.postMessage({transform: transform});
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
worker.postMessage({ canvas: offscreen }, [offscreen]);
|
||||
|
||||
canvas.onpaint = () => {
|
||||
const elementImage = canvas.captureElementImage(form_element); // Transferable
|
||||
worker.postMessage({ elementImage }, [elementImage]);
|
||||
};
|
||||
|
||||
worker.onmessage = ({ data }) => {
|
||||
form_element.style.transform = data.transform.toString(); // 메인에서 동기화
|
||||
};
|
||||
|
||||
new ResizeObserver(([entry]) => {
|
||||
worker.postMessage({
|
||||
width: entry.devicePixelContentBoxSize[0].inlineSize,
|
||||
height: entry.devicePixelContentBoxSize[0].blockSize
|
||||
});
|
||||
canvas.requestPaint();
|
||||
}).observe(canvas, { box: 'device-pixel-content-box' });
|
||||
</script>
|
||||
```
|
||||
|
||||
- 워커에서도 `drawElementImage(elementImage, ...)`가 `DOMMatrix`를 돌려주고, 그걸 `postMessage`로 메인에 되돌려 적용한다.
|
||||
- 위치가 **동적**이면 왕복 지연 때문에 어긋나므로, **메인 스레드에서 위치를 미리 계산해 `ElementImage` 전송과 동시에 `style.transform`을 적용**하라고 explainer가 권한다.
|
||||
- 다 쓴 `ElementImage`는 `close()`로 해제.
|
||||
|
||||
---
|
||||
|
||||
## 9. 실전 체크리스트 (커뮤니티가 실제로 데인 것들)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| ☐ `requestPaint()` 최초 1회 | 안 하면 아무것도 안 그려지고, 첫 그리기에서 `InvalidStateError` |
|
||||
| ☐ 그리기는 `onpaint` 안에서 | 밖이면 1프레임 지연 |
|
||||
| ☐ 캔버스 그리드 = device pixel | `ResizeObserver` + `device-pixel-content-box`. 안 하면 텍스트가 흐리다 |
|
||||
| ☐ 좌표는 device pixel 단위 | CSS px 값에 `canvas.width / rect.width`를 곱하라. 안 하면 Retina에서 어긋난다 |
|
||||
| ☐ 캔버스 자식 크기 = 캔버스 CSS 크기 | 불일치하면 텍스처가 늘어나고 좌표 오차가 페이지 아래로 갈수록 누적 |
|
||||
| ☐ WebGL UV Y 뒤집기 | `vUv = vec2(x*0.5+0.5, 0.5 - y*0.5)` |
|
||||
| ☐ 배경은 불투명 색 | 반투명 배경은 셰이더 효과가 비쳐 나온다 |
|
||||
| ☐ `style.transform` 동기화 | 안 하면 클릭·포커스·find-in-page 하이라이트 위치가 전부 어긋난다 |
|
||||
| ☐ 텍스트에는 `LINEAR` 필터 | mipmap보다 결과가 낫다 (WICG 예제 주석) |
|
||||
| ☐ `<canvas>`는 div가 아니다 | `width:100%` 기본값도 없고 콘텐츠 높이로 자라지도 않는다. 크기를 명시하라 |
|
||||
| ☐ 히트테스트가 필요 없으면 `inert` | WICG WebGL 예제가 `<div id="draw_element" inert>`로 히트테스트를 끈다 |
|
||||
| ☐ 왜곡이 크면 클릭이 어긋남을 인지 | 큰 전환 중에는 `pointer-events: none` 등으로 처리 |
|
||||
| ☐ 스크롤 콘텐츠는 신중히 | 캔버스 안에서는 컴포지터 스레드 스크롤이 불가. 캔버스 전체를 스크롤시키는 편이 낫다 |
|
||||
165
research/canvas/04-fallbacks.md
Normal file
165
research/canvas/04-fallbacks.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# 04. 폴백 전략 비교
|
||||
|
||||
> HTML-in-Canvas를 못 쓰는 브라우저(Firefox, Safari, 그리고 플래그/OT 없는 Chrome Stable 전부)에서 **비슷한 인상**을 내는 방법들.
|
||||
> 핵심 질문 3개로 갈린다: **① 상호작용을 유지하는가 ② 매 프레임 갱신되는가 ③ 렌더된 픽셀을 셰이더가 읽을 수 있는가**
|
||||
|
||||
---
|
||||
|
||||
## 1. 한눈에 보는 비교표
|
||||
|
||||
| 방식 | 원리 | 상호작용<br>유지 | 매 프레임<br>실시간 | 픽셀을<br>셰이더로 | CSS 충실도 | 접근성 | 브라우저 | 비용 |
|
||||
|---|---|:---:|:---:|:---:|---|---|---|---|
|
||||
| **A. 네이티브 HTML-in-Canvas** | 브라우저가 요소 렌더링을 캔버스/텍스처로 직접 복사 | ✅ 완전 | ✅ | ✅ | 100% (브라우저 본체) | ✅ 자동 (DOM 그대로) | Chrome 148+ OT / 플래그만 | 낮음 (GPU 경로) |
|
||||
| **B. `three-html-render` 폴리필** | 네이티브 있으면 fast path, 없으면 `foreignObject` 래스터화 + DOM 오버레이 | ✅ (오버레이가 이벤트 수신) | △ 무효화마다 재래스터 | ✅ | 높음 (브라우저 렌더러 재사용) | ✅ (실 DOM 유지) | 전 브라우저 | 중 (직렬화·이미지 디코드) |
|
||||
| **C. SnapDOM** | DOM → 인라인 SVG `foreignObject` → 래스터화 | ❌ 정지 이미지 | ❌ | ✅ | **매우 높음** (그라디언트·필터·블렌드·transform 포함) | ❌ 별도 대체 필요 | 전 모던 브라우저 | 낮음 (html2canvas 대비 2~16배 빠름) |
|
||||
| **D. `foreignObject` 직접 구현** | 직접 SVG 직렬화 → `<img>` → `drawImage` | ❌ | ❌ | ✅ | 높음 (단 폰트·이미지 인라인 직접 처리) | ❌ | 전 모던 브라우저<br>(Safari 제약 있음) | 낮음, 단 구현 부담 |
|
||||
| **E. html2canvas** | 브라우저 렌더러를 **JS로 재구현**해 DOM을 다시 그림 | ❌ | ❌ | ✅ | **낮음** — 미지원 CSS 목록이 길다 | ❌ | 전 브라우저 | 높음 (느림) |
|
||||
| **F. CSS3DRenderer (three.js)** | 실제 DOM 요소를 `matrix3d`로 3D 배치 | ✅ 완전 | ✅ | **❌ 불가** | 100% (진짜 DOM) | ✅ | 전 브라우저 | 낮음 |
|
||||
| **G. `backdrop-filter` + SVG `feDisplacementMap`** | 배경 레이어를 변위 맵으로 굴절 | ✅ (위 콘텐츠 그대로) | ✅ (CSS 합성) | ❌ (고정 필터 셋) | — | ✅ | **Chromium만.** Safari·Firefox는 `backdrop-filter: url()` 미지원 → 블러로 degrade | 낮음~중 (GPU) |
|
||||
| **H. View Transitions API** | 전/후 **스냅샷 2장**을 CSS로 크로스페이드/클립 | 전환 중 ❌ | 전환 중 ❌ | ❌ | 100% | ✅ | Chrome/Edge/Safari 18+ (Firefox 뒤처짐) | 낮음 |
|
||||
| **I. 배경 캔버스 오버레이** | HTML 뒤/앞에 별도 `<canvas>`를 깔고 이펙트만 그림 | ✅ | ✅ | **❌ HTML을 못 읽음** | — | ✅ | 전 브라우저 | 낮음 |
|
||||
| **J. Satori / 서버 렌더** | 서버에서 HTML/CSS → SVG/PNG | ❌ | ❌ | ✅ | 중 (지원 CSS 부분집합) | ❌ | 무관 | 서버 비용 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 각 방식의 실무 판단
|
||||
|
||||
### A. 네이티브 (기준선)
|
||||
당연히 최선이지만 **오늘 프로덕션 불가**. 나머지 전부는 "A의 어떤 성질을 포기할 것인가"의 문제다.
|
||||
|
||||
### B. `three-html-render` 폴리필 — **가장 현실적인 "같은 코드, 전 브라우저" 해법**
|
||||
|
||||
`https://github.com/repalash/three-html-render` (MIT). three.js 공식 예제 `webgl_materials_texture_html.html`이 실제로 이걸 쓴다.
|
||||
|
||||
```js
|
||||
if (!('requestPaint' in HTMLCanvasElement.prototype)) {
|
||||
const { installHtmlInCanvasPolyfill } = await import('three-html-render/polyfill');
|
||||
installHtmlInCanvasPolyfill();
|
||||
}
|
||||
```
|
||||
|
||||
- **API 표면을 그대로 제공**: `requestPaint()`, `captureElementImage()`, `drawElementImage()`, `texElementImage2D()`, `copyElementImageToTexture()`.
|
||||
- 내부 동작: 캔버스 자식을 오프스크린 host div로 옮기고 → SVG `foreignObject`로 변환 → `<img>`로 2D 캔버스에 렌더 → 텍스처 업로드. DOM 오버레이를 CSS `matrix3d`로 3D 지오메트리에 정렬해 **포인터 이벤트는 진짜 DOM이 받는다.**
|
||||
- Chrome Canary에서는 네이티브 `texElementImage2D` fast path를 자동으로 탄다.
|
||||
- three.js 0.150.0+ 및 standalone WebGL/WebGPU 지원.
|
||||
|
||||
**알려진 한계 (README 명시):**
|
||||
- `textarea` 내부 스크롤이 텍스처에 반영되지 않음
|
||||
- `contenteditable`의 캐럿/선택 영역이 렌더되지 않음
|
||||
- 동적 스타일시트는 수동 무효화 필요
|
||||
- **`:visited`는 폴리필 불가** (브라우저 보안)
|
||||
- 일부 CSS가 `foreignObject` 컨텍스트에서 다르게 렌더됨
|
||||
|
||||
**판정**: three.js 기반 3D UI라면 **이걸 쓰고, 네이티브가 켜지면 자동으로 빨라지는 구조**가 정답.
|
||||
|
||||
### C. SnapDOM — **정지 스냅샷이 필요할 때 최선**
|
||||
|
||||
`https://snapdom.dev` — DOM을 인라인 SVG `foreignObject`로 직렬화한 뒤 브라우저 네이티브 파이프라인으로 래스터화.
|
||||
- "브라우저가 화면에 그릴 수 있는 것이면 대체로 캡처된다" — 그라디언트, 필터, 블렌드 모드, transform, 최신 CSS 포함.
|
||||
- html2canvas 대비 **2~16배 빠름** (복잡한 요소 기준, 단순 요소는 1ms 미만).
|
||||
- **한계**: 정지 이미지다. 매 프레임 재캡처하면 GC와 이미지 디코딩 비용이 프레임을 잡아먹는다.
|
||||
|
||||
**판정**: "클릭 순간에 카드가 산산조각 나며 사라진다" 같은 **일회성 트랜지션**에는 충분하다. 스냅샷을 한 번 뜬 뒤 원본 DOM을 숨기고, 스냅샷 텍스처만 셰이더로 애니메이션하면 A와 시각적으로 구분이 잘 안 된다. 지속적 실시간 왜곡에는 부적합.
|
||||
|
||||
### D. `foreignObject` 직접 구현
|
||||
SnapDOM/html-to-image가 하는 일을 손으로 하는 것. 직접 만들 이유는 거의 없다. 다만 **왜 라이브러리들이 폰트·이미지를 base64로 인라인하는지**는 알아두는 게 좋다 — SVG `<img>`는 외부 리소스를 가져오지 못하고, 외부 리소스가 있으면 **canvas가 taint되어 `getImageData`/WebGL 업로드가 막힌다.**
|
||||
|
||||
주요 함정:
|
||||
- 외부 이미지 → CORS 필요, 아니면 캔버스 taint
|
||||
- 웹폰트 → `@font-face`의 `src`를 data URI로 인라인해야 함
|
||||
- `<canvas>` 자식이 taint 상태면 전체 실패
|
||||
- Safari는 `foreignObject` 안의 일부 CSS 처리가 Chromium과 다르다
|
||||
|
||||
### E. html2canvas — **더 이상 기본 선택지가 아니다**
|
||||
브라우저 렌더링 엔진을 JS로 재구현하는 방식이라, 모든 CSS 속성·레이아웃 예외·텍스트 렌더링 엣지케이스를 손으로 재현해야 한다. 그래서 **"지원하지 않는 CSS 속성" 목록이 길다.** 외부 도메인 이미지는 CORS 헤더 없이는 못 읽는다. 느리다.
|
||||
**판정**: 레거시 유지보수가 아니면 SnapDOM 또는 html-to-image로 갈아타라.
|
||||
|
||||
### F. `CSS3DRenderer` — **"3D 배치"만 필요하면 이게 정답**
|
||||
three.js 애드온. 실제 DOM 요소에 `matrix3d`를 걸어 3D 공간에 배치한다.
|
||||
- **장점**: 100% 진짜 DOM. 상호작용·접근성·폰트 렌더링 완벽. 전 브라우저. 가볍다.
|
||||
- **결정적 한계**: 요소는 **DOM 합성 레이어**로 남으므로 **WebGL 씬과 진짜로 섞이지 않는다.** 깊이 테스트, 오클루전, 셰이더 왜곡, 조명, 그림자, 후처리 어느 것도 적용할 수 없다. 항상 WebGL 캔버스 위/아래 별도 레이어다.
|
||||
- 즉 **평면을 3D로 눕히는 것까지는 되지만, 천이 구겨지거나 유리에 굴절되게는 못 한다.**
|
||||
|
||||
**판정**: "카드를 3D로 기울인다" → CSS3DRenderer. "카드를 물결처럼 왜곡한다" → 불가.
|
||||
|
||||
### G. `backdrop-filter` + SVG `feDisplacementMap` — **Liquid Glass 폴백의 정석**
|
||||
`filter` 프로퍼티로 SVG 필터를 참조해 배경 레이어를 굴절시킨다. `feDisplacementMap`이 두 번째 이미지의 R/G 채널로 첫 이미지를 공간 변위시키므로, 높이장/법선/스넬 법칙 기반 변위 맵을 만들면 진짜 굴절처럼 보인다.
|
||||
|
||||
**브라우저 현실 (2026 기준):**
|
||||
- **Chromium(Chrome/Edge/Brave/Arc)**: `backdrop-filter: url(#filter)` 동작 → 진짜 굴절
|
||||
- **Safari / Firefox**: `backdrop-filter`에 SVG 필터 URL을 **지원하지 않는다.** GPU 가속 안정성 때문에 내장 CSS 필터 함수로 제한. → **자동으로 블러 글래스모피즘으로 degrade** (별도 코드 불필요)
|
||||
- 상호운용 표준화 논의 진행 중: [w3c/svgwg#1142](https://github.com/w3c/svgwg/issues/1142) "define interoperable backdrop displacement/refraction for 'liquid glass' UI"
|
||||
|
||||
**판정**: 영상의 "Apple Liquid Glass" 인상만 필요하다면 **HTML-in-Canvas 없이 CSS+SVG로 상당 부분 재현 가능**하고, 브라우저 지원 범위도 오히려 넓다. 다만 "유리 아래 콘텐츠가 마우스를 따라 실시간으로 출렁이며 색수차까지" 수준은 셰이더가 필요하다.
|
||||
|
||||
### H. View Transitions API — **"상태 A → 상태 B" 전환 한정**
|
||||
- `document.startViewTransition()` 이 전/후 스냅샷을 만들고 `::view-transition-*` 의사 요소를 CSS로 애니메이션한다.
|
||||
- **Matt Rothenberg의 정확한 대비**: "View Transitions는 **두 개의 스냅샷**을 clip-path와 opacity로 애니메이션한다. (HTML-in-Canvas는) **두 개의 라이브 렌더**와 셰이더를 준다."
|
||||
- 즉 불타는 다크모드 전환의 **타이밍과 구조**는 View Transitions로 잡되, **불꽃의 픽셀 단위 시뮬레이션**은 포기하고 CSS `mask-image`(노이즈 PNG/SVG) 애니메이션으로 근사하는 게 현실적인 폴백이다. `mask-image` + `mask-position` 애니메이션으로 "타들어가는 마스크"는 꽤 그럴듯하게 나온다.
|
||||
|
||||
### I. 배경 캔버스 오버레이 — **가장 흔한 오해**
|
||||
"어차피 HTML 뒤에 canvas 깔면 되는 거 아냐?"에 대한 Matt Rothenberg의 답:
|
||||
|
||||
> **"배경 캔버스는 그릴 수는 있어도 읽을 수는 없다(A background canvas can draw. It can't read.)"**
|
||||
|
||||
이 방식으로 **불가능한 것 3가지:**
|
||||
1. **픽셀 단위 왜곡** — CSS `transform`은 요소 박스 전체에 걸린다. div를 회전/스케일/스큐할 수는 있어도, 그 안에 렌더된 **텍스트를 배럴 왜곡**하거나 **input의 아래쪽 절반만 압축**할 수는 없다.
|
||||
2. **두 HTML 상태의 커스텀 블렌딩** — 라이트/다크 테마를 동시에 텍스처로 올려 노이즈·불·스캔라인으로 픽셀 단위 합성.
|
||||
3. **렌더된 콘텐츠에 반응** — 셰이더가 픽셀 휘도를 읽어 어두운 픽셀과 밝은 픽셀을 다르게 처리하거나, 렌더된 HTML의 엣지를 검출해 **바운딩 박스가 아니라 콘텐츠의 실제 모양을 따라가는** 효과.
|
||||
|
||||
**판정**: 글로우, 파티클, 커서 트레일, 배경 그라디언트 같은 **"HTML을 읽을 필요 없는" 효과라면 이 방식으로 충분하고 훨씬 싸다.** 실제로 많은 "화려한" 사이트가 이 정도로 만족한다.
|
||||
|
||||
### J. Satori 등 서버 렌더
|
||||
OG 이미지 생성처럼 **결과가 이미지여도 되는** 경우. 지원 CSS 부분집합이 제한적이지만 서버에서 안정적으로 돈다. 브라우저 인터랙션 효과의 폴백으로는 부적합.
|
||||
|
||||
---
|
||||
|
||||
## 3. 목표별 권장 조합
|
||||
|
||||
| 만들고 싶은 것 | 네이티브 있을 때 | 폴백 |
|
||||
|---|---|---|
|
||||
| **3D 씬 안의 상호작용 UI** (영상의 천 위 포트폴리오, 3D 책) | `THREE.HTMLTexture` + `InteractionManager` | **`three-html-render` 폴리필** (동일 코드, 자동 전환). 3D 배치만 필요하면 `CSS3DRenderer` |
|
||||
| **Liquid Glass / 굴절 오버레이** | WebGL 굴절 셰이더 + `drawElementImage` | **`backdrop-filter` + SVG `feDisplacementMap`** (Chromium 굴절, Safari/FF 블러 degrade) |
|
||||
| **다크모드 불타는 전환** | 두 텍스처 + FBM 셰이더 | **View Transitions + CSS `mask-image` 노이즈 애니메이션** |
|
||||
| **폼 포커스 글로우 / 배경 반응** | 셰이더 한 패스에서 글로우+콘텐츠 합성 | **배경 캔버스 오버레이** (I). 글로우가 폼 *뒤*, 도트 *앞*에 오는 레이어링만 포기 |
|
||||
| **버튼 리플 / 클릭 시 산산조각** | `drawElementImage` + 픽셀 조작 | **SnapDOM으로 1회 스냅샷** → 요소 숨김 → 파티클 애니메이션 |
|
||||
| **HTML → 이미지/영상 내보내기** | `drawElementImage` + `canvas.captureStream()` | **SnapDOM** (클라이언트) 또는 **Satori/Puppeteer** (서버) |
|
||||
| **캔버스 앱의 리치 텍스트 UI** (Figma/Docs류) | `drawElementImage` | 현행 유지 (DOM 오버레이 또는 자체 텍스트 레이아웃) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 실전 폴백 패턴 — CanvasUI 모델
|
||||
|
||||
`canvasui.dev`(David Haz)가 채택한 원칙이 가장 건전하다:
|
||||
|
||||
> "컴포넌트가 **런타임에 지원 여부를 감지하고 우아하게 degrade한다**. API가 없으면 콘텐츠는 그냥 일반 HTML로 렌더되고, 그래도 돌 수 있는 이펙트 부분은 계속 돈다."
|
||||
|
||||
구현 형태:
|
||||
|
||||
```js
|
||||
const HAS_HIC = 'requestPaint' in HTMLCanvasElement.prototype;
|
||||
|
||||
if (HAS_HIC) {
|
||||
canvas.setAttribute('layoutsubtree', '');
|
||||
mountShaderEffect(canvas); // 셰이더 왜곡 전체 경로
|
||||
} else {
|
||||
canvas.replaceWith(...canvas.childNodes); // HTML 을 문서로 승격
|
||||
mountCssOnlyEffect(container); // backdrop-filter / transition / mask 로 근사
|
||||
}
|
||||
```
|
||||
|
||||
**핵심 설계 규칙 3가지:**
|
||||
1. **HTML을 먼저 쓰고 캔버스를 나중에 씌운다.** 캔버스가 없어도 페이지가 완성되어 있어야 한다.
|
||||
2. **캔버스는 장식이지 구조가 아니다.** 레이아웃·포커스 순서·읽기 순서는 전부 HTML이 결정한다.
|
||||
3. **효과의 "의미"와 "구현"을 분리한다.** "제출 시 폼이 사라진다"는 의미는 셰이더 왜곡으로도, CSS `scale`+`opacity`로도 표현된다. 폴백은 열화판이지 부재가 아니어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 비용·성능 메모
|
||||
|
||||
- **A(네이티브)**: 스냅샷은 브라우저 내부 디스플레이 리스트에서 나오므로 직렬화·디코딩이 없다. 다만 explainer가 경고하듯 **캔버스 안 콘텐츠는 컴포지터 스레드 스크롤/애니메이션 혜택을 잃고 JS에 묶인다.** 캔버스 안에 스크롤 영역을 넣기보다 캔버스 전체를 스크롤시켜라.
|
||||
- **B/C/D(foreignObject)**: 매 캡처마다 DOM 직렬화 → SVG 파싱 → 이미지 디코드. 60fps 지속 갱신에는 부적합. **무효화 시점에만 재캡처**하도록 설계할 것.
|
||||
- **E(html2canvas)**: 가장 느리다. 신규 채택 근거 없음.
|
||||
- **F(CSS3DRenderer)**: DOM 합성 레이어라 저렴하지만, 요소가 많으면 레이어 폭발.
|
||||
- **G(backdrop-filter)**: GPU 가속이지만 큰 영역에 걸면 비싸다. Safari/FF가 SVG 필터를 backdrop에 안 붙이는 이유가 정확히 이것(GPU 사용량·불안정성).
|
||||
- **I(배경 캔버스)**: 가장 싸다. HTML을 읽을 필요가 없다면 이걸 먼저 검토하라.
|
||||
107
research/canvas/05-sources.md
Normal file
107
research/canvas/05-sources.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# 05. 출처 목록
|
||||
|
||||
> 조사일 2026-08-20. ★ = 1차 출처(사양·구현·공식 발표). 나머지는 2차/커뮤니티.
|
||||
> 총 60개.
|
||||
|
||||
---
|
||||
|
||||
## A. 사양 · 제안 (1차)
|
||||
|
||||
1. ★ https://github.com/WICG/html-in-canvas — WICG 공식 저장소. 이 문서 전체의 기준이 되는 living explainer(README.md)가 여기 있다.
|
||||
2. ★ https://github.com/WICG/html-in-canvas/blob/main/README.md — explainer 원문. `layoutsubtree`, `drawElementImage`, `paint` 이벤트, `captureElementImage`, 전체 IDL, read-back-allowed rendering 목록, 설계 대안 논의.
|
||||
3. ★ https://wicg.github.io/html-in-canvas/ — explainer의 GitHub Pages 렌더링본. 형식 스펙이 아니라 explainer 그 자체(정규 알고리즘 없음).
|
||||
4. ★ https://github.com/WICG/html-in-canvas/blob/main/security-privacy-questionnaire.md — W3C 보안·프라이버시 자기점검 답변 19문항. 무엇이 그려지지 않는지의 근거.
|
||||
5. ★ https://github.com/whatwg/html/pull/11588 — WHATWG HTML 스펙 PR "Add HTML-in-Canvas APIs" (2025-08-21 개설, **미머지**). GC 압박, 교차 캔버스 `ElementImage`, 중첩 canvas 구현 불일치 등 미해결 논의.
|
||||
6. ★ https://github.com/whatwg/html/issues/10650 — 원 이슈 스레드. 웹 개발자 긍정 신호의 출처.
|
||||
7. https://github.com/WICG/html-in-canvas/commits/main/README.md — 커밋 로그. `drawElement`→`drawHTMLElement`→`drawHTML`→`drawElementImage` 개명 이력과 `setHitTestRegions` 폐기 시점의 근거.
|
||||
8. https://github.com/w3ctag/design-reviews/issues/1204 — W3C TAG 디자인 리뷰.
|
||||
|
||||
## B. Chromium 출시 프로세스 (1차)
|
||||
|
||||
9. ★ https://chromestatus.com/feature/5172548013916160 — Chrome Platform Status "HTML-in-canvas". 상태 `In development`, 표준 성숙도, Gecko/WebKit/개발자 신호, 소유자, 동기.
|
||||
10. ★ https://chromestatus.com/api/v0/features/5172548013916160 — 위 항목의 원시 JSON. desktop/android/webview 출시 마일스톤이 모두 `null`임을 확인.
|
||||
11. ★ https://developer.chrome.com/blog/html-in-canvas-origin-trial — Chrome for Developers 공식 블로그 (Thomas Nattestad, Natalia Markoborodova, 최종수정 2026-05-19). OT 안내, 3단계 사용법, WebGL/WebGPU 코드, `getElementTransform` 행렬 유도, 한계.
|
||||
12. ★ https://developer.chrome.com/release-notes/148 — Chrome 148 릴리스 노트. Stable **2026-05-05**, HTML-in-canvas가 Origin Trials 섹션에 등재.
|
||||
13. ★ https://developer.chrome.com/origintrials/#/view_trial/3478467762190286849 — Origin Trial 등록 페이지 (열람에 로그인 필요).
|
||||
14. ★ https://groups.google.com/a/chromium.org/g/blink-dev/c/t_nGEmJ_v4s — **Intent to Experiment: HTML-in-canvas**. OT M148–M151, chromestatus/스펙 링크, Gecko·WebKit 입장, Jake Archibald의 "WebGL에서 텍스처 크기 지정 불가" 지적.
|
||||
15. ★ http://www.mail-archive.com/blink-dev@chromium.org/msg16735.html — **Intent to Extend Experiment**. DevTrial M138 시작, OT 데스크톱 M148–150 → **M154 연장**, 연장 사유("상당한 피드백 + WebGL/WebGPU·프라이버시 중대 변경").
|
||||
16. ★ http://www.mail-archive.com/blink-dev@chromium.org/msg16743.html — 위 연장 요청에 대한 **Mike Taylor LGTM (2026-06-11)**.
|
||||
17. ★ https://groups.google.com/a/chromium.org/g/blink-dev/c/LYJyOdLbOfY — "Ready for Developer Testing: HTML in Canvas: drawElement". 구 메서드명 시절의 DevTrial 공지.
|
||||
18. ★ https://crbug.com/500967896 — Chromium 추적 버그 (`Blink>Canvas`).
|
||||
19. ★ https://chromiumdash.appspot.com/schedule — Chrome 마일스톤 공식 스케줄. M148=2026-05-05, M150=2026-06-30, M152=2026-08-25, M154=**2026-09-22**, M155=2026-10-06.
|
||||
20. https://groups.google.com/g/html-in-canvas-developer-newsletter — Chrome 팀 운영 개발자 뉴스레터(변경 공지 채널).
|
||||
|
||||
## C. 타 브라우저 입장 (1차)
|
||||
|
||||
21. ★ https://github.com/mozilla/standards-positions/issues/1076 — Mozilla 입장. **open, "Needs proposed position"**. 2024-09-25 개설, Graphics 팀 배정. 공식 입장 미표명 = "No signal".
|
||||
22. ★ https://github.com/WebKit/standards-positions/issues/630 — WebKit 입장. **open, 라벨 없음 = "No signal"**. 이전 제안(canvas place element #403)의 retained-mode 우려로 immediate-mode 설계 회귀했다는 배경 기재.
|
||||
|
||||
## D. 공식 예제 · 데모 (1차)
|
||||
|
||||
23. ★ https://wicg.github.io/html-in-canvas/Examples/complex-text.html — 회전된 복합 텍스트(RTL·세로쓰기·이모지·인라인 이미지·SVG) 2D 데모. `ctx.rotate` + `drawElementImage` + `requestPaint()` 킥스타트 패턴.
|
||||
24. ★ https://wicg.github.io/html-in-canvas/Examples/text-input.html — 캔버스 안 완전 동작 폼(체크박스·라디오·range·submit). "Spaceship Control Panel".
|
||||
25. ★ https://wicg.github.io/html-in-canvas/Examples/pie-chart.html — 멀티라인 라벨 파이 차트. 접근성 개선 유스케이스의 대표 예.
|
||||
26. ★ https://wicg.github.io/html-in-canvas/Examples/webGL.html — `texElementImage2D`로 3D 큐브에 HTML. **신/구 시그니처 try/catch 패턴의 원본**.
|
||||
27. ★ https://wicg.github.io/html-in-canvas/Examples/webgpu-jelly-slider/ — **영상의 젤리 슬라이더 원본**. `copyElementImageToTexture`의 현재 dictionary 시그니처 실사용 예 (소스: `Examples/webgpu-jelly-slider/src/index.ts`).
|
||||
28. ★ https://chrome.dev/html-in-canvas/ — Chrome 팀 공식 데모 갤러리 (3D 빌보드, Tokyo 3D 라벨, 3D 책, Fluid Prism, D3 시각화, OffscreenCanvas, iframe 등).
|
||||
29. ★ https://github.com/GoogleChromeLabs/css-web-ui-demos/blob/main/html-in-canvas/awesome-html-in-canvas.md — **"Awesome HTML-in-Canvas"** 커뮤니티 데모·프레임워크 큐레이션 목록.
|
||||
|
||||
## E. 영상에 나온 데모의 원본
|
||||
|
||||
30. https://arrival.space/htmlcanvas — **영상의 "천 위 포트폴리오"**. 게임 안에 걸린 천에 폼이 그려지고 캐릭터가 부딪히는 데모. 작성자 **Thomas Richter-Trummer (@fimbox)**. 소스: https://github.com/fimbox/html-in-canvas/blob/main/plugins/html-cloth.mjs
|
||||
31. https://mattrothenberg.com/notes/html-in-canvas/ — **Matt Rothenberg (2026-04-04)**. 영상의 **"불타는 다크모드 전환"(Demo 3: The Burn Transition)** 과 **"폼 포커스 글로우 + 제출 시 왜곡"(Demo 1: The Focus Ring)** 의 원본. "배경 캔버스는 그릴 수는 있어도 읽을 수는 없다" 논증, 5구역 화염 셰이더 구조, 실전 함정 6가지.
|
||||
32. https://html-in-canvas.dev/demos/liquid-glass/ — **Liquid Glass Distortion** (En Dash Consulting, 2026-04-10 작성 / 2026-08-19 갱신). 굴절·색수차·프레넬·코스틱 셰이더 + 라이브 DOM.
|
||||
33. https://github.com/jeantimex/liquid-glass-html-in-canvas — 또 다른 Liquid Glass 구현 (WebGL).
|
||||
34. https://github.com/jeantimex/glass-effect-webgpu — WebGPU 기반 실시간 리퀴드 글래스 렌더러.
|
||||
35. https://compiz-web.vercel.app/ — Max Leiter. 셰이더 기반 페이지 전환(Compiz 오마주). 소스: https://github.com/MaxLeiter/compiz-web
|
||||
36. https://x.com/wesbos/status/2041594973674483851 — Wes Bos "Duck Hunt TODO" (폼이자 슈팅 게임). 소스: https://github.com/wesbos/hot-tips/blob/main/html-in-canvas/demos/wicg/website-shatter-shooter.html
|
||||
37. https://x.com/wesbos/status/2041974552478052507 — Wes Bos "Wobble Buttons" (리플 버튼).
|
||||
38. https://html-in-canvas.vittoretrivi.dev/examples/vanish-input — 영상의 "제출하면 입력이 사라지는" 효과 원본. 같은 저자의 login / page-curl / basic-ui 예제도 참조. 소스: https://github.com/motiontx/html-in-canvas
|
||||
|
||||
## F. 데모 · 컴포넌트 모음
|
||||
|
||||
39. https://html-in-canvas.dev/ + https://html-in-canvas.dev/demos/ — 비공식이지만 가장 잘 정리된 레퍼런스 사이트. 17개 데모(2D/WebGL/WebGPU), Hello World부터 OffscreenCanvas 워커까지. 작성: En Dash.
|
||||
40. https://hicshowroom.com/ — HiC Showroom. 각 이펙트가 독립 웹 컴포넌트로 되어 있어 한 줄 import로 삽입 가능.
|
||||
41. https://canvasui.dev/ + https://canvasui.dev/docs — **CanvasUI (David Haz)**. 40+ 이펙트 컴포넌트, React/Solid/Preact/Vue/Svelte/vanilla TS, shadcn 레지스트리 방식. **런타임 지원 감지 + graceful degradation 모델**의 참고 사례.
|
||||
42. https://pixijs-html-in-canvas.vercel.app — Zyie. PixiJS 기반 "HTML Laser" 랜딩 페이지(부서졌다 복원).
|
||||
43. https://vav-labs.com/case-studies/quest-signal/ — Vav Labs. Godot 씬에 접근성 있는 DOM 패널을 월드스페이스 WebGL 텍스처로.
|
||||
|
||||
## G. 프레임워크 통합 (1차)
|
||||
|
||||
44. ★ https://threejs.org/docs/#api/en/textures/HTMLTexture — three.js `HTMLTexture` 공식 문서.
|
||||
45. ★ https://threejs.org/examples/webgl_materials_texture_html.html — three.js 공식 예제. `HTMLTexture` + `InteractionManager` + 폴리필 자동 전환의 완성 코드.
|
||||
46. ★ https://github.com/mrdoob/three.js/pull/31233 — HTMLTexture 도입 PR. **dev 브랜치 2026-04-10 머지, r184 포함.** WebGLRenderer/WebGPURenderer 양쪽 지원, `matrix3d` 자동 계산, 레이캐스팅 불필요.
|
||||
47. ★ https://raw.githubusercontent.com/mrdoob/three.js/dev/src/textures/HTMLTexture.js — `HTMLTexture` 구현 원문(50줄). `parent.onpaint`로 `needsUpdate` 세우고 `parent.requestPaint()` 킥스타트.
|
||||
48. ★ https://developer.playcanvas.com/user-manual/graphics/advanced-rendering/html-in-canvas/ — PlayCanvas 공식 문서. `device.supportsHtmlTextures`, `texture.setSource(el)`. **WebGL 백엔드만, WebGPU 대기 중.**
|
||||
49. https://playcanvas.vercel.app/#/misc/html-texture — PlayCanvas 데모.
|
||||
50. https://pixijs.download/release/docs/rendering.HTMLSource.html — PixiJS `HTMLSource` API 문서.
|
||||
51. https://doc.babylonjs.com/features/featuresDeepDive/materials/using/htmlTexture/ — Babylon.js HTML Texture 문서. 플레이그라운드: https://playground.babylonjs.com/#8RDVXG#1
|
||||
52. https://www.remotion.dev/docs/transitions/presentations/custom-html-in-canvas — Remotion의 HTML-in-Canvas 트랜지션 프레젠테이션.
|
||||
|
||||
## H. 폴백 · 폴리필
|
||||
|
||||
53. ★ https://github.com/repalash/three-html-render — **HTML-in-Canvas 폴리필**. `installHtmlInCanvasPolyfill()`이 `requestPaint`/`captureElementImage`/`drawElementImage`/`texElementImage2D`/`copyElementImageToTexture`를 전부 제공. 내부는 `foreignObject` 래스터화 + `matrix3d` DOM 오버레이. 네이티브 있으면 fast path. MIT. 한계 목록(textarea 스크롤, contenteditable 캐럿, `:visited` 불가) 포함.
|
||||
54. https://snapdom.dev/docs/ — SnapDOM. `foreignObject` 직렬화 + 네이티브 래스터화. html2canvas 대비 2~16배 빠르고 CSS 충실도가 훨씬 높다.
|
||||
55. https://dev.to/tinchox5/why-snapdom-beats-html2canvas-for-dom-to-image-capture-14ch — SnapDOM vs html2canvas 원리 비교. html2canvas가 "브라우저 렌더러를 JS로 재구현"하는 방식이라 미지원 CSS 목록이 긴 이유.
|
||||
56. https://npm-compare.com/dom-to-image,html-to-image,html2canvas — dom-to-image / html-to-image / html2canvas 비교.
|
||||
57. https://kube.io/blog/liquid-glass-css-svg/ — CSS + SVG만으로 굴절(refraction) 구현. `feDisplacementMap` 변위 맵 생성 방법.
|
||||
58. https://blog.logrocket.com/how-create-liquid-glass-effects-css-and-svg/ — `backdrop-filter` + SVG 필터 조합과 **Safari/Firefox의 `backdrop-filter: url()` 미지원**, 자동 블러 degrade.
|
||||
59. https://github.com/w3c/svgwg/issues/1142 — "Filter Effects: define interoperable backdrop displacement/refraction for 'liquid glass' UI". 이 폴백을 표준화하려는 진행 중 논의.
|
||||
60. https://threejs.org/docs/#examples/en/renderers/CSS3DRenderer — three.js `CSS3DRenderer`. 진짜 DOM을 3D 배치하지만 WebGL 씬과 깊이·셰이더로 섞이지 않는다.
|
||||
|
||||
## I. 해설 기사 (2차, 실전 노트로 유용)
|
||||
|
||||
61. https://tympanus.net/codrops/2026/05/13/exploring-the-html-in-canvas-proposal/ — Codrops, Vittorio Retrivi (2026-05-13). React Three Fiber + `HTMLTexture` + CRT 셰이더 완성 코드, 전체 화면 후처리가 접근성을 훼손할 수 있다는 지적.
|
||||
62. https://frontendmasters.com/blog/the-web-is-fun-again-first-experiments-with-html-in-canvas/ — Frontend Masters, Amit Sheen (2026-04-21). 픽셀 조작 데모 10종, "크기/리사이즈가 유일하게 덜 익은 부분", `requestPaint()` 킥스타트 필수, `<canvas>`가 div처럼 동작하지 않는 문제.
|
||||
63. https://biggo.com/news/202508030712_Chrome_HTML-in-Canvas_Security_Concerns — 개발자 커뮤니티의 보안·핑거프린팅 우려 정리. **구 API명(`drawElement`, `texElement2D`, `setHitTestRegions`)이 쓰인 시점의 기사**라 명칭 대조용으로 유용.
|
||||
64. https://www.equero.dev/posts/html-in-canvas-wicg-proposal-drawelementimage/ — Enrique Quero. `drawElementImage`가 `foreignObject` 해킹과 html2canvas를 사실상 대체할 것이라는 관점.
|
||||
65. https://imiel.dev/blog/html-in-canvas-wicg-drawelementimage-guide — Imiel Visser. "OffscreenCanvas 이후 최대의 Canvas API 추가"라는 관점의 가이드.
|
||||
66. https://maximov.by/html-in-canvas-guide.html — 실용 가이드. 플래그 활성화, 성능 주의사항.
|
||||
67. https://azukiazusa.dev/en/blog/html-in-canvas-api/ — 일본어권 해설(영문판). API 개요 정리.
|
||||
68. https://flaviocopes.com/canvas-ui/ — Flavio Copes. CanvasUI 소개.
|
||||
69. https://dev.to/manikant92/google-io-2026-quietly-ended-a-20-year-old-web-problem-meet-the-html-in-canvas-api-4h9d — Google I/O 2026 맥락에서의 소개.
|
||||
70. https://developer.chrome.com/docs/modern-web-guidance — Chrome의 AI 코딩 도구용 최신 웹 가이던스(HTML-in-Canvas 항목 포함). 저장소: https://github.com/GoogleChrome/guidance
|
||||
|
||||
## J. 참고 (영상 원본)
|
||||
|
||||
71. `D:\workspace\designpaca\research\youtube\nomad-html.en.srt` / `nomad-html.ko.srt` — 노마드코더 "이게 진짜 HTML이라고?" 자막 원문. 챕터: 0:00 인트로 / 2:30 왜 불가능했나 / 2:51 작동 원리 / 3:52 직접 만들기(반사 버튼) / 6:03 아직 쓸 수 없다.
|
||||
36
research/canvas/_raw/awesome.md
Normal file
36
research/canvas/_raw/awesome.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Awesome HTML-in-Canvas
|
||||
|
||||
This is a collection of resources to help developers build with HTML-in-Canvas.
|
||||
|
||||
Check out the HTML-in-canvas deployed at [chrome.dev](https://chrome.dev/html-in-canvas/) or view the source code [here](https://github.com/GoogleChromeLabs/css-web-ui-demos/tree/main/html-in-canvas).
|
||||
|
||||
## HTML-in-Canvas demos by the ecosystem
|
||||
This is a curated list of links to awesome HTML-in-canvas demos created by the ecosystem. Note that the demos featured here are contributed by third-party developers and are not created or maintained by Google. Read the [contribution guidelines](https://github.com/GoogleChromeLabs/css-web-ui-demos/blob/main/CONTRIBUTING.md#add-a-demo-to-the-awesome-html-in-canvas-list) to suggest another demo.
|
||||
|
||||
| Demo | Description | Author | Source code |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| [Duck Hunt TODO](https://x.com/wesbos/status/2041594973674483851) | A form that's also a shooting game | [Wes Bos](https://github.com/wesbos) | [Source](https://github.com/wesbos/hot-tips/blob/main/html-in-canvas/demos/wicg/website-shatter-shooter.html) |
|
||||
| [Wobble Buttons](https://x.com/wesbos/status/2041974552478052507) | Interactive ripple-effect buttons | [Wes Bos](https://github.com/wesbos) | [Source](https://github.com/wesbos/hot-tips/blob/main/html-in-canvas/demos/wicg/ripple-buttons.html) |
|
||||
| [Compiz Web](https://compiz-web.vercel.app/) | Shader-driven web page transitions demo | [Max Leiter](https://github.com/MaxLeiter) | [Source](https://github.com/MaxLeiter/compiz-web) |
|
||||
| [HTML cloth](https://arrival.space/htmlcanvas) | Customize a form on a hanging cloth inside a game | [Thomas Richter-Trummer](https://github.com/fimbox) | [Source](https://github.com/fimbox/html-in-canvas/blob/main/plugins/html-cloth.mjs) |
|
||||
| [PixiJS HTML Laser](https://pixijs-html-in-canvas.vercel.app) | Interactive landing page that shatters and heals over time | [Zyie](https://github.com/Zyie) | [Source](https://github.com/Zyie/pixijs-html-in-canvas) |
|
||||
| [Quest Signal](https://vav-labs.com/case-studies/quest-signal/) | A playable Godot scene with accessible, interactive DOM panels rendered as world-space WebGL textures | [Vav Labs](https://vav-labs.com/) | [Source](https://github.com/Vav-Labs/quest-signal) |
|
||||
| More | demos | coming | soon... |
|
||||
|
||||
## Framework Support
|
||||
This is a list of frameworks that have added support for HTML-in-Canvas along with the documentation
|
||||
| Framework | Description | Documentation | Sample Code |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| [Three.js](https://threejs.org/) | JavaScript library used to create and display animated 3D computer graphics with WebGL & WebGPU | [HTMLTexture](https://goo.gle/HIC-threejs) | [Sample](https://goo.gle/HIC-threejs-example) |
|
||||
| [PlayCanvas](https://playcanvas.com/) | Open source engine and tools for building amazing 3D experiences | [html-texture](https://goo.gle/HIC-playcanvas) | [Sample](https://goo.gle/HIC-playcanvas-example) |
|
||||
| [PixiJS](https://pixijs.com/) | Fast, lightweight 2D rendering library for WebGL & WebGPU | [HTMLSource](https://pixijs.download/release/docs/rendering.HTMLSource.html) | [Sample](https://pixijs-html-in-canvas.vercel.app/) |
|
||||
| [Babylon.js](https://babylonjs.com/) | Babylon.js: Powerful, Beautiful, Simple, Open 3D engine for the web | [HTML Texture](https://doc.babylonjs.com/features/featuresDeepDive/materials/using/htmlTexture/) | [Sample](https://playground.babylonjs.com/#8RDVXG#1) |
|
||||
| [CanvasUI](https://canvasui.dev/) | An open source library of tasteful html-in-canvas & WebGL components. | [Introduction](https://canvasui.dev/docs) | [Sample](https://canvasui.dev/docs/components/bend) |
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**Important note on external content**: The demos linked in the [HTML-in-Canvas demos by the ecosystem](#html-in-canvas-demos-by-the-ecosystem) section are created by third-party developers and are not created, maintained, or supported by Google. Please be aware of the following:
|
||||
|
||||
* No endorsement: Inclusion of these links does not constitute an endorsement or recommendation by Google.
|
||||
* Subject to change: Content, functionality, and availability are at the sole discretion of the third-party owners and may change or be removed without notice.
|
||||
* No liability: Google assumes no responsibility or liability for the accuracy, legality, or performance of these demos.
|
||||
50
research/canvas/_raw/ex-complex-text.html
Normal file
50
research/canvas/_raw/ex-complex-text.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Demo of complex text in canvas</title>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
border: 1px solid blue;
|
||||
width: 638px;
|
||||
height: 318px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<canvas id="canvas" width="638" height="318" layoutsubtree="true">
|
||||
<div id="draw_element" style="width: 550px;">
|
||||
Hello from <a href="https://github.com/WICG/html-in-canvas">html-in-canvas</a>!
|
||||
<br>I'm multi-line, <b>formatted</b>,
|
||||
rotated text with emoji (😀), RTL text
|
||||
<span dir=rtl>من فارسی صحبت میکنم</span>,
|
||||
vertical text,
|
||||
<p style="writing-mode: vertical-rl;">
|
||||
这是垂直文本
|
||||
</p>
|
||||
an inline image (<img width="150" src="wolf.jpg">), and
|
||||
<svg width="50" height="50">
|
||||
<circle cx="25" cy="25" r="20" fill="green" />
|
||||
<text x="25" y="30" font-size="15" text-anchor="middle" fill="#fff">
|
||||
SVG
|
||||
</text>
|
||||
</svg>!
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.onpaint = (event) => {
|
||||
ctx.reset();
|
||||
ctx.rotate((15 * Math.PI) / 180);
|
||||
ctx.translate(80 * devicePixelRatio, -20 * devicePixelRatio);
|
||||
let transform = ctx.drawElementImage(draw_element, 0, 0);
|
||||
draw_element.style.transform = transform.toString();
|
||||
};
|
||||
canvas.requestPaint(); // Request an initial paint event.
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
</script>
|
||||
87
research/canvas/_raw/ex-pie-chart.html
Normal file
87
research/canvas/_raw/ex-pie-chart.html
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Pie chart</title>
|
||||
|
||||
<style>
|
||||
.pie {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
}
|
||||
.pie .label {
|
||||
text-align: center;
|
||||
max-width: 40%;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
.pie .label .val {
|
||||
display: block;
|
||||
font-size: xx-large;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<canvas layoutsubtree class="pie" role="list" aria-label="Pie Chart">
|
||||
<div class="label" role="listitem" tabindex="0" data-val="0.45" data-color="tomato">
|
||||
<span class="val">45%</span>Apple
|
||||
</div>
|
||||
<div class="label" role="listitem" tabindex="0" data-val="0.35" data-color="cornflowerblue">
|
||||
<span class="val">35%</span>Blackberry / Bramble
|
||||
</div>
|
||||
<div class="label" role="listitem" tabindex="0" data-val="0.20" data-color="gold">
|
||||
<span class="val">20%</span>Durian
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const canvas = document.querySelector('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.onpaint = () => {
|
||||
ctx.reset();
|
||||
|
||||
// 1. Center the coordinate system.
|
||||
const radius = 0.95 * Math.min(canvas.width, canvas.height) / 2;
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
|
||||
let angle = 0;
|
||||
let focusedPath = null;
|
||||
for (const label of canvas.children) {
|
||||
const slice = Number(label.dataset.val) * Math.PI * 2;
|
||||
|
||||
// 2. Draw the wedge.
|
||||
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
|
||||
grad.addColorStop(0, `color-mix(${label.dataset.color}, white 40%)`);
|
||||
grad.addColorStop(1, label.dataset.color);
|
||||
ctx.fillStyle = grad;
|
||||
const path = new Path2D();
|
||||
path.moveTo(0, 0);
|
||||
path.arc(0, 0, radius, angle, angle + slice);
|
||||
path.closePath();
|
||||
ctx.fill(path);
|
||||
if (document.activeElement === label)
|
||||
focusedPath = path;
|
||||
|
||||
// 3. Draw the label element, and update its transform.
|
||||
const mid = angle + slice / 2;
|
||||
const label_width = label.offsetWidth * devicePixelRatio;
|
||||
const label_height = label.offsetHeight * devicePixelRatio;
|
||||
const x = Math.cos(mid) * radius * 0.60 - label_width / 2;
|
||||
const y = Math.sin(mid) * radius * 0.60 - label_height / 2;
|
||||
const transform = ctx.drawElementImage(label, x, y);
|
||||
label.style.transform = transform;
|
||||
|
||||
angle += slice;
|
||||
}
|
||||
|
||||
// 4. Draw the focus ring on top of everything else.
|
||||
if (focusedPath)
|
||||
ctx.drawFocusIfNeeded(focusedPath, document.activeElement);
|
||||
};
|
||||
canvas.requestPaint(); // Request an initial paint event.
|
||||
|
||||
// Setup a resize observer to resize the canvas in response to dpr changes.
|
||||
new ResizeObserver(([entry]) => {
|
||||
const box = entry.devicePixelContentBoxSize[0];
|
||||
canvas.width = box.inlineSize;
|
||||
canvas.height = box.blockSize;
|
||||
}).observe(canvas, {box: ['device-pixel-content-box']});
|
||||
</script>
|
||||
68
research/canvas/_raw/ex-text-input.html
Normal file
68
research/canvas/_raw/ex-text-input.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Demo of interactive content in canvas</title>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
border: 1px solid blue;
|
||||
width: 638px;
|
||||
height: 318px;
|
||||
}
|
||||
form p {
|
||||
margin: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<canvas id="canvas" width="638" height="318" layoutsubtree="true">
|
||||
<div id=draw_element style="width: 578px" >
|
||||
<form id="demo-form" action="#" method="get">
|
||||
<fieldset>
|
||||
<legend>🚀 Spaceship Control Panel</legend>
|
||||
<p>
|
||||
<label for="shipName">Ship Name:</label>
|
||||
<input type="text" id="shipName" value="The 'Canvas' Voyager">
|
||||
</p>
|
||||
<p>
|
||||
<input type="checkbox" id="hyperdrive" checked>
|
||||
<label for="hyperdrive">Engage Hyperdrive</label>
|
||||
</p>
|
||||
<fieldset>
|
||||
<legend>Target System</legend>
|
||||
<p>
|
||||
<input type="radio" id="alpha" name="system" value="alpha" checked>
|
||||
<label for="alpha">Alpha Centauri</label>
|
||||
</p>
|
||||
<p>
|
||||
<input type="radio" id="beta" name="system" value="beta">
|
||||
<label for="beta">Betelgeuse</label>
|
||||
</p>
|
||||
</fieldset>
|
||||
<p>
|
||||
<label for="shieldLevel">Shield Strength:</label>
|
||||
<input type="range" id="shieldLevel" min="0" max="100" value="75">
|
||||
</p>
|
||||
<p style="text-align: right; margin: 0;">
|
||||
<button type="submit">Launch!</button>
|
||||
</p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.onpaint = (event) => {
|
||||
ctx.reset();
|
||||
let x = canvas.width / 25;
|
||||
let y = canvas.height / 25;
|
||||
let transform = ctx.drawElementImage(draw_element, x, y);
|
||||
draw_element.style.transform = transform.toString();
|
||||
};
|
||||
canvas.requestPaint(); // Request an initial paint event.
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
</script>
|
||||
193
research/canvas/_raw/ex-webGL.html
Normal file
193
research/canvas/_raw/ex-webGL.html
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Demo of complex text in WebGL</title>
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"
|
||||
integrity="sha512-zhHQR0/H5SEBL3Wn6yYSaTTZej12z0hVZKOv3TwCUXT1z5qeqGcXJLLrbERYRScEDDpYIJhPC1fk31gqR783iQ=="
|
||||
crossorigin="anonymous"
|
||||
defer>
|
||||
</script>
|
||||
<script src="webGLSetup.js"></script>
|
||||
<style>
|
||||
canvas {
|
||||
border: 1px solid blue;
|
||||
width: 638px;
|
||||
height: 318px;
|
||||
}
|
||||
#draw_element {
|
||||
border: 1px solid blue;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<canvas id="gl-canvas" width="638" height="318" layoutsubtree="true">
|
||||
<!-- inert to prevent hit testing in this example. -->
|
||||
<div id="draw_element" inert>
|
||||
Hello world!<br>I'm multi-line, <b>formatted</b>,
|
||||
rotated text with emoji (😀), RTL text
|
||||
<span dir=rtl>من فارسی صحبت میکنم</span>,
|
||||
vertical text,
|
||||
<p style="writing-mode: vertical-rl;">
|
||||
这是垂直文本
|
||||
</p>
|
||||
an inline image (<img width="150" src="wolf.jpg">), and
|
||||
<svg width="50" height="50">
|
||||
<circle cx="25" cy="25" r="20" fill="green" />
|
||||
<text x="25" y="30" font-size="15" text-anchor="middle" fill="#fff">
|
||||
SVG
|
||||
</text>
|
||||
</svg>!
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
let cubeRotation = 0.0;
|
||||
let currentTime = 0;
|
||||
let deltaTime = 0;
|
||||
let render_context = null;
|
||||
|
||||
//
|
||||
// Initialize a texture and load an image.
|
||||
// When the image finished loading copy it into the texture.
|
||||
//
|
||||
function loadTexture(gl) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
const internalFormat = gl.RGBA8;
|
||||
try {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, internalFormat, draw_element);
|
||||
} catch (e) {
|
||||
// The texElementImage2D API was recently changed (see:
|
||||
// https://github.com/WICG/html-in-canvas#idl-changes). This snippet
|
||||
// supports the old syntax temporarily so that the demos do not break.
|
||||
const level = 0;
|
||||
const srcFormat = gl.RGBA;
|
||||
const destType = gl.UNSIGNED_BYTE;
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, level, internalFormat,
|
||||
srcFormat, destType, draw_element);
|
||||
console.log('Note: using old texElementImage2D API');
|
||||
}
|
||||
|
||||
|
||||
// Linear texture filtering produces better results than mipmap with text.
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
// Draw the scene repeatedly
|
||||
function render() {
|
||||
let new_time = performance.now() * 0.001; // convert to seconds
|
||||
deltaTime = new_time - currentTime;
|
||||
currentTime = new_time;
|
||||
|
||||
if (render_context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawScene(render_context.gl,
|
||||
render_context.program,
|
||||
render_context.buffers,
|
||||
render_context.texture,
|
||||
cubeRotation);
|
||||
|
||||
cubeRotation += deltaTime;
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const canvas = document.querySelector('#gl-canvas');
|
||||
// Initialize the GL context
|
||||
const gl = canvas.getContext('webgl2');
|
||||
|
||||
// Only continue if WebGL is available and working
|
||||
if (gl === null) {
|
||||
alert(
|
||||
'Unable to initialize WebGL. Your browser or machine may not support it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertex shader program
|
||||
const vsSource = `
|
||||
attribute vec4 aVertexPosition;
|
||||
attribute vec2 aTextureCoord;
|
||||
|
||||
uniform mat4 uModelViewMatrix;
|
||||
uniform mat4 uProjectionMatrix;
|
||||
|
||||
varying highp vec2 vTextureCoord;
|
||||
|
||||
void main(void) {
|
||||
gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
|
||||
vTextureCoord = aTextureCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
// Fragment shader program
|
||||
const fsSource = `
|
||||
varying highp vec2 vTextureCoord;
|
||||
|
||||
uniform sampler2D uSampler;
|
||||
|
||||
void main(void) {
|
||||
gl_FragColor = texture2D(uSampler, vTextureCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
// Initialize a shader program; this is where all the lighting
|
||||
// for the vertices and so forth is established.
|
||||
const shaderProgram = initShaderProgram(gl, vsSource, fsSource);
|
||||
|
||||
// Collect all the info needed to use the shader program.
|
||||
// Look up which attribute our shader program is using
|
||||
// for aVertexPosition and look up uniform locations.
|
||||
const programInfo = {
|
||||
program: shaderProgram,
|
||||
attribLocations: {
|
||||
vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
|
||||
textureCoord: gl.getAttribLocation(shaderProgram, 'aTextureCoord'),
|
||||
},
|
||||
uniformLocations: {
|
||||
projectionMatrix: gl.getUniformLocation(shaderProgram, 'uProjectionMatrix'),
|
||||
modelViewMatrix: gl.getUniformLocation(shaderProgram, 'uModelViewMatrix'),
|
||||
uSampler: gl.getUniformLocation(shaderProgram, 'uSampler'),
|
||||
},
|
||||
};
|
||||
|
||||
const buffers = initBuffers(gl);
|
||||
|
||||
// Load texture
|
||||
const texture = loadTexture(gl);
|
||||
// Flip image pixels into the bottom-to-top order that WebGL expects.
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
|
||||
render_context = {
|
||||
gl: gl,
|
||||
program: programInfo,
|
||||
buffers: buffers,
|
||||
texture:texture,
|
||||
};
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
onload = () => {
|
||||
const canvas = document.querySelector('#gl-canvas');
|
||||
canvas.onpaint = () => {
|
||||
main();
|
||||
}
|
||||
canvas.requestPaint();
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
}
|
||||
</script>
|
||||
288
research/canvas/_raw/ex-webGLSetup.js
Normal file
288
research/canvas/_raw/ex-webGLSetup.js
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
//
|
||||
// creates a shader of the given type, uploads the source and
|
||||
// compiles it.
|
||||
//
|
||||
function loadShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
|
||||
// Send the source to the shader object
|
||||
gl.shaderSource(shader, source);
|
||||
|
||||
// Compile the shader program
|
||||
gl.compileShader(shader);
|
||||
|
||||
// See if it compiled successfully
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
alert(
|
||||
`An error occurred compiling the shaders: ${gl.getShaderInfoLog(shader)}`,
|
||||
);
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
//
|
||||
// Initialize a shader program, so WebGL knows how to draw our data
|
||||
//
|
||||
function initShaderProgram(gl, vsSource, fsSource) {
|
||||
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
|
||||
// Create the shader program
|
||||
const shaderProgram = gl.createProgram();
|
||||
gl.attachShader(shaderProgram, vertexShader);
|
||||
gl.attachShader(shaderProgram, fragmentShader);
|
||||
gl.linkProgram(shaderProgram);
|
||||
|
||||
// If creating the shader program failed, alert
|
||||
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
|
||||
alert(
|
||||
`Unable to initialize the shader program: ${gl.getProgramInfoLog(
|
||||
shaderProgram,
|
||||
)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shaderProgram;
|
||||
}
|
||||
|
||||
function initBuffers(gl) {
|
||||
const positionBuffer = initPositionBuffer(gl);
|
||||
const textureCoordBuffer = initTextureBuffer(gl);
|
||||
const indexBuffer = initIndexBuffer(gl);
|
||||
|
||||
return {
|
||||
position: positionBuffer,
|
||||
textureCoord: textureCoordBuffer,
|
||||
indices: indexBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
function initPositionBuffer(gl) {
|
||||
// Create a buffer for the square's positions.
|
||||
const positionBuffer = gl.createBuffer();
|
||||
|
||||
// Select the positionBuffer as the one to apply buffer
|
||||
// operations to from here out.
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
|
||||
const positions = [
|
||||
// Front face
|
||||
-1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0,
|
||||
|
||||
// Back face
|
||||
-1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0,
|
||||
|
||||
// Top face
|
||||
-1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0,
|
||||
|
||||
// Bottom face
|
||||
-1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0,
|
||||
|
||||
// Right face
|
||||
1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0,
|
||||
|
||||
// Left face
|
||||
-1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0,
|
||||
];
|
||||
|
||||
// Now pass the list of positions into WebGL to build the
|
||||
// shape. We do this by creating a Float32Array from the
|
||||
// JavaScript array, then use it to fill the current buffer.
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
|
||||
|
||||
return positionBuffer;
|
||||
}
|
||||
|
||||
function initIndexBuffer(gl) {
|
||||
const indexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
||||
|
||||
// This array defines each face as two triangles, using the
|
||||
// indices into the vertex array to specify each triangle's
|
||||
// position.
|
||||
|
||||
// prettier-ignore
|
||||
const indices = [
|
||||
0, 1, 2, 0, 2, 3, // front
|
||||
4, 5, 6, 4, 6, 7, // back
|
||||
8, 9, 10, 8, 10, 11, // top
|
||||
12, 13, 14, 12, 14, 15, // bottom
|
||||
16, 17, 18, 16, 18, 19, // right
|
||||
20, 21, 22, 20, 22, 23, // left
|
||||
];
|
||||
|
||||
// Now send the element array to GL
|
||||
|
||||
gl.bufferData(
|
||||
gl.ELEMENT_ARRAY_BUFFER,
|
||||
new Uint16Array(indices),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
|
||||
return indexBuffer;
|
||||
}
|
||||
|
||||
function initTextureBuffer(gl) {
|
||||
const textureCoordBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer);
|
||||
|
||||
const textureCoordinates = [
|
||||
// Front
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Back
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Top
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Bottom
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Right
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Left
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
];
|
||||
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array(textureCoordinates),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
|
||||
return textureCoordBuffer;
|
||||
}
|
||||
|
||||
function drawScene(gl, programInfo, buffers, texture, cubeRotation) {
|
||||
gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque
|
||||
gl.clearDepth(1.0); // Clear everything
|
||||
gl.enable(gl.DEPTH_TEST); // Enable depth testing
|
||||
gl.depthFunc(gl.LEQUAL); // Near things obscure far things
|
||||
|
||||
// Clear the canvas before we start drawing on it.
|
||||
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
|
||||
// Create a perspective matrix, a special matrix that is
|
||||
// used to simulate the distortion of perspective in a camera.
|
||||
// Our field of view is 35 degrees, with a width/height
|
||||
// ratio that matches the display size of the canvas
|
||||
// and we only want to see objects between 0.1 units
|
||||
// and 100 units away from the camera.
|
||||
|
||||
const fieldOfView = (35 * Math.PI) / 180; // in radians
|
||||
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
|
||||
const zNear = 0.1;
|
||||
const zFar = 100.0;
|
||||
const projectionMatrix = mat4.create();
|
||||
|
||||
// note: glMatrix always has the first argument
|
||||
// as the destination to receive the result.
|
||||
mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
|
||||
|
||||
// Set the drawing position to the "identity" point, which is
|
||||
// the center of the scene.
|
||||
const modelViewMatrix = mat4.create();
|
||||
|
||||
// Now move the drawing position a bit to where we want to
|
||||
// start drawing the square.
|
||||
mat4.translate(
|
||||
modelViewMatrix, // destination matrix
|
||||
modelViewMatrix, // matrix to translate
|
||||
[-0.0, 0.0, -6.0],
|
||||
); // amount to translate
|
||||
mat4.rotate(
|
||||
modelViewMatrix, // destination matrix
|
||||
modelViewMatrix, // matrix to rotate
|
||||
cubeRotation, // amount to rotate in radians
|
||||
[0, 0, 1],
|
||||
); // axis to rotate around (Z)
|
||||
mat4.rotate(
|
||||
modelViewMatrix, // destination matrix
|
||||
modelViewMatrix, // matrix to rotate
|
||||
cubeRotation * 0.7, // amount to rotate in radians
|
||||
[0, 1, 0],
|
||||
); // axis to rotate around (Y)
|
||||
mat4.rotate(
|
||||
modelViewMatrix, // destination matrix
|
||||
modelViewMatrix, // matrix to rotate
|
||||
cubeRotation * 0.3, // amount to rotate in radians
|
||||
[1, 0, 0],
|
||||
); // axis to rotate around (X)
|
||||
|
||||
setPositionAttribute(gl, buffers, programInfo);
|
||||
setTextureAttribute(gl, buffers, programInfo);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);
|
||||
|
||||
// Tell WebGL to use our program when drawing
|
||||
gl.useProgram(programInfo.program);
|
||||
|
||||
// Set the shader uniforms
|
||||
gl.uniformMatrix4fv(
|
||||
programInfo.uniformLocations.projectionMatrix,
|
||||
false,
|
||||
projectionMatrix,
|
||||
);
|
||||
gl.uniformMatrix4fv(
|
||||
programInfo.uniformLocations.modelViewMatrix,
|
||||
false,
|
||||
modelViewMatrix,
|
||||
);
|
||||
|
||||
// Tell WebGL we want to affect texture unit 0
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
|
||||
// Bind the texture to texture unit 0
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
// Tell the shader we bound the texture to texture unit 0
|
||||
gl.uniform1i(programInfo.uniformLocations.uSampler, 0);
|
||||
|
||||
{
|
||||
const vertexCount = 36;
|
||||
const type = gl.UNSIGNED_SHORT;
|
||||
const offset = 0;
|
||||
gl.drawElements(gl.TRIANGLES, vertexCount, type, offset);
|
||||
}
|
||||
}
|
||||
|
||||
// Tell WebGL how to pull out the positions from the position
|
||||
// buffer into the vertexPosition attribute.
|
||||
function setPositionAttribute(gl, buffers, programInfo) {
|
||||
const numComponents = 3; // pull out 2 values per iteration
|
||||
const type = gl.FLOAT; // the data in the buffer is 32bit floats
|
||||
const normalize = false; // don't normalize
|
||||
const stride = 0; // how many bytes to get from one set of values to the next
|
||||
// 0 = use type and numComponents above
|
||||
const offset = 0; // how many bytes inside the buffer to start from
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
|
||||
gl.vertexAttribPointer(
|
||||
programInfo.attribLocations.vertexPosition,
|
||||
numComponents,
|
||||
type,
|
||||
normalize,
|
||||
stride,
|
||||
offset,
|
||||
);
|
||||
gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
|
||||
}
|
||||
|
||||
// tell webgl how to pull out the texture coordinates from buffer
|
||||
function setTextureAttribute(gl, buffers, programInfo) {
|
||||
const num = 2; // every coordinate composed of 2 values
|
||||
const type = gl.FLOAT; // the data in the buffer is 32-bit float
|
||||
const normalize = false; // don't normalize
|
||||
const stride = 0; // how many bytes to get from one set to the next
|
||||
const offset = 0; // how many bytes inside the buffer to start from
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.textureCoord);
|
||||
gl.vertexAttribPointer(
|
||||
programInfo.attribLocations.textureCoord,
|
||||
num,
|
||||
type,
|
||||
normalize,
|
||||
stride,
|
||||
offset,
|
||||
);
|
||||
gl.enableVertexAttribArray(programInfo.attribLocations.textureCoord);
|
||||
}
|
||||
379
research/canvas/_raw/explainer.md
Normal file
379
research/canvas/_raw/explainer.md
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
# HTML-in-Canvas
|
||||
|
||||
This is a proposal for using 2D and 3D `<canvas>` to customize the rendering of HTML content.
|
||||
|
||||
## Status
|
||||
|
||||
This is a living explainer which is continuously updated as we receive feedback.
|
||||
|
||||
The APIs described here are implemented behind a flag in Chromium and can be enabled with `chrome://flags/#canvas-draw-element`.
|
||||
|
||||
## Motivation
|
||||
|
||||
There is no web API to easily render complex layouts of text and other content into a `<canvas>`. As a result, `<canvas>`-based content suffers in accessibility, internationalization, performance, and quality.
|
||||
|
||||
### Use cases
|
||||
|
||||
* **Styled, Laid Out Content in Canvas.** There’s a strong need for better styled text support in Canvas. Examples include chart components (legend, axes, etc.), rich content boxes in creative tools, and in-game menus.
|
||||
* **Accessibility Improvements.** There is currently no guarantee that the canvas fallback content used for `<canvas>` accessibility always matches the rendered content, and such fallback content can be hard to generate. With this API, elements drawn into the canvas will match their corresponding canvas fallback.
|
||||
* **Composing HTML Elements with Effects.** A limited set of CSS effects, such as filters, backdrop-filter, and mix-blend-mode are already available, but there is a desire to use general WebGL shaders with HTML.
|
||||
* **HTML Rendering in a 3D Context.** 3D aspects of sites and games need to render rich 2D content into surfaces within a 3D scene.
|
||||
* **Media Export.** There's a need to export HTML content as images or video.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
The solution introduces three main primitives: an attribute to opt-in canvas elements, methods to draw child elements into the canvas, and an event which fires to handle updates.
|
||||
|
||||
### 1. The `layoutsubtree` attribute
|
||||
The `layoutsubtree` attribute on a `<canvas>` element opts in canvas descendants to layout and participate in hit testing. It causes the direct children of the `<canvas>` to have a stacking context, become a containing block for all descendants, and have paint containment. Canvas element children behave as if they are visible, but their rendering is not visible to the user unless and until they are explicitly drawn into the canvas via a call to `drawElementImage()` (see below).
|
||||
|
||||
### 2. `drawElementImage` (and WebGL/WebGPU equivalents)
|
||||
The `drawElementImage()` method draws a child of the canvas into the canvas, and returns a transform that can be applied to `element.style.transform` to align its DOM location with its drawn location. A snapshot of the rendering of all children of the canvas is recorded just prior to the `paint` event. When called during the `paint` event, `drawElementImage()` will draw the child as it would appear in the current frame. When called outside the `paint` event, the previous frame's snapshot is used. An exception is thrown if `drawElementImage()` is called with a child before an initial snapshot has been recorded.
|
||||
|
||||
**Requirements & Constraints:**
|
||||
* `layoutsubtree` must be specified on the `<canvas>` in the most recent rendering update.
|
||||
* The `element` must be a direct child of the `<canvas>` in the most recent rendering update.
|
||||
* The `element` must have generated boxes (i.e., not `display: none`) in the most recent rendering update.
|
||||
* **Transforms:** The canvas's current transformation matrix is applied when drawing into the canvas. CSS transforms on the source `element` are **ignored** for drawing (but continue to affect hit testing/accessibility, see below).
|
||||
* **Clipping:** Overflowing content (both layout and ink overflow) is clipped to the element's border box.
|
||||
* **Sizing:** The optional `width`/`height` arguments specify a destination rect in canvas coordinates. If omitted, the `width`/`height` arguments default to sizing the element so that it has the same on-screen size and proportion in canvas coordinates as it does outside the canvas.
|
||||
|
||||
**WebGL/WebGPU Support:**
|
||||
Similar methods are added for 3D contexts: `WebGLRenderingContext.texElementImage2D` and `copyElementImageToTexture`.
|
||||
|
||||
### 3. The `paint` event
|
||||
A `paint` event is added to `canvas` elements and fires if the rendering of any canvas children has changed. This event fires just after intersection observer steps have run during [update-the-rendering](https://html.spec.whatwg.org/#update-the-rendering). The event contains a list of the canvas children which have changed. Because CSS transforms on canvas children are ignored for rendering, changing the transform does not cause the `paint` event to fire in the next frame. Canvas drawing commands made in the `paint` event will appear in the current frame, but DOM changes made in the `paint` event will not show up until the subsequent frame. If there are multiple `<canvas>` elements, the `paint` event fires in _reverse_ tree order which ensures that descendants fire `paint` before ancestors.
|
||||
|
||||
To support application patterns which update every frame, a new `requestPaint()` function is added which will cause the `paint` event to fire once, even if no children have changed (analagous to `requestAnimationFrame()`).
|
||||
|
||||
### 4. `captureElementImage`
|
||||
To support `OffscreenCanvas` in workers, a snapshot of an element can be captured as an `ElementImage` snapshot using `canvas.captureElementImage(element)`. These objects can be transferred to a worker and drawn to an `OffscreenCanvas`.
|
||||
|
||||
### Synchronization
|
||||
|
||||
Browser features like hit testing, intersection observer, and accessibility rely on an element's DOM location. To ensure these work, the element's `transform` property should be updated so that the DOM location matches the drawn location.
|
||||
|
||||
<details>
|
||||
<summary>Calculating a CSS transform to match a drawn location</summary>
|
||||
The general formula for the CSS transform is:
|
||||
|
||||
<div align="center">$$T_{\text{origin}}^{-1} \cdot S_{\text{css} \to \text{grid}}^{-1} \cdot T_{\text{draw}} \cdot S_{\text{css} \to \text{grid}} \cdot T_{\text{origin}} $$</div>
|
||||
|
||||
Where:
|
||||
|
||||
* $$T_{\text{draw}}$$: Transform used to draw the element in the canvas grid coordinate system.
|
||||
For `drawElementImage`, this is $$CTM \cdot T_{(\text{x}, \text{y})} \cdot S_{(\text{destScale})}$$, where $$CTM$$ is the Current Transformation Matrix, $$T_{(\text{x}, \text{y})}$$ is a translation from the x and y arguments, and $$S_{(\text{destScale})}$$ is a scale from the width and height arguments.
|
||||
* $$T_{\text{origin}}$$: Translation matrix of the element's computed `transform-origin`.
|
||||
* $$S_{\text{css} \to \text{grid}}$$: Scaling matrix converting CSS pixels to Canvas Grid pixels.
|
||||
</details>
|
||||
|
||||
To assist with synchronization, `drawElementImage()` returns the CSS transform which can be applied to the element to keep its location synchronized. For 3D contexts, the `getElementTransform(element, drawTransform)` helper method is provided which returns the CSS transform, provided a general transformation matrix.
|
||||
|
||||
The transform used to draw the element on the worker thread needs to be synced back to the DOM, and can simply be `postMessage()`'d back to the main thread if the position is static. If the position is dynamic, an alternative is to calculate the position on the main thread and update `element.style.transform` at the same time that the `ElementImage` objects is sent to the worker thread.
|
||||
|
||||
### Basic Example
|
||||
|
||||
<img width="250" height="38" alt="a screenshot showing a form element with a blinking cursor" src="https://github.com/user-attachments/assets/acbdd231-3259-4819-b57e-32e29c460fc9" />
|
||||
|
||||
```html
|
||||
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const ctx = document.getElementById('canvas').getContext('2d');
|
||||
|
||||
canvas.onpaint = () => {
|
||||
ctx.reset();
|
||||
const transform = ctx.drawElementImage(form_element, 100, 0);
|
||||
form_element.style.transform = transform.toString();
|
||||
};
|
||||
|
||||
// Size the canvas grid to match the device scale factor to prevent blurriness.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
</script>
|
||||
```
|
||||
|
||||
### OffscreenCanvas Example
|
||||
|
||||
In this example, `OffscreenCanvas` in a worker is used. The `canvas` child form is captured as an `ElementImage` object in the `paint` event and transferred to the worker for painting.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
<script>
|
||||
const workerCode = `
|
||||
let ctx;
|
||||
self.onmessage = (e) => {
|
||||
if (e.data.canvas) {
|
||||
ctx = e.data.canvas.getContext('2d');
|
||||
}
|
||||
if (e.data.width && e.data.height) {
|
||||
ctx.canvas.width = e.data.width;
|
||||
ctx.canvas.height = e.data.height;
|
||||
}
|
||||
if (e.data.elementImage) {
|
||||
ctx.reset();
|
||||
const transform = ctx.drawElementImage(e.data.elementImage, 100, 0);
|
||||
self.postMessage({transform: transform});
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
|
||||
worker.postMessage({ canvas: offscreen }, [offscreen]);
|
||||
|
||||
canvas.onpaint = (event) => {
|
||||
const elementImage = canvas.captureElementImage(form_element)
|
||||
worker.postMessage({ elementImage: elementImage }, [elementImage]);
|
||||
};
|
||||
|
||||
// Synchronize the element's CSS transform to match its drawn location.
|
||||
worker.onmessage = ({data}) => {
|
||||
form_element.style.transform = data.transform.toString();
|
||||
};
|
||||
|
||||
// Size the canvas grid to match the device scale factor to prevent blurriness.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
worker.postMessage({
|
||||
width: entry.devicePixelContentBoxSize[0].inlineSize,
|
||||
height: entry.devicePixelContentBoxSize[0].blockSize
|
||||
});
|
||||
canvas.requestPaint();
|
||||
});
|
||||
observer.observe(canvas, { box: 'device-pixel-content-box' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### IDL changes
|
||||
|
||||
```idl
|
||||
partial interface HTMLCanvasElement {
|
||||
[CEReactions, Reflect] attribute boolean layoutSubtree;
|
||||
|
||||
attribute EventHandler onpaint;
|
||||
|
||||
void requestPaint();
|
||||
|
||||
ElementImage captureElementImage(Element element);
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
};
|
||||
|
||||
partial interface OffscreenCanvas {
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
};
|
||||
|
||||
interface mixin CanvasDrawElementImage {
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double dx, unrestricted double dy);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double dx, unrestricted double dy,
|
||||
unrestricted double dwidth, unrestricted double dheight);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double sx, unrestricted double sy,
|
||||
unrestricted double swidth, unrestricted double sheight,
|
||||
unrestricted double dx, unrestricted double dy);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double sx, unrestricted double sy,
|
||||
unrestricted double swidth, unrestricted double sheight,
|
||||
unrestricted double dx, unrestricted double dy,
|
||||
unrestricted double dwidth, unrestricted double dheight);
|
||||
};
|
||||
|
||||
CanvasRenderingContext2D includes CanvasDrawElementImage;
|
||||
OffscreenCanvasRenderingContext2D includes CanvasDrawElementImage;
|
||||
|
||||
dictionary WebGLCopyElementImageConfig {
|
||||
GLfloat sx;
|
||||
GLfloat sy;
|
||||
GLfloat swidth;
|
||||
GLfloat sheight;
|
||||
GLsizei width;
|
||||
GLsizei height;
|
||||
};
|
||||
|
||||
partial interface WebGLRenderingContext {
|
||||
void texElementImage2D(GLenum target, GLenum internalformat,
|
||||
(Element or ElementImage) element,
|
||||
optional WebGLCopyElementImageConfig config = {});
|
||||
};
|
||||
|
||||
dictionary GPUCopyElementImageDestination {
|
||||
required GPUImageCopyTextureTagged destination;
|
||||
GPUIntegerCoordinate width;
|
||||
GPUIntegerCoordinate height;
|
||||
};
|
||||
|
||||
dictionary GPUCopyElementImageSource {
|
||||
required (Element or ElementImage) source;
|
||||
float sx;
|
||||
float sy;
|
||||
float swidth;
|
||||
float sheight;
|
||||
};
|
||||
|
||||
partial interface GPUQueue {
|
||||
void copyElementImageToTexture(GPUCopyElementImageSource source,
|
||||
GPUCopyElementImageDestination destination);
|
||||
}
|
||||
|
||||
[Exposed=Window]
|
||||
interface PaintEvent : Event {
|
||||
constructor(DOMString type, optional PaintEventInit eventInitDict);
|
||||
|
||||
readonly attribute FrozenArray<Element> changedElements;
|
||||
};
|
||||
|
||||
dictionary PaintEventInit : EventInit {
|
||||
sequence<Element> changedElements = [];
|
||||
};
|
||||
|
||||
[Exposed=(Window,Worker), Transferable]
|
||||
interface ElementImage {
|
||||
readonly attribute double width;
|
||||
readonly attribute double height;
|
||||
undefined close();
|
||||
};
|
||||
```
|
||||
|
||||
## Demos
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/complex-text.html) ([source](Examples/complex-text.html)) using the `drawElementImage` API to draw rotated complex text.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing rotated, complex text drawn into canvas" src="https://github.com/user-attachments/assets/3ef73e0f-9119-49de-bf84-dfb3a4f5d77c" />
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/pie-chart.html) ([source](Examples/pie-chart.html)) using the `drawElementImage` API to draw a pie chart with multi-line labels.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing a pie chart" src="https://github.com/user-attachments/assets/887eefa2-ffc0-49d6-914b-987b05ccb45d" />
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/webgpu-jelly-slider/) ([source](Examples/webgpu-jelly-slider)) using the WebGPU `copyElementImageToTexture` API to draw a div under a jelly slider.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing a range slider with a jelly effect" src="https://github.com/user-attachments/assets/86ecb8b8-4d3b-49b0-8aa0-5f2df5674045" />
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/webGL.html) ([source](Examples/webGL.html)) using the WebGL `texElementImage2D` API to draw HTML onto a 3D cube.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing html content on a 3D cube" src="https://github.com/user-attachments/assets/689fefe3-56d9-4ae9-b386-32a01ebb0117" />
|
||||
|
||||
A demo of the same thing using an experimental extension of [three.js](https://threejs.org/) is [here](https://raw.githack.com/mrdoob/three.js/htmltexture/examples/webgl_materials_texture_html.html). Further instructions and context are [here](https://github.com/mrdoob/three.js/pull/31233).
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/text-input.html) ([source](Examples/text-input.html)) of interactive content in canvas.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing a form drawn into canvas" src="https://github.com/user-attachments/assets/be2d098f-17ae-4982-a0f9-a069e3c2d1d5" />
|
||||
|
||||
## Read-back-allowed rendering
|
||||
|
||||
The `drawElementImage()` method and any other methods that draw element image snapshots, as well as the paint event, must not reveal any security- or privacy-sensitive information that isn't otherwise observable to author code. This concept is called read-back-allowed rendering because it makes it possible to allow pixel read-back, which is always possible with WebGL and WebGPU.
|
||||
|
||||
Both painting (via canvas pixel readbacks or timing attacks) and invalidation (via `onpaint`) have the potential to leak sensitive information, and this is prevented by excluding sensitive information when painting and invalidating.
|
||||
|
||||
Sensitive information includes:
|
||||
* Cross-origin data in [embedded content](https://html.spec.whatwg.org/#embedded-content-category) (e.g., `<iframe>`, `<img>`), [`<url>`](https://drafts.csswg.org/css-values-4/#url-value) references (e.g., `background-image`, `clip-path`), `<canvas>` elements tained with cross-origin data, and [SVG](https://svgwg.org/svg2-draft/single-page.html#types-InterfaceSVGURIReference) (e.g., `<use>`, `<pattern>`, `<feImage>`). Note that same-origin iframes would still paint, but cross-origin content in them would not.
|
||||
* System colors, themes, or preferences.
|
||||
* Spelling and grammar markers.
|
||||
* Visited link information.
|
||||
* Pending form autofill information not otherwise available to JavaScript.
|
||||
* Subpixel text anti-aliasing.
|
||||
* User preferences for caption and subtitle selection and appearance.
|
||||
* IME pop-ups and distinctive IME text formatting.
|
||||
|
||||
The following new information is not considered sensitive:
|
||||
* Search text (find-in-page) and text-fragment (fragment url) markers.
|
||||
* Scrollbar and form element appearance (these are already detectable in Blink and WebKit through [foreignObject](https://jsfiddle.net/progers/qhawnyeu)).
|
||||
* Caret blink rate.
|
||||
* forced-colors (this information is already available to javascript using the `forced-colors` media query and system colors).
|
||||
|
||||
## Developer Trial (dev trial) Information
|
||||
The HTML-in-Canvas features may be enabled with `chrome://flags/#canvas-draw-element` in Chrome Canary.
|
||||
|
||||
We are most interested in feedback on the following topics:
|
||||
* What content works, and what fails? Which failure modes are most important to fix?
|
||||
* How does the feature interact with accessibility features? How can accessibility support be improved?
|
||||
|
||||
Please file bugs or design issues [here](https://github.com/WICG/html-in-canvas/issues/new).
|
||||
|
||||
## Alternatives considered: `paint` event timing
|
||||
|
||||
A new `paint` event is needed to give developers an opportunity to update their canvas rendering in response to paint changes. This is integrated into [update the rendering](https://html.spec.whatwg.org/#update-the-rendering) so that canvas updates can occur in sync with the DOM.
|
||||
|
||||
There are several opportunities in the [update the rendering](https://html.spec.whatwg.org/#update-the-rendering) steps where the `paint` event could fire:
|
||||
|
||||
* 14\. Run animation frame callbacks.
|
||||
|
||||
* 16.2.1\. Recalculate styles and update layout.
|
||||
|
||||
* 16.2.6\. Deliver resize observers, looping back to 16.2.1 if needed.
|
||||
|
||||
* _Option A: Fire `paint` at resize observer timing, looping back to 16.2.1 if needed._
|
||||
|
||||
* 19\. Run the update intersection observations steps.
|
||||
|
||||
* Paint, where the painted output of elements is calculated. This is not an explicitly named step in [update the rendering](https://html.spec.whatwg.org/#update-the-rendering).
|
||||
|
||||
* _Option B: Fire `paint` immediately after Paint, looping back to 16.2.1 if needed._
|
||||
|
||||
* _Option C: Fire `paint` immediately after Paint._
|
||||
|
||||
* Commit / thread handoff, where the painted output is sent to another process. This is not an explicitly named step in [update the rendering](https://html.spec.whatwg.org/#update-the-rendering).
|
||||
|
||||
Note that the `paint` event is the new event on canvas introduced in this proposal, and the Paint step is the existing operation that browsers perform to record the painted output of the rendering tree following [paint order](https://drafts.csswg.org/css-position-4/#painting-order).
|
||||
|
||||
#### Option A: Fire `paint` at resize observer timing, looping back to 16.2.1 if needed.
|
||||
|
||||
Similar to resize observer, a looping approach is needed to handle cases where the paint event performs modifications (including of elements outside the canvas). There is no mechanism for preventing arbitrary javascript from modifying the DOM. Looping will be required for more conditions than those required by ResizeObserver, such as background style changes. A downside of looping is that the user's canvas code may need to run multiple times per frame.
|
||||
|
||||
One option is to do a synchronous Paint step to snapshot the painted output of canvas children. A downside of this approach is that the Paint step may be expensive to run, and may need to be run multiple times. This approach has unique implementation challenges in Gecko, and possibly other engines, due to architectural limitations.
|
||||
|
||||
A second option is to not run the Paint step synchronously, but instead record a placeholder representing how an element will appear on the next rendering update (see [design](https://docs.google.com/document/d/1YaHCxYqE4uQc4-UTWo4a5pHt2I2MutlwJtsnj5ljEkM/edit?usp=sharing)). This model can be implemented with 2D canvas by buffering the canvas commands until the next Paint step. When the next Paint step occurs, the placeholders would then be replaced with the actual rendering. Canvas operations such as `getImageData` require synchronous flushing of the canvas command buffer and would need to show blank or stale data for the placeholders. Unfortunately, this approach has a fundamental flaw for WebGL because many APIs require flushing (e.g., `getError()`, see callsites of [WaitForCmd](https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/client/implementation_base.h;drc=b3eab4fd06ddbeee84b37224f4cc9d78094fc2f7;l=102)), and calling any of these APIs would result in a deadlock or inconsistent rendering. Therefore, we must run the `paint` event at a time where we have the complete painted display list of an element already available.
|
||||
|
||||
#### Option B: Fire `paint` immediately after Paint, looping back to 16.2.1 if needed.
|
||||
|
||||
See above for the reasons and downsides of looping when there are modifications made during the `paint` event.
|
||||
|
||||
The upside of option B as compared with option A is that it does not require partial Paint of canvas children. An additional downside is that even more steps of [update the rendering](https://html.spec.whatwg.org/#update-the-rendering) need to run on each iteration of the loop.
|
||||
|
||||
#### Option C: Fire `paint` immediately after Paint.
|
||||
|
||||
This is the design approach taken for the API.
|
||||
|
||||
This approach only runs `paint` once per frame, similar to the browser's own Paint step. To solve the issue of javascript being able to perform arbitrary modifications, it is important to ensure that before `paint` runs we have locked in the contents of the rendering update, except for one intentional carve-out: the drawn content of the canvas. DOM invalidations that may occur in the `paint` event apply to the subsequent frame, not the current frame.
|
||||
|
||||
## Alternatives considered: Supporting threaded effects with worker threads
|
||||
|
||||
To support threaded effects, we explored a [design](https://docs.google.com/document/d/1TWe6HP7HMn6y-XnNKppIhgf9FtuXJ6LPgenJJxZDjzg/edit?tab=t.0) where canvas children "snapshots" are sent to a worker thread. In response to threaded scrolling and animations, the worker thread could then render the most up-to-date rendering of the snapshots into OffscreenCanvas. This model requires that javascript can be synchronously called on scroll and animation updates, which is difficult for architectures that perform threaded scroll updates in a restricted process.
|
||||
|
||||
## Future considerations: Supporting threaded effects with an auto-updating canvas
|
||||
|
||||
To support threaded effects such as scrolling and animations, we are considering a future "auto-updating canvas" mode.
|
||||
|
||||
In this model, `drawElementImage` records a placeholder representing the latest rendering. Canvas retains a command buffer which can be automatically replayed following every scroll or animation update. This allows the canvas to re-rasterize with updated placeholders that incorporate threaded scrolling and animations, without needing to block on script. This would enable visual effects that stay perfectly in sync with native scrolling or animations within the canvas, independent of the main thread. This design is viable for 2D contexts, and may be viable for WebGPU with some small API additions.
|
||||
|
||||
## Other documents
|
||||
|
||||
* [Security and Privacy Questionnaire](./security-privacy-questionnaire.md)
|
||||
|
||||
## Authors
|
||||
|
||||
* [Philip Rogers](mailto:pdr@chromium.org)
|
||||
* [Stephen Chenney](mailto:schenney@igalia.com)
|
||||
* [Chris Harrelson](mailto:chrishtr@chromium.org)
|
||||
* [Philip Jägenstedt](mailto:foolip@chromium.org)
|
||||
* [Khushal Sagar](mailto:khushalsagar@chromium.org)
|
||||
* [Vladimir Levin](mailto:vmpstr@chromium.org)
|
||||
* [Fernando Serboncini](mailto:fserb@chromium.org)
|
||||
182
research/canvas/_raw/htex-ex.html
Normal file
182
research/canvas/_raw/htex-ex.html
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>three.js webgl - materials - html texture</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||||
<meta property="og:title" content="three.js webgl - materials - html texture">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://threejs.org/examples/webgl_materials_texture_html.html">
|
||||
<meta property="og:image" content="https://threejs.org/examples/screenshots/webgl_materials_texture_html.jpg">
|
||||
<link type="text/css" rel="stylesheet" href="main.css">
|
||||
<style>
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
#draw_element {
|
||||
width: 600px;
|
||||
background-color: #aaaaaa;
|
||||
color: #000000;
|
||||
font-family: sans-serif;
|
||||
font-size: 30px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
/* border: 10px solid #cccccc; */
|
||||
}
|
||||
#draw_element img {
|
||||
animation: swing 1s ease-in-out infinite alternate;
|
||||
}
|
||||
#draw_element input[type="text"] {
|
||||
font-size: 24px;
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #888;
|
||||
border-radius: 6px;
|
||||
width: 80%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
#draw_element button {
|
||||
font-size: 24px;
|
||||
padding: 8px 20px;
|
||||
margin-top: 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
#draw_element button:hover {
|
||||
background-color: #2196F3;
|
||||
}
|
||||
@keyframes swing {
|
||||
from { transform: rotate(-15deg); }
|
||||
to { transform: rotate(15deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="info">
|
||||
<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - webgl - HTMLTexture
|
||||
</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "../build/three.module.js",
|
||||
"three/addons/": "./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 { installHtmlInCanvasPolyfill } from 'three-html-render/polyfill';
|
||||
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
|
||||
|
||||
if ( ! ( 'requestPaint' in HTMLCanvasElement.prototype ) ) {
|
||||
|
||||
installHtmlInCanvasPolyfill();
|
||||
info.innerHTML += '<br><a href="https://github.com/WICG/html-in-canvas" target="_blank">HTML-in-Canvas API</a> not available. Using <a href="https://github.com/repalash/three-html-render" target="_blank">polyfill</a>.';
|
||||
|
||||
}
|
||||
|
||||
let camera, scene, renderer, mesh, interactions;
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
|
||||
renderer = new THREE.WebGLRenderer( { antialias: true } );
|
||||
|
||||
renderer.toneMapping = THREE.NeutralToneMapping;
|
||||
renderer.setPixelRatio( window.devicePixelRatio );
|
||||
renderer.setSize( window.innerWidth, window.innerHeight );
|
||||
renderer.setAnimationLoop( animate );
|
||||
document.body.appendChild( renderer.domElement );
|
||||
|
||||
camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 1, 2000 );
|
||||
camera.position.z = 500;
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color( 0xaaaaaa );
|
||||
scene.environment = new THREE.PMREMGenerator( renderer ).fromScene( new RoomEnvironment(), 0.02 ).texture;
|
||||
|
||||
// HTML element
|
||||
|
||||
const element = document.createElement( 'div' );
|
||||
element.id = 'draw_element';
|
||||
element.innerHTML = `
|
||||
Hello world!<br>I'm multi-line, <b>formatted</b>,
|
||||
rotated text with emoji (😀), RTL text
|
||||
<span dir=rtl>من فارسی صحبت میکنم</span>,
|
||||
vertical text,
|
||||
<p style="writing-mode: vertical-rl;">
|
||||
这是垂直文本
|
||||
</p>
|
||||
an inline image (<img width="150" src="textures/758px-Canestra_di_frutta_(Caravaggio).jpg">), and
|
||||
<svg width="50" height="50">
|
||||
<circle cx="25" cy="25" r="20" fill="green" />
|
||||
<text x="25" y="30" font-size="15" text-anchor="middle" fill="#fff">
|
||||
SVG
|
||||
</text>
|
||||
</svg>!
|
||||
<br>
|
||||
<input type="text" placeholder="Type here...">
|
||||
<button>Click me</button>
|
||||
`;
|
||||
|
||||
const geometry = new RoundedBoxGeometry( 200, 200, 200, 10, 10 );
|
||||
|
||||
const material = new THREE.MeshStandardMaterial( { roughness: 0, metalness: 0.5 } );
|
||||
material.map = new THREE.HTMLTexture( element );
|
||||
|
||||
mesh = new THREE.Mesh( geometry, material );
|
||||
scene.add( mesh );
|
||||
|
||||
// Interaction
|
||||
|
||||
interactions = new InteractionManager();
|
||||
interactions.connect( renderer, camera );
|
||||
interactions.add( mesh );
|
||||
|
||||
// Button click handler
|
||||
|
||||
element.querySelector( 'button' ).addEventListener( 'click', function () {
|
||||
|
||||
this.textContent = 'Clicked!';
|
||||
|
||||
} );
|
||||
|
||||
window.addEventListener( 'resize', onWindowResize );
|
||||
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
renderer.setSize( window.innerWidth, window.innerHeight );
|
||||
|
||||
}
|
||||
|
||||
function animate( time ) {
|
||||
|
||||
mesh.rotation.x = Math.sin( time * 0.0005 ) * 0.5;
|
||||
mesh.rotation.y = Math.cos( time * 0.0008 ) * 0.5;
|
||||
|
||||
interactions.update();
|
||||
|
||||
renderer.render( scene, camera );
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
74
research/canvas/_raw/htmltex.js
Normal file
74
research/canvas/_raw/htmltex.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Texture } from './Texture.js';
|
||||
|
||||
/**
|
||||
* Creates a texture from an HTML element.
|
||||
*
|
||||
* This is almost the same as the base texture class, except that it sets {@link Texture#needsUpdate}
|
||||
* to `true` immediately and listens for the parent canvas's paint events to trigger updates.
|
||||
*
|
||||
* @augments Texture
|
||||
*/
|
||||
class HTMLTexture extends Texture {
|
||||
|
||||
/**
|
||||
* Constructs a new texture.
|
||||
*
|
||||
* @param {HTMLElement} [element] - The HTML element.
|
||||
* @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
|
||||
* @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
|
||||
* @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
|
||||
* @param {number} [magFilter=LinearFilter] - The mag filter value.
|
||||
* @param {number} [minFilter=LinearMipmapLinearFilter] - The min filter value.
|
||||
* @param {number} [format=RGBAFormat] - The texture format.
|
||||
* @param {number} [type=UnsignedByteType] - The texture type.
|
||||
* @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
|
||||
*/
|
||||
constructor( element, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ) {
|
||||
|
||||
super( element, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy );
|
||||
|
||||
/**
|
||||
* This flag can be used for type testing.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @readonly
|
||||
* @default true
|
||||
*/
|
||||
this.isHTMLTexture = true;
|
||||
this.generateMipmaps = false;
|
||||
|
||||
this.needsUpdate = true;
|
||||
|
||||
const parent = element ? element.parentNode : null;
|
||||
|
||||
if ( parent !== null && 'requestPaint' in parent ) {
|
||||
|
||||
parent.onpaint = () => {
|
||||
|
||||
this.needsUpdate = true;
|
||||
|
||||
};
|
||||
|
||||
parent.requestPaint();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
const parent = this.image ? this.image.parentNode : null;
|
||||
|
||||
if ( parent !== null && 'onpaint' in parent ) {
|
||||
|
||||
parent.onpaint = null;
|
||||
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { HTMLTexture };
|
||||
938
research/canvas/_raw/jelly.ts
Normal file
938
research/canvas/_raw/jelly.ts
Normal file
|
|
@ -0,0 +1,938 @@
|
|||
import * as sdf from '@typegpu/sdf';
|
||||
import tgpu, { common, d, std } from 'typegpu';
|
||||
|
||||
import { randf } from '@typegpu/noise';
|
||||
import { Slider } from './slider.ts';
|
||||
import { CameraController } from './camera.ts';
|
||||
import {
|
||||
DirectionalLight,
|
||||
HitInfo,
|
||||
LineInfo,
|
||||
ObjectType,
|
||||
Ray,
|
||||
rayMarchLayout,
|
||||
sampleLayout,
|
||||
SdfBbox,
|
||||
} from './dataTypes.ts';
|
||||
import {
|
||||
beerLambert,
|
||||
createBackgroundTexture,
|
||||
createTextures,
|
||||
fresnelSchlick,
|
||||
intersectBox,
|
||||
} from './utils.ts';
|
||||
import { TAAResolver } from './taa.ts';
|
||||
import {
|
||||
AMBIENT_COLOR,
|
||||
AMBIENT_INTENSITY,
|
||||
AO_BIAS,
|
||||
AO_INTENSITY,
|
||||
AO_RADIUS,
|
||||
AO_STEPS,
|
||||
JELLY_IOR,
|
||||
JELLY_SCATTER_STRENGTH,
|
||||
LINE_HALF_THICK,
|
||||
LINE_RADIUS,
|
||||
MAX_DIST,
|
||||
MAX_STEPS,
|
||||
SPECULAR_INTENSITY,
|
||||
SPECULAR_POWER,
|
||||
SURF_DIST,
|
||||
} from './constants.ts';
|
||||
|
||||
const root = await tgpu.init({
|
||||
device: {
|
||||
optionalFeatures: ['timestamp-query'],
|
||||
},
|
||||
});
|
||||
|
||||
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
|
||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
|
||||
|
||||
const NUM_POINTS = 17;
|
||||
|
||||
const slider = new Slider(root, d.vec2f(-1, 0), d.vec2f(0.9, 0), NUM_POINTS, -0.03);
|
||||
const bezierTexture = slider.bezierTexture.createView();
|
||||
const bezierBbox = slider.bbox;
|
||||
|
||||
let qualityScale = 1.0;
|
||||
let [width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
|
||||
|
||||
let textures = createTextures(root, width, height);
|
||||
let backgroundTexture = createBackgroundTexture(root, width, height);
|
||||
|
||||
const sliderElement = document.getElementById('slider') as HTMLInputElement;
|
||||
const valueElement = document.getElementById('value') as HTMLDivElement;
|
||||
|
||||
const valueRawTexture = root.device.createTexture({
|
||||
size: [width, height, 1],
|
||||
format: 'rgba8unorm',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
|
||||
});
|
||||
const valueTextureView = valueRawTexture.createView();
|
||||
|
||||
// Return a number from 0...100 as a string Zero percent...One hundred percent.
|
||||
function getPercentString(n: number): string {
|
||||
if (n === 100) return "One-hundred %";
|
||||
|
||||
const ones: string[] = [
|
||||
"Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
|
||||
"Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"
|
||||
];
|
||||
|
||||
const tens: string[] = [
|
||||
"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"
|
||||
];
|
||||
|
||||
// Handle 0 through 19
|
||||
if (n < 20) {
|
||||
return `${ones[n]} %`;
|
||||
}
|
||||
|
||||
// Handle 20 through 99
|
||||
const tensWord: string = tens[Math.floor(n / 10)];
|
||||
const onesWord: string = n % 10 === 0 ? "" : `-${ones[n % 10].toLowerCase()}`;
|
||||
|
||||
return `${tensWord}${onesWord} %`;
|
||||
}
|
||||
|
||||
let targetMouseX = 0.9;
|
||||
let currentMouseX = 0.9;
|
||||
|
||||
sliderElement.addEventListener('input', () => {
|
||||
const t = Number(sliderElement.value) / 100.0;
|
||||
targetMouseX = t * 1.9 - 1.0;
|
||||
valueElement.textContent = getPercentString(Number(sliderElement.value));
|
||||
(canvas as any).requestPaint();
|
||||
});
|
||||
valueElement.textContent = getPercentString(Number(sliderElement.value));
|
||||
|
||||
const filteringSampler = root['~unstable'].createSampler({
|
||||
magFilter: 'linear',
|
||||
minFilter: 'linear',
|
||||
});
|
||||
|
||||
const camera = new CameraController(
|
||||
root,
|
||||
d.vec3f(0, 2.7, 1.9),
|
||||
d.vec3f(0, 0, 0),
|
||||
d.vec3f(0, 1, 0),
|
||||
Math.PI / 4,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const cameraUniform = camera.cameraUniform;
|
||||
|
||||
const lightUniform = root.createUniform(DirectionalLight, {
|
||||
direction: std.normalize(d.vec3f(0.19, -0.24, 0.75)),
|
||||
color: d.vec3f(1, 1, 1),
|
||||
});
|
||||
|
||||
const jellyColorUniform = root.createUniform(d.vec4f, d.vec4f(1.0, 0.45, 0.075, 1.0));
|
||||
const jellyScatterUniform = root.createUniform(d.f32, JELLY_SCATTER_STRENGTH);
|
||||
const groundColorUniform = root.createUniform(d.vec3f, d.vec3f(1.0));
|
||||
const groundTextColorUniform = root.createUniform(d.vec3f, d.vec3f(0.5));
|
||||
|
||||
const randomUniform = root.createUniform(d.vec2f);
|
||||
const blurEnabledUniform = root.createUniform(d.u32);
|
||||
|
||||
const getRay = (ndc: d.v2f) => {
|
||||
'use gpu';
|
||||
const clipPos = d.vec4f(ndc.x, ndc.y, -1.0, 1.0);
|
||||
|
||||
const invView = cameraUniform.$.viewInv;
|
||||
const invProj = cameraUniform.$.projInv;
|
||||
|
||||
const viewPos = invProj.mul(clipPos);
|
||||
const viewPosNormalized = d.vec4f(viewPos.xyz.div(viewPos.w), 1.0);
|
||||
|
||||
const worldPos = invView.mul(viewPosNormalized);
|
||||
|
||||
const rayOrigin = invView.columns[3].xyz;
|
||||
const rayDir = std.normalize(worldPos.xyz.sub(rayOrigin));
|
||||
|
||||
return Ray({
|
||||
origin: rayOrigin,
|
||||
direction: rayDir,
|
||||
});
|
||||
};
|
||||
|
||||
const getSliderBbox = () => {
|
||||
'use gpu';
|
||||
return SdfBbox({
|
||||
left: d.f32(bezierBbox[3]),
|
||||
right: d.f32(bezierBbox[1]),
|
||||
bottom: d.f32(bezierBbox[2]),
|
||||
top: d.f32(bezierBbox[0]),
|
||||
});
|
||||
};
|
||||
|
||||
const sdInflatedPolyline2D = (p: d.v2f) => {
|
||||
'use gpu';
|
||||
const bbox = getSliderBbox();
|
||||
|
||||
const uv = d.vec2f(
|
||||
(p.x - bbox.left) / (bbox.right - bbox.left),
|
||||
(bbox.top - p.y) / (bbox.top - bbox.bottom),
|
||||
);
|
||||
const clampedUV = std.saturate(uv);
|
||||
|
||||
const sampledColor = std.textureSampleLevel(bezierTexture.$, filteringSampler.$, clampedUV, 0);
|
||||
const segUnsigned = sampledColor.x;
|
||||
const progress = sampledColor.y;
|
||||
const normal = sampledColor.zw;
|
||||
|
||||
return LineInfo({
|
||||
t: progress,
|
||||
distance: segUnsigned,
|
||||
normal: normal,
|
||||
});
|
||||
};
|
||||
|
||||
const cap3D = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const endCap = slider.endCapUniform.$;
|
||||
const secondLastPoint = d.vec2f(endCap.x, endCap.y);
|
||||
const lastPoint = d.vec2f(endCap.z, endCap.w);
|
||||
|
||||
const angle = std.atan2(lastPoint.y - secondLastPoint.y, lastPoint.x - secondLastPoint.x);
|
||||
const rot = d.mat2x2f(std.cos(angle), -std.sin(angle), std.sin(angle), std.cos(angle));
|
||||
|
||||
let pieP = position.sub(d.vec3f(secondLastPoint, 0));
|
||||
pieP = d.vec3f(rot.mul(pieP.xy), pieP.z);
|
||||
const hmm = sdf.sdPie(pieP.zx, d.vec2f(1, 0), LINE_HALF_THICK);
|
||||
const extrudeEnd = sdf.opExtrudeY(pieP, hmm, 0.001) - LINE_RADIUS;
|
||||
return extrudeEnd;
|
||||
};
|
||||
|
||||
const sliderSdf3D = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const poly2D = sdInflatedPolyline2D(position.xy);
|
||||
|
||||
let finalDist = d.f32(0.0);
|
||||
if (poly2D.t > 0.94) {
|
||||
finalDist = cap3D(position);
|
||||
} else {
|
||||
const body = sdf.opExtrudeZ(position, poly2D.distance, LINE_HALF_THICK) - LINE_RADIUS;
|
||||
finalDist = body;
|
||||
}
|
||||
|
||||
return LineInfo({
|
||||
t: poly2D.t,
|
||||
distance: finalDist,
|
||||
normal: poly2D.normal,
|
||||
});
|
||||
};
|
||||
|
||||
const GroundParams = {
|
||||
groundThickness: 0.03,
|
||||
groundRoundness: 0.02,
|
||||
};
|
||||
|
||||
const rectangleCutoutDist = (position: d.v2f) => {
|
||||
'use gpu';
|
||||
const groundRoundness = GroundParams.groundRoundness;
|
||||
|
||||
return sdf.sdRoundedBox2d(
|
||||
position,
|
||||
d.vec2f(1 + groundRoundness, 0.2 + groundRoundness),
|
||||
0.2 + groundRoundness,
|
||||
);
|
||||
};
|
||||
|
||||
const getMainSceneDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const groundThickness = GroundParams.groundThickness;
|
||||
const groundRoundness = GroundParams.groundRoundness;
|
||||
|
||||
return sdf.opUnion(
|
||||
sdf.sdPlane(position, d.vec3f(0, 1, 0), 0.06),
|
||||
sdf.opExtrudeY(position, -rectangleCutoutDist(position.xz), groundThickness - groundRoundness) -
|
||||
groundRoundness,
|
||||
);
|
||||
};
|
||||
|
||||
const sliderApproxDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const bbox = getSliderBbox();
|
||||
|
||||
const p = position.xy;
|
||||
if (p.x < bbox.left || p.x > bbox.right || p.y < bbox.bottom || p.y > bbox.top) {
|
||||
return 1e9;
|
||||
}
|
||||
|
||||
const poly2D = sdInflatedPolyline2D(p);
|
||||
const dist3D = sdf.opExtrudeZ(position, poly2D.distance, LINE_HALF_THICK) - LINE_RADIUS;
|
||||
|
||||
return dist3D;
|
||||
};
|
||||
|
||||
const getSceneDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const mainScene = getMainSceneDist(position);
|
||||
const poly3D = sliderSdf3D(position);
|
||||
|
||||
const hitInfo = HitInfo();
|
||||
|
||||
if (poly3D.distance < mainScene) {
|
||||
hitInfo.distance = poly3D.distance;
|
||||
hitInfo.objectType = ObjectType.SLIDER;
|
||||
hitInfo.t = poly3D.t;
|
||||
} else {
|
||||
hitInfo.distance = mainScene;
|
||||
hitInfo.objectType = ObjectType.BACKGROUND;
|
||||
}
|
||||
return hitInfo;
|
||||
};
|
||||
|
||||
const getSceneDistForAO = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const mainScene = getMainSceneDist(position);
|
||||
const sliderApprox = sliderApproxDist(position);
|
||||
return std.min(mainScene, sliderApprox);
|
||||
};
|
||||
|
||||
const sdfSlot = tgpu.slot<(pos: d.v3f) => number>();
|
||||
|
||||
const getNormalFromSdf = tgpu.fn(
|
||||
[d.vec3f, d.f32],
|
||||
d.vec3f,
|
||||
)((position, epsilon) => {
|
||||
'use gpu';
|
||||
const k = d.vec3f(1, -1, 0);
|
||||
|
||||
const offset1 = k.xyy.mul(epsilon);
|
||||
const offset2 = k.yyx.mul(epsilon);
|
||||
const offset3 = k.yxy.mul(epsilon);
|
||||
const offset4 = k.xxx.mul(epsilon);
|
||||
|
||||
const sample1 = offset1.mul(sdfSlot.$(position.add(offset1)));
|
||||
const sample2 = offset2.mul(sdfSlot.$(position.add(offset2)));
|
||||
const sample3 = offset3.mul(sdfSlot.$(position.add(offset3)));
|
||||
const sample4 = offset4.mul(sdfSlot.$(position.add(offset4)));
|
||||
|
||||
const gradient = sample1.add(sample2).add(sample3).add(sample4);
|
||||
|
||||
return std.normalize(gradient);
|
||||
});
|
||||
|
||||
const getNormalCapSdf = getNormalFromSdf.with(sdfSlot, cap3D);
|
||||
const getNormalMainSdf = getNormalFromSdf.with(sdfSlot, getMainSceneDist);
|
||||
|
||||
const getNormalCap = (pos: d.v3f) => {
|
||||
'use gpu';
|
||||
return getNormalCapSdf(pos, 0.01);
|
||||
};
|
||||
|
||||
const getNormalMain = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
if (std.abs(position.z) > 0.22 || std.abs(position.x) > 1.02) {
|
||||
return d.vec3f(0, 1, 0);
|
||||
}
|
||||
return getNormalMainSdf(position, 0.0001);
|
||||
};
|
||||
|
||||
const getSliderNormal = (position: d.v3f, hitInfo: d.Infer<typeof HitInfo>) => {
|
||||
'use gpu';
|
||||
const poly2D = sdInflatedPolyline2D(position.xy);
|
||||
const gradient2D = poly2D.normal;
|
||||
|
||||
const threshold = LINE_HALF_THICK * 0.85;
|
||||
const absZ = std.abs(position.z);
|
||||
const zDistance = std.max(
|
||||
0,
|
||||
((absZ - threshold) * LINE_HALF_THICK) / (LINE_HALF_THICK - threshold),
|
||||
);
|
||||
const edgeDistance = LINE_RADIUS - poly2D.distance;
|
||||
|
||||
const edgeContrib = 0.9;
|
||||
const zContrib = 1.0 - edgeContrib;
|
||||
|
||||
const zDirection = std.sign(position.z);
|
||||
const zAxisVector = d.vec3f(0, 0, zDirection);
|
||||
|
||||
const edgeBlendDistance = edgeContrib * LINE_RADIUS + zContrib * LINE_HALF_THICK;
|
||||
|
||||
const blendFactor = std.smoothstep(
|
||||
edgeBlendDistance,
|
||||
0.0,
|
||||
zDistance * zContrib + edgeDistance * edgeContrib,
|
||||
);
|
||||
|
||||
const normal2D = d.vec3f(gradient2D.xy, 0);
|
||||
const blendedNormal = std.mix(zAxisVector, normal2D, blendFactor * 0.5 + 0.5);
|
||||
|
||||
let normal = std.normalize(blendedNormal);
|
||||
|
||||
if (hitInfo.t > 0.94) {
|
||||
const ratio = (hitInfo.t - 0.94) / 0.02;
|
||||
const fullNormal = getNormalCap(position);
|
||||
normal = std.normalize(std.mix(normal, fullNormal, ratio));
|
||||
}
|
||||
|
||||
return normal;
|
||||
};
|
||||
|
||||
const getNormal = (position: d.v3f, hitInfo: d.Infer<typeof HitInfo>) => {
|
||||
'use gpu';
|
||||
if (hitInfo.objectType === ObjectType.SLIDER && hitInfo.t < 0.96) {
|
||||
return getSliderNormal(position, hitInfo);
|
||||
}
|
||||
|
||||
return std.select(
|
||||
getNormalCap(position),
|
||||
getNormalMain(position),
|
||||
hitInfo.objectType === ObjectType.BACKGROUND,
|
||||
);
|
||||
};
|
||||
|
||||
const sqLength = (a: d.v3f) => {
|
||||
'use gpu';
|
||||
return std.dot(a, a);
|
||||
};
|
||||
|
||||
const getFakeShadow = (position: d.v3f, lightDir: d.v3f): d.v3f => {
|
||||
'use gpu';
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
const endCapX = slider.endCapUniform.$.x;
|
||||
|
||||
if (position.y < -GroundParams.groundThickness) {
|
||||
// Applying darkening under the ground (the shadow cast by the upper ground layer)
|
||||
const fadeSharpness = 30;
|
||||
const inset = 0.02;
|
||||
const cutout = rectangleCutoutDist(position.xz) + inset;
|
||||
const edgeDarkening = std.saturate(1 - cutout * fadeSharpness);
|
||||
|
||||
// Applying a slight gradient based on the light direction
|
||||
const lightGradient = std.saturate(-position.z * 4 * lightDir.z + 1);
|
||||
|
||||
return d
|
||||
.vec3f(1)
|
||||
.mul(edgeDarkening)
|
||||
.mul(lightGradient * 0.5);
|
||||
} else {
|
||||
const finalUV = d.vec2f(
|
||||
(position.x - position.z * lightDir.x * std.sign(lightDir.z)) * 0.5 + 0.5,
|
||||
1 - (-position.z / lightDir.z) * 0.5 - 0.2,
|
||||
);
|
||||
const data = std.textureSampleLevel(bezierTexture.$, filteringSampler.$, finalUV, 0);
|
||||
|
||||
// Normally it would be just data.y, but there transition is too sudden when the jelly is bunched up.
|
||||
// To mitigate this, we transition into a position-based transition.
|
||||
const jellySaturation = std.mix(0, data.y, std.saturate(position.x * 1.5 + 1.1));
|
||||
const shadowColor = std.mix(d.vec3f(0, 0, 0), jellyColor.rgb, jellySaturation);
|
||||
|
||||
const contrast = 20 * std.saturate(finalUV.y) * (0.8 + endCapX * 0.2);
|
||||
const shadowOffset = -0.3;
|
||||
const featherSharpness = 10;
|
||||
const uvEdgeFeather =
|
||||
std.saturate(finalUV.x * featherSharpness) *
|
||||
std.saturate((1 - finalUV.x) * featherSharpness) *
|
||||
std.saturate((1 - finalUV.y) * featherSharpness) *
|
||||
std.saturate(finalUV.y);
|
||||
const influence = std.saturate((1 - lightDir.y) * 2) * uvEdgeFeather;
|
||||
return std.mix(
|
||||
d.vec3f(1),
|
||||
std.mix(shadowColor, d.vec3f(1), std.saturate(data.x * contrast + shadowOffset)),
|
||||
influence,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateAO = (position: d.v3f, normal: d.v3f) => {
|
||||
'use gpu';
|
||||
let totalOcclusion = d.f32(0.0);
|
||||
let sampleWeight = d.f32(1.0);
|
||||
const stepDistance = AO_RADIUS / AO_STEPS;
|
||||
|
||||
for (let i = 1; i <= AO_STEPS; i++) {
|
||||
const sampleHeight = stepDistance * d.f32(i);
|
||||
const samplePosition = position.add(normal.mul(sampleHeight));
|
||||
const distanceToSurface = getSceneDistForAO(samplePosition) - AO_BIAS;
|
||||
const occlusionContribution = std.max(0.0, sampleHeight - distanceToSurface);
|
||||
totalOcclusion += occlusionContribution * sampleWeight;
|
||||
sampleWeight *= 0.5;
|
||||
if (totalOcclusion > AO_RADIUS / AO_INTENSITY) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const rawAO = 1.0 - (AO_INTENSITY * totalOcclusion) / AO_RADIUS;
|
||||
return std.saturate(rawAO);
|
||||
};
|
||||
|
||||
const calculateLighting = (hitPosition: d.v3f, normal: d.v3f, rayOrigin: d.v3f) => {
|
||||
'use gpu';
|
||||
const lightDir = std.neg(lightUniform.$.direction);
|
||||
|
||||
const fakeShadow = getFakeShadow(hitPosition, lightDir);
|
||||
const diffuse = std.max(std.dot(normal, lightDir), 0.0);
|
||||
|
||||
const viewDir = std.normalize(rayOrigin.sub(hitPosition));
|
||||
const reflectDir = std.reflect(std.neg(lightDir), normal);
|
||||
const specularFactor = std.max(std.dot(viewDir, reflectDir), 0) ** SPECULAR_POWER;
|
||||
const specular = lightUniform.$.color.mul(specularFactor * SPECULAR_INTENSITY);
|
||||
|
||||
const baseColor = d.vec3f(0.9);
|
||||
|
||||
const directionalLight = baseColor.mul(lightUniform.$.color).mul(diffuse).mul(fakeShadow);
|
||||
const ambientLight = baseColor.mul(AMBIENT_COLOR).mul(AMBIENT_INTENSITY);
|
||||
|
||||
const finalSpecular = specular.mul(fakeShadow);
|
||||
|
||||
return std.saturate(directionalLight.add(ambientLight).add(finalSpecular));
|
||||
};
|
||||
|
||||
const applyAO = (litColor: d.v3f, hitPosition: d.v3f, normal: d.v3f) => {
|
||||
'use gpu';
|
||||
const ao = calculateAO(hitPosition, normal);
|
||||
const finalColor = litColor.mul(ao);
|
||||
return d.vec4f(finalColor, 1.0);
|
||||
};
|
||||
|
||||
const rayMarchNoJelly = (rayOrigin: d.v3f, rayDirection: d.v3f) => {
|
||||
'use gpu';
|
||||
let distanceFromOrigin = d.f32();
|
||||
let hit = d.f32();
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const p = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
hit = getMainSceneDist(p);
|
||||
distanceFromOrigin += hit;
|
||||
if (distanceFromOrigin > MAX_DIST || hit < SURF_DIST * 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (distanceFromOrigin < MAX_DIST) {
|
||||
return renderBackground(
|
||||
rayOrigin,
|
||||
rayDirection,
|
||||
distanceFromOrigin,
|
||||
std.select(d.f32(), 0.87, blurEnabledUniform.$ === 1),
|
||||
).rgb;
|
||||
}
|
||||
return d.vec3f();
|
||||
};
|
||||
|
||||
const renderPercentageOnGround = (hitPosition: d.v3f, center: d.v3f) => {
|
||||
'use gpu';
|
||||
|
||||
const textWidth = 1.9;
|
||||
const textHeight = 0.33;
|
||||
|
||||
if (
|
||||
std.abs(hitPosition.x - center.x) > textWidth * 0.5 ||
|
||||
std.abs(hitPosition.z - center.z) > textHeight * 0.5
|
||||
) {
|
||||
return d.vec4f();
|
||||
}
|
||||
|
||||
const localX = hitPosition.x - center.x;
|
||||
const localZ = hitPosition.z - center.z;
|
||||
|
||||
const uvX = (localX + textWidth * 0.5) / textWidth;
|
||||
const uvZ = (localZ + textHeight * 0.5) / textHeight;
|
||||
|
||||
if (uvX < 0.0 || uvX > 1.0 || uvZ < 0.0 || uvZ > 1.0) {
|
||||
return d.vec4f();
|
||||
}
|
||||
|
||||
return std.textureSampleLevel(
|
||||
rayMarchLayout.$.valueTexture,
|
||||
filteringSampler.$,
|
||||
d.vec2f(uvX, uvZ),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const renderBackground = (
|
||||
rayOrigin: d.v3f,
|
||||
rayDirection: d.v3f,
|
||||
backgroundHitDist: number,
|
||||
offset: number,
|
||||
) => {
|
||||
'use gpu';
|
||||
const hitPosition = rayOrigin.add(rayDirection.mul(backgroundHitDist));
|
||||
|
||||
const percentageSample = renderPercentageOnGround(
|
||||
hitPosition,
|
||||
d.vec3f(0, 0, 0),
|
||||
);
|
||||
|
||||
let highlights = d.f32();
|
||||
|
||||
const highlightWidth = d.f32(1);
|
||||
const highlightHeight = 0.2;
|
||||
let offsetX = d.f32();
|
||||
let offsetZ = d.f32(0.05);
|
||||
|
||||
const lightDir = lightUniform.$.direction;
|
||||
const causticScale = 0.2;
|
||||
offsetX -= lightDir.x * causticScale;
|
||||
offsetZ += lightDir.z * causticScale;
|
||||
|
||||
const endCapX = slider.endCapUniform.$.x;
|
||||
const sliderStretch = (endCapX + 1) * 0.5;
|
||||
|
||||
if (
|
||||
std.abs(hitPosition.x + offsetX) < highlightWidth &&
|
||||
std.abs(hitPosition.z + offsetZ) < highlightHeight
|
||||
) {
|
||||
const uvX_orig = ((hitPosition.x + offsetX + highlightWidth * 2) / highlightWidth) * 0.5;
|
||||
const uvZ_orig = ((hitPosition.z + offsetZ + highlightHeight * 2) / highlightHeight) * 0.5;
|
||||
|
||||
const centeredUV = d.vec2f(uvX_orig - 0.5, uvZ_orig - 0.5);
|
||||
const finalUV = d.vec2f(centeredUV.x, 1 - (std.abs(centeredUV.y - 0.5) * 2) ** 2 * 0.3);
|
||||
|
||||
const density = std.max(
|
||||
0,
|
||||
(std.textureSampleLevel(bezierTexture.$, filteringSampler.$, finalUV, 0).x - 0.25) * 8,
|
||||
);
|
||||
|
||||
const fadeX = std.smoothstep(0, -0.2, hitPosition.x - endCapX);
|
||||
const fadeZ = 1 - (std.abs(centeredUV.y - 0.5) * 2) ** 3;
|
||||
const fadeStretch = std.saturate(1 - sliderStretch);
|
||||
const edgeFade = std.saturate(fadeX) * std.saturate(fadeZ) * fadeStretch;
|
||||
|
||||
highlights = (density ** 3 * edgeFade * 3 * (1 + lightDir.z)) / 1.5;
|
||||
}
|
||||
|
||||
const originYBound = std.saturate(rayOrigin.y + 0.01);
|
||||
const posOffset = hitPosition.add(
|
||||
d.vec3f(0, 1, 0).mul(offset * (originYBound / (1.0 + originYBound)) * (1 + randf.sample() / 2)),
|
||||
);
|
||||
const newNormal = getNormalMain(posOffset);
|
||||
|
||||
// Calculate fake bounce lighting
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
const sqDist = sqLength(hitPosition.sub(d.vec3f(endCapX, 0, 0)));
|
||||
const bounceLight = jellyColor.rgb.mul((1 / (sqDist * 15 + 1)) * 0.4);
|
||||
const sideBounceLight = jellyColor.rgb
|
||||
.mul((1 / (sqDist * 40 + 1)) * 0.3)
|
||||
.mul(std.abs(newNormal.z));
|
||||
|
||||
const litColor = calculateLighting(posOffset, newNormal, rayOrigin);
|
||||
const backgroundColor = applyAO(groundColorUniform.$.mul(litColor), posOffset, newNormal)
|
||||
.add(d.vec4f(bounceLight, 0))
|
||||
.add(d.vec4f(sideBounceLight, 0));
|
||||
|
||||
const textColor = groundTextColorUniform.$;
|
||||
|
||||
return d.vec4f(
|
||||
std.mix(backgroundColor.rgb, textColor, percentageSample.x).mul(1.0 + highlights),
|
||||
1.0,
|
||||
);
|
||||
};
|
||||
|
||||
const rayMarch = (rayOrigin: d.v3f, rayDirection: d.v3f, _uv: d.v2f) => {
|
||||
'use gpu';
|
||||
let totalSteps = d.u32();
|
||||
|
||||
let backgroundDist = d.f32();
|
||||
for (let i = 0; i < MAX_STEPS; i++) {
|
||||
const p = rayOrigin.add(rayDirection.mul(backgroundDist));
|
||||
const hit = getMainSceneDist(p);
|
||||
backgroundDist += hit;
|
||||
if (hit < SURF_DIST) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const background = renderBackground(rayOrigin, rayDirection, backgroundDist, d.f32());
|
||||
|
||||
const bbox = getSliderBbox();
|
||||
const zDepth = d.f32(0.25);
|
||||
|
||||
const sliderMin = d.vec3f(bbox.left, bbox.bottom, -zDepth);
|
||||
const sliderMax = d.vec3f(bbox.right, bbox.top, zDepth);
|
||||
|
||||
const intersection = intersectBox(rayOrigin, rayDirection, sliderMin, sliderMax);
|
||||
|
||||
if (!intersection.hit) {
|
||||
return background;
|
||||
}
|
||||
|
||||
let distanceFromOrigin = std.max(d.f32(0.0), intersection.tMin);
|
||||
|
||||
for (let i = 0; i < MAX_STEPS; i++) {
|
||||
if (totalSteps >= MAX_STEPS) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
|
||||
const hitInfo = getSceneDist(currentPosition);
|
||||
distanceFromOrigin += hitInfo.distance;
|
||||
totalSteps++;
|
||||
|
||||
if (hitInfo.distance < SURF_DIST) {
|
||||
const hitPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
|
||||
if (!(hitInfo.objectType === ObjectType.SLIDER)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const N = getNormal(hitPosition, hitInfo);
|
||||
const I = rayDirection;
|
||||
const cosi = std.min(1.0, std.max(0.0, std.dot(std.neg(I), N)));
|
||||
const F = fresnelSchlick(cosi, d.f32(1.0), d.f32(JELLY_IOR));
|
||||
|
||||
const reflection = std.saturate(d.vec3f(hitPosition.y + 0.2));
|
||||
|
||||
const eta = 1.0 / JELLY_IOR;
|
||||
const k = 1.0 - eta * eta * (1.0 - cosi * cosi);
|
||||
let refractedColor = d.vec3f();
|
||||
if (k > 0.0) {
|
||||
const refrDir = std.normalize(std.add(I.mul(eta), N.mul(eta * cosi - std.sqrt(k))));
|
||||
const p = hitPosition.add(refrDir.mul(SURF_DIST * 2.0));
|
||||
const exitPos = p.add(refrDir.mul(SURF_DIST * 2.0));
|
||||
|
||||
const env = rayMarchNoJelly(exitPos, refrDir);
|
||||
const progress = hitInfo.t;
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
|
||||
const scatterTint = jellyColor.rgb.mul(1.5);
|
||||
const density = d.f32(20.0);
|
||||
const absorb = d.vec3f(1.0).sub(jellyColor.rgb).mul(density);
|
||||
|
||||
const T = beerLambert(absorb.mul(progress ** 2), 0.08);
|
||||
|
||||
const lightDir = std.neg(lightUniform.$.direction);
|
||||
|
||||
const forward = std.max(0.0, std.dot(lightDir, refrDir));
|
||||
const scatter = scatterTint.mul(jellyScatterUniform.$ * forward * progress ** 3);
|
||||
refractedColor = env.mul(T).add(scatter);
|
||||
}
|
||||
|
||||
const jelly = std.add(reflection.mul(F), refractedColor.mul(1 - F));
|
||||
|
||||
const finalJelly = std.mix(background.rgb, jelly, jellyColorUniform.$.w);
|
||||
|
||||
return d.vec4f(finalJelly, 1.0);
|
||||
}
|
||||
|
||||
if (distanceFromOrigin > backgroundDist) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return background;
|
||||
};
|
||||
|
||||
const raymarchFn = tgpu.fragmentFn({
|
||||
in: { uv: d.vec2f },
|
||||
out: d.vec4f,
|
||||
})(({ uv }) => {
|
||||
randf.seed2(randomUniform.$.mul(uv));
|
||||
|
||||
const ndc = d.vec2f(uv.x * 2 - 1, -(uv.y * 2 - 1));
|
||||
const ray = getRay(ndc);
|
||||
|
||||
const color = rayMarch(ray.origin, ray.direction, uv);
|
||||
return d.vec4f(std.tanh(color.rgb.mul(1.3)), 1);
|
||||
});
|
||||
|
||||
const fragmentMain = tgpu.fragmentFn({
|
||||
in: { uv: d.vec2f },
|
||||
out: d.vec4f,
|
||||
})((input) => {
|
||||
return std.textureSample(sampleLayout.$.currentTexture, filteringSampler.$, input.uv);
|
||||
});
|
||||
|
||||
const rayMarchPipeline = root.createRenderPipeline({
|
||||
vertex: common.fullScreenTriangle,
|
||||
fragment: raymarchFn,
|
||||
targets: { format: 'rgba8unorm' },
|
||||
});
|
||||
|
||||
const renderPipeline = root.createRenderPipeline({
|
||||
vertex: common.fullScreenTriangle,
|
||||
fragment: fragmentMain,
|
||||
targets: { format: presentationFormat },
|
||||
});
|
||||
|
||||
let lastTimeStamp = performance.now();
|
||||
let frameCount = 0;
|
||||
const taaResolver = new TAAResolver(root, width, height);
|
||||
|
||||
function createBindGroups() {
|
||||
return {
|
||||
rayMarch: root.createBindGroup(rayMarchLayout, {
|
||||
backgroundTexture: backgroundTexture.sampled,
|
||||
valueTexture: valueTextureView,
|
||||
}),
|
||||
render: [0, 1].map((frame) =>
|
||||
root.createBindGroup(sampleLayout, {
|
||||
currentTexture: taaResolver.getResolvedTexture(frame),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
(canvas as any).onpaint = () => {
|
||||
const sourceDict = { source: valueElement };
|
||||
const destDict = {
|
||||
destination: { texture: valueRawTexture },
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
try {
|
||||
(root.device.queue as any).copyElementImageToTexture(sourceDict, destDict);
|
||||
} catch (e) {
|
||||
// The copyElementImageToTexture API was recently changed to take two maps
|
||||
// (see: https://github.com/WICG/html-in-canvas#idl-changes). This snippet
|
||||
// supports the old syntax temporarily so that the demos do not break.
|
||||
(root.device.queue as any).copyElementImageToTexture(
|
||||
valueElement, width, height, { texture: valueRawTexture });
|
||||
console.log('Note: using old copyElementImageToTexture API');
|
||||
}
|
||||
|
||||
// TODO(pdr): Calculate this correctly using `getElementTransform`. For now,
|
||||
// the transform is just hard-coded.
|
||||
//const view = camera.view;
|
||||
//const proj = camera.proj;
|
||||
//const mvp = m.mat4.mul(proj, view, d.mat4x4f());
|
||||
//const sliderWidth = sliderElement.clientWidth || (canvas.clientWidth * 0.75);
|
||||
const sliderHeight = sliderElement.clientHeight || (canvas.clientHeight * 0.125);
|
||||
let x = (canvas.width / devicePixelRatio) / 8;
|
||||
let y = (canvas.height / devicePixelRatio) / 2 - (sliderHeight / 2);
|
||||
sliderElement.style.transform = `translate(${x}px, ${y}px)`;
|
||||
valueElement.style.transform = `translate(${x}px, ${y}px)`;
|
||||
};
|
||||
(canvas as any).requestPaint();
|
||||
|
||||
let bindGroups = createBindGroups();
|
||||
|
||||
let animationFrameHandle: number;
|
||||
function render(timestamp: number) {
|
||||
frameCount++;
|
||||
camera.jitter();
|
||||
const deltaTime = Math.min((timestamp - lastTimeStamp) * 0.001, 0.1);
|
||||
lastTimeStamp = timestamp;
|
||||
|
||||
randomUniform.write(d.vec2f((Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2));
|
||||
|
||||
const reduce = motionMedia.matches || transparencyMedia.matches;
|
||||
if (reduce) {
|
||||
currentMouseX = targetMouseX;
|
||||
slider.restLen = Math.max(0.001, Math.abs(currentMouseX - slider.anchor[0])) / (slider.n - 1);
|
||||
} else {
|
||||
currentMouseX += (targetMouseX - currentMouseX) * 0.08;
|
||||
slider.restLen = 1.9 / (slider.n - 1);
|
||||
}
|
||||
|
||||
slider.setDragX(currentMouseX);
|
||||
slider.update(deltaTime);
|
||||
|
||||
const currentFrame = frameCount % 2;
|
||||
|
||||
rayMarchPipeline
|
||||
.withColorAttachment({
|
||||
view: textures[currentFrame].sampled,
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
})
|
||||
.with(bindGroups.rayMarch)
|
||||
.draw(3);
|
||||
|
||||
taaResolver.resolve(textures[currentFrame].sampled, frameCount, currentFrame);
|
||||
|
||||
renderPipeline
|
||||
.withColorAttachment({ view: context })
|
||||
.with(bindGroups.render[currentFrame])
|
||||
.draw(3);
|
||||
|
||||
animationFrameHandle = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
[width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
|
||||
camera.updateProjection(Math.PI / 4, width, height);
|
||||
textures = createTextures(root, width, height);
|
||||
backgroundTexture = createBackgroundTexture(root, width, height);
|
||||
taaResolver.resize(width, height);
|
||||
frameCount = 0;
|
||||
|
||||
bindGroups = createBindGroups();
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleResize();
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
animationFrameHandle = requestAnimationFrame(render);
|
||||
|
||||
|
||||
const hcMedia = window.matchMedia('(forced-colors: active)');
|
||||
const darkMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const contrastMedia = window.matchMedia('(prefers-contrast: more)');
|
||||
|
||||
const motionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const transparencyMedia = window.matchMedia('(prefers-reduced-transparency: reduce)');
|
||||
|
||||
const updateReducedFeatures = () => {
|
||||
const reduce = motionMedia.matches || transparencyMedia.matches;
|
||||
|
||||
if (reduce) {
|
||||
slider.damping = 1.0;
|
||||
slider.archStrength = 0.0;
|
||||
jellyScatterUniform.write(0.0);
|
||||
} else {
|
||||
slider.damping = 0.01;
|
||||
slider.archStrength = 2.0;
|
||||
jellyScatterUniform.write(JELLY_SCATTER_STRENGTH);
|
||||
}
|
||||
};
|
||||
|
||||
motionMedia.addEventListener('change', updateReducedFeatures);
|
||||
transparencyMedia.addEventListener('change', updateReducedFeatures);
|
||||
updateReducedFeatures();
|
||||
|
||||
const parseColor3 = (colorStr: string): d.Infer<typeof d.vec3f> => {
|
||||
const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
return d.vec3f(parseInt(match[1]) / 255, parseInt(match[2]) / 255, parseInt(match[3]) / 255);
|
||||
}
|
||||
return d.vec3f(1.0);
|
||||
};
|
||||
|
||||
const parseColor4 = (colorStr: string): d.Infer<typeof d.vec4f> => {
|
||||
const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/);
|
||||
if (match) {
|
||||
const a = match[4] !== undefined ? parseFloat(match[4]) : 1.0;
|
||||
return d.vec4f(parseInt(match[1]) / 255, parseInt(match[2]) / 255, parseInt(match[3]) / 255, a);
|
||||
}
|
||||
return d.vec4f(1.0, 1.0, 1.0, 1.0);
|
||||
};
|
||||
|
||||
const updateColors = () => {
|
||||
const style = getComputedStyle(sliderElement);
|
||||
|
||||
jellyColorUniform.write(parseColor4(style.color));
|
||||
groundColorUniform.write(parseColor3(style.backgroundColor));
|
||||
groundTextColorUniform.write(parseColor3(style.caretColor));
|
||||
(canvas as any).requestPaint?.();
|
||||
};
|
||||
|
||||
sliderElement.addEventListener('focus', updateColors);
|
||||
sliderElement.addEventListener('blur', updateColors);
|
||||
hcMedia.addEventListener('change', updateColors);
|
||||
darkMedia.addEventListener('change', updateColors);
|
||||
contrastMedia.addEventListener('change', updateColors);
|
||||
updateColors();
|
||||
|
||||
|
||||
export function onCleanup() {
|
||||
sliderElement.removeEventListener('focus', updateColors);
|
||||
sliderElement.removeEventListener('blur', updateColors);
|
||||
hcMedia.removeEventListener('change', updateColors);
|
||||
darkMedia.removeEventListener('change', updateColors);
|
||||
contrastMedia.removeEventListener('change', updateColors);
|
||||
motionMedia.removeEventListener('change', updateReducedFeatures);
|
||||
transparencyMedia.removeEventListener('change', updateReducedFeatures);
|
||||
cancelAnimationFrame(animationFrameHandle);
|
||||
resizeObserver.disconnect();
|
||||
root.destroy();
|
||||
}
|
||||
85
research/canvas/_raw/secpriv.md
Normal file
85
research/canvas/_raw/secpriv.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
01. What information might this feature expose to Web sites or other parties,
|
||||
and for what purposes is that exposure necessary?
|
||||
|
||||
A design requirement is to not expose any new security information, and to limit the amount of new privacy information (see: [Privacy-preserving painting](https://github.com/WICG/html-in-canvas?tab=readme-ov-file#privacy-preserving-painting)). For the purpose of enabling interactivity, this API will reveal form control rendering, scrollbar rendering, text selection, find-in-page selection, and the caret blink rate (all without revealing OS theme colors).
|
||||
|
||||
02. Do features in your specification expose the minimum amount of information
|
||||
necessary to enable their intended uses?
|
||||
|
||||
Yes.
|
||||
|
||||
03. How do the features in your specification deal with personal information,
|
||||
personally-identifiable information (PII), or information derived from
|
||||
them?
|
||||
|
||||
Since the feature renders pixels from DOM elements into canvas, those pixels can now be accessed by script, so it is important that no PII is present in those pixels. Cross-origin information, visited link information, spellcheck information, and autofill previews must not be painted. Disabling painting of this information also prevents revealing invalidation information via the `paint` event. See [privacy-preserving-painting](https://github.com/WICG/html-in-canvas/tree/main?tab=readme-ov-file#privacy-preserving-painting) for additional details.
|
||||
|
||||
04. How do the features in your specification deal with sensitive information?
|
||||
|
||||
See answer above, the feature ensures no new security information is revealed, and limits new privacy information.
|
||||
|
||||
05. Do the features in your specification introduce new state for an origin
|
||||
that persists across browsing sessions?
|
||||
|
||||
No.
|
||||
|
||||
06. Do the features in your specification expose information about the
|
||||
underlying platform to origins?
|
||||
|
||||
Similar to #1, the painting of information revealing information about the underlying platform (e.g., form autofill) is disabled, but some new platform information is revealed for interactivity, such as the caret blink rate. See [privacy-preserving-painting](https://github.com/WICG/html-in-canvas/tree/main?tab=readme-ov-file#privacy-preserving-painting) for additional details.
|
||||
|
||||
8. Does this specification allow an origin to send data to the underlying
|
||||
platform?
|
||||
|
||||
No.
|
||||
|
||||
9. Do features in this specification enable access to device sensors?
|
||||
|
||||
No.
|
||||
|
||||
10. Do features in this specification enable new script execution/loading
|
||||
mechanisms?
|
||||
|
||||
No.
|
||||
|
||||
11. Do features in this specification allow an origin to access other devices?
|
||||
|
||||
No.
|
||||
|
||||
12. Do features in this specification allow an origin some measure of control over
|
||||
a user agent's native UI?
|
||||
|
||||
No.
|
||||
|
||||
13. What temporary identifiers do the features in this specification create or
|
||||
expose to the web?
|
||||
|
||||
None.
|
||||
|
||||
14. How does this specification distinguish between behavior in first-party and
|
||||
third-party contexts?
|
||||
|
||||
There is no difference in behaviour.
|
||||
|
||||
15. How do the features in this specification work in the context of a browser’s
|
||||
Private Browsing or Incognito mode?
|
||||
|
||||
There is no difference in behaviour for these modes.
|
||||
|
||||
16. Does this specification have both "Security Considerations" and "Privacy
|
||||
Considerations" sections?
|
||||
|
||||
The specification is still in progress. The privacy issues have been highlighted in the explainer.
|
||||
|
||||
17. Do features in your specification enable origins to downgrade default
|
||||
security protections?
|
||||
|
||||
No.
|
||||
|
||||
18. How does your feature handle non-"fully active" documents?
|
||||
|
||||
It only works in fully active documents.
|
||||
|
||||
19. What should this questionnaire have asked?
|
||||
|
||||
No suggestions.
|
||||
63
research/dogfood-coffee/01-references.md
Normal file
63
research/dogfood-coffee/01-references.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# 1단계 — 레퍼런스 조사 (무월 원두 정기구독)
|
||||
|
||||
## 브리프
|
||||
홈카페 3년차 이상 애호가가 **구독 신청 버튼을 누르게**. 느낌은 **차분한, 정확한**.
|
||||
|
||||
## R1 · 구조 — Coffee Collective https://coffeecollective.dk
|
||||
갤러리: ecomm.design `/tag/coffee/` (인덱스에서 href 추출) · 업종: 스페셜티 커피 D2C
|
||||
|
||||
- **가져올 축**: 축 1(그리드), 축 6(시선 흐름·섹션 순서)
|
||||
- **축 1 그리드**: 컨테이너 1425px / 뷰포트 1440px = **0.99 (사실상 풀블리드)**. 내부 2분할 673 + 713px (≈1:1). 보조 컬럼 581·633·650px
|
||||
- **축 2 타입**(참고만): 크기 10종 = 10/12/14/16/20/22/24/25.6/30/48px → **8단계 초과 = 통제 실패**. 최대 48 ÷ 본문 14 = **3.4배 (4배 미만 = 위계 약함)**. 패밀리 1개(NB International Pro)
|
||||
- **축 3 컬러**(참고만): 배경 2단계 #FAF6ED(20) / #F1EDE3(14). 텍스트 1단계 지배 #4C4341(101). **강조 오렌지 #E17526 등장 1회** = 면적 1% 미만. radius 종류 **1개**(50%, 원형 아이콘만) → 각진 형태 어휘
|
||||
- **축 4 여백**: ❌ **빈칸.** `sectionPadding` 추출이 `0px / 0px` 하나만 반환. 이 사이트는 `<section>`에 패딩을 주지 않는다
|
||||
- **축 6 시선**: 히어로(상품 2종 + h1) → 선별커피 → **구독** → 장비 → 특가 → 도매 → 뉴스레터 → 카페위치. CTA `Subscribe now`가 **3번째 섹션**. 목표 행동이 "구매"와 "구독"으로 **갈라져 있음**
|
||||
- **6축 밖의 관측**: ① 내비 최상위에 `Transparency`(생두 구매가·농가 공개). 이 업종의 신뢰 장치는 스토리가 아니라 **거래 내역 공개**다. ② 가격을 히어로에서 즉시 노출(379/161/159 DKK). 숨기지 않는다
|
||||
- **변형**: 컨테이너를 0.99 풀블리드 → **최대 1120px**로 축소. 한국어 본문이 풀블리드에서 한 줄 40자를 넘기 때문. 그리고 목표 행동을 **구독 단 하나로 통합** — 브리프가 구독 전환을 명시했으므로 구매 CTA를 만들지 않는다
|
||||
|
||||
## R2 · 톤 — Fitzcarraldo Editions https://fitzcarraldoeditions.com [업종: 독립 문학 출판 — R1과 다름 ✓]
|
||||
갤러리 라우팅 이탈 — 사유는 FINDINGS.md F2 참조
|
||||
|
||||
- **가져올 축**: 축 2(타입 스케일), 축 3(컬러 역할)
|
||||
- **축 2 타입**: 크기 **5단계** 15/16/18/20.8/22px. 최대 22 ÷ 본문 16 = 1.375배(인덱스 페이지라 위계 약함 — 그대로 안 가져감). 패밀리 2개인데 **둘 다 세리프**(adobe-caslon-pro 120회 + kings-caslon 13회). 본문 16px / line-height 24px = **1.5** / 컬럼 폭 **522px**
|
||||
- **축 3 컬러**: 배경 2단계 #FFFFFF(12) / #FBFBFB(8). 텍스트 **3단계** #151B23(67) / #363636(3) / #666666(7). **강조 = 파랑 #0033A0 하나뿐, 46회 전부 링크**. radius **5종**(2/3/4/5/50%) → 우연히 결정됨(안 가져감)
|
||||
- **축 1 그리드**(참고): 컨테이너 1329~1345px, 2분할 672/673px
|
||||
- **축 4 여백**: ❌ **빈칸.** section padding이 `32px / 32px` 하나만 나옴
|
||||
- **축 6**: h1이 실질적으로 없고 h2는 0개. 위계를 태그가 아니라 크기·색으로만 만든다 — **구조적 결함이므로 안 가져간다**
|
||||
- **6축 밖의 관측**: 상품(책)을 **이미지 없이 제목 + 저자 + 한 문단 설명**으로 판다. 텍스트가 상품 이미지를 대신한다. → 브리프 제약(사진 없음)과 정확히 맞는다
|
||||
- **변형**: 타입 비율을 1.375배 → **최대/본문 4.2배**로 벌린다. 원본은 카탈로그 인덱스라 위계가 필요 없지만 우리는 단일 랜딩이라 첫 화면에서 위계가 서야 한다. 세리프 2종은 **세리프 1 + 산세리프 1**로 교체 — 한글 세리프(마루 부리)를 본문에 쓰면 16px에서 가독성이 떨어진다
|
||||
|
||||
## R3 · 디테일 — Codrops, "A Practical Introduction to Scroll-Driven Animations with CSS scroll() and view()"
|
||||
https://tympanus.net/codrops/2024/01/17/a-practical-introduction-to-scroll-driven-animations-with-css-scroll-and-view/
|
||||
|
||||
- **가져올 축**: 축 5(모션 언어) — R3는 이 축만 채운다
|
||||
- **모션**: `animation-timeline: view()` — 요소가 뷰포트에 교차할 때 페이드 + 상승. **JS 0바이트**
|
||||
- **구현 제약**:
|
||||
- 브라우저: 기사 시점 **Chromium 전용** (Firefox·Safari 미지원)
|
||||
- 이중 게이팅 필수 — `@media (prefers-reduced-motion: no-preference)` 안에 `@supports (animation-timeline: view())`
|
||||
- 폴백: 애니메이션 없음. **콘텐츠는 기본 visible**이어야 한다(antipatterns 5절 "reveal 실패 시 opacity:0 잔존" 회피)
|
||||
- 폴리필(flackr/scroll-timeline) 존재하나 **쓰지 않는다** — 브리프가 "프레임워크 없는 정적 HTML/CSS"이고 모바일 70%다
|
||||
- **변형**: `animation-range`를 `entry 0% entry 40%`로 짧게. 모바일 뷰포트에서는 진입 구간이 길면 요소가 계속 반쯤 투명한 상태로 머문다
|
||||
|
||||
## 회색조 위계
|
||||
가장 강한 덩어리는 **로스팅 프로파일 표(원두 3종의 산미·바디 수치)**, 두 번째 **구독 CTA**, 세 번째 **가격·주기 조건**.
|
||||
비즈니스 우선순위 [1 구독 신청 / 2 원두 선택 근거 제공 / 3 가격 납득]과 — **부분 불일치**. 1위 덩어리가 CTA가 아니다.
|
||||
→ 4-1에서 조정: 프로파일 표는 폭을 넓게 두되 **명도 대비를 낮추고**, CTA는 면적은 작지만 **유일한 반전 블록(잉크 배경 + 오프화이트 텍스트)**으로 만들어 회색조에서 가장 어둡게 한다. 색으로 해결하지 않는다.
|
||||
|
||||
## 안 할 것 (antipatterns.md에서 이 브리프에 특히 위험한 항목)
|
||||
1. **크림/베이지를 "고급"의 기본값으로** (1절) — 커피 브리프에서 가장 빠지기 쉬운 함정. 오프화이트를 쓰되 **왜 그 색인지 브리프와 연결**하고 잉크·강조를 재설계한다
|
||||
2. **근거 없는 지표 배너** (3절) + **모든 숫자가 어림수** (6절) — "10,000+ 구독자", "4.9★" 금지. 사용자가 준 수치가 없다. **5단계 하드 게이트 11번 대상**
|
||||
3. **버튼이 항상 2개 나란히** (4절) — R1이 구매/구독으로 갈라져 있으므로 그대로 베끼면 걸린다. 주 CTA 1개
|
||||
4. **아이콘이 카드 상단 중앙인 3열 피처 카드** (3·4절) — 원두 3종을 이 형태로 만들면 즉시 걸린다. 표 또는 비대칭 리스트로
|
||||
5. **한글 조판 8절 전체** — `word-break: keep-all`, line-height 1.6~1.8, 음수 자간 금지, 한 줄 25~40자, 라틴 폰트를 폴백 앞에
|
||||
6. **이미지 없음 → 떠 있는 3D 블롭·일반 SVG 도형으로 채우기** (7절) — 사진이 없다고 장식 그래픽을 만들지 않는다. R2처럼 **텍스트가 이미지를 대신**한다
|
||||
|
||||
---
|
||||
## 검증 조건 자가 판정
|
||||
- [x] R1/R2/R3 각각 원본 URL (갤러리 URL 아님)
|
||||
- [x] R2 업종이 R1과 다름 (문학 출판 vs 커피 커머스)
|
||||
- [x] 각 슬롯에 가져올 축 명시
|
||||
- [x] 각 슬롯에 변형 1개 + 이유
|
||||
- [~] 6축 값에 숫자 있음 — **축 4(여백 리듬)는 R1·R2 모두 빈칸.** §5 스크립트가 값을 못 뽑았음(FINDINGS F3)
|
||||
- [x] R3에 구현 제약(지원 범위·JS 비용)
|
||||
- [~] 회색조 위계 — 작성했으나 **화면이 아직 없어 추정**이다. 실제 판정은 4-1 이후 (FINDINGS F4)
|
||||
77
research/dogfood-coffee/02-direction.md
Normal file
77
research/dogfood-coffee/02-direction.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# 2단계 — 방향 결정
|
||||
|
||||
## 한 문장 컨셉
|
||||
**감상은 이름에만, 화면에는 수치만.**
|
||||
"무월(霧月)"이라는 이름은 모호하다. 이 브랜드가 파는 것은 모호함이 아니라 **측정된 정확성**이다. 그 낙차를 컨셉으로 쓴다.
|
||||
|
||||
## 프리셋 — ❗ 네 프리셋 중 어느 것도 맞지 않는다. 새로 정의한다: `quiet-commerce`
|
||||
|
||||
판정 근거(각 프리셋 파일의 "쓰지 마라"):
|
||||
|
||||
| 프리셋 | 배제 사유 |
|
||||
|---|---|
|
||||
| `editorial` | "**전환이 목적인 랜딩**에는 쓰지 마라" — 이 브리프의 목표가 정확히 구독 전환이다 |
|
||||
| `anti-grid` | "**전환이 목적인 커머스**에는 쓰지 마라" — 동일 |
|
||||
| `swiss-minimal` | "제품이 복잡할 때. B2B·도구·문서" — 이 제품은 원두 3종이고 B2C다. 적극적 근거가 없다 |
|
||||
| `dark-instrument` | 다크를 정당화할 이유(야간 사용·밝은 데이터 시각화·제품 자체가 어두움)가 브리프에 없다 |
|
||||
|
||||
→ README의 "어느 것도 맞지 않으면 새로 정의해라"를 따른다. 항목 구조는 프리셋 문서 형식 그대로 채운다.
|
||||
(이 판정 자체가 검증 결과다 — FINDINGS.md F5)
|
||||
|
||||
---
|
||||
|
||||
### `quiet-commerce` — 읽히는 커머스
|
||||
|
||||
> 한 문장: **콘텐츠가 설득하고, 화면은 한 번만 요구한다.**
|
||||
> editorial의 타이포 규율 + 전환 구조. 콘텐츠가 주인공이되 CTA가 경로 위에 정확히 하나 있다.
|
||||
|
||||
**언제 고르나**: 상품 수가 적고(≤5), 구매자가 이미 카테고리를 아는 D2C. 사진보다 사양·근거가 설득의 주체일 때.
|
||||
**쓰지 마라**: 상품 수가 많은 커머스(그리드·필터가 주인공이 됨), 카테고리 입문자 대상.
|
||||
|
||||
#### 타이포그래피
|
||||
- 디스플레이 **세리프 1**(R2에서 가져온 원리) + 본문 **산세리프 1**. R2는 세리프 2종이었으나 한글 본문 세리프는 16px에서 가독성이 떨어져 교체
|
||||
- 비율 **1.333**, 최대/본문 **4.2배** (R2의 1.375배를 의도적으로 벌림)
|
||||
- 단계 **4개**. R1은 10단계였다 — 그대로 두면 antipatterns 2절 "8단계 이상 = 통제 실패"에 걸린다
|
||||
- 웨이트 2개
|
||||
|
||||
| 슬롯 | 라틴 | 한글 |
|
||||
|---|---|---|
|
||||
| 디스플레이 | Fraunces (무료, Google Fonts) | 마루 부리 |
|
||||
| 본문·UI | Switzer (Fontshare, 무료 상업) | Pretendard Variable |
|
||||
| 수치 | 본문과 동일 + `tabular-nums` | — |
|
||||
|
||||
#### 색
|
||||
- 배경은 순백이 아니다. **오프화이트**. 단 크림/베이지는 아니다 — antipatterns 1절 "크림을 고급의 기본값으로"에 걸린다. 브리프와의 연결: 무월 = 안개 낀 달 → **회청색이 2% 섞인 종이색**
|
||||
- 잉크는 순흑이 아니다
|
||||
- **강조색 하나, 면적 5% 미만.** R1이 오렌지를 1회만 쓴 것(면적 <1%)과 R2가 파랑을 링크에만 쓴 것(46회, 전부 링크)이 같은 원리다
|
||||
- hue 2개 (중립 + 강조). antipatterns "hue 4개 이상" 회피
|
||||
|
||||
#### 간격
|
||||
- 8px 그리드. 여백 값 **5종**
|
||||
- 섹션 간 : 요소 간 = **1:5**
|
||||
- 비대칭 — 본문을 그리드 왼쪽에 두고 오른쪽 여백에 수치를 흘린다 (R2의 "텍스트가 이미지를 대신한다"를 여백 운용으로 번역)
|
||||
- 컨테이너 최대 **1120px**, 본문 `--measure`는 한글 **32자**
|
||||
|
||||
#### 재질 (`svg-filters.md`)
|
||||
- **필름 그레인 `--grain: 0.03`** — 최약. 오프화이트 배경의 밴딩만 없앤다
|
||||
- 유리·글로우·굴절 없음
|
||||
- 사진이 없으므로 듀오톤·그라디언트 맵은 이번 브리프에서 쓸 곳이 없다
|
||||
|
||||
#### 모션 (`motion.md`)
|
||||
- 거의 움직이지 않는다. R3의 `animation-timeline: view()` 페이드 + 8px 상승 하나만
|
||||
- 스크롤 재킹 금지
|
||||
|
||||
#### 흔한 실패
|
||||
1. 세리프 디스플레이를 쓰고 여백이 좁다
|
||||
2. CTA가 2개로 갈라진다 (R1이 그렇다 — 베끼면 걸린다)
|
||||
3. 수치를 "지표 배너"로 만든다 — 근거 없는 숫자는 신뢰를 깎는다
|
||||
4. 한글에 라틴 세리프만 지정해 한글이 시스템 기본으로 떨어진다
|
||||
|
||||
---
|
||||
|
||||
## 감수할 리스크 하나
|
||||
**히어로에 이미지도 감성 카피도 없이, 원두 3종의 로스팅 프로파일 수치표를 첫 화면에 올린다.**
|
||||
|
||||
- 정당화: 대상이 홈카페 3년차 이상이고 이미 여러 로스터리를 거쳤다. 이들에게 "장인의 손길" 카피는 정보량 0이다. 산미·바디 수치는 즉시 판단 재료가 된다
|
||||
- 이 리스크가 브리프 제약(사진 없음)을 **결함에서 자산으로** 바꾼다
|
||||
- 대담함은 여기 한 곳뿐. 나머지(타이포·색·여백)는 조용히 받쳐준다
|
||||
100
research/dogfood-coffee/03-tokens.css
Normal file
100
research/dogfood-coffee/03-tokens.css
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* ==========================================================================
|
||||
무월(霧月) — 3단계 디자인 토큰
|
||||
프리셋: quiet-commerce (커스텀, 02-direction.md 참조)
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* ---- 타입 — 비율 1.333 (Perfect Fourth), 본문 16px 기준 ---------------
|
||||
tokens.md §6은 --step--1 ~ --step-5 (7단계)를 산출물로 요구한다.
|
||||
antipatterns.md §2는 "3~5단계로 제한"을 권한다.
|
||||
→ 7개를 정의하되 화면에서 실제로 쓰는 것은 4개다:
|
||||
--step--1(라벨) / --step-0(본문) / --step-2(소제목) / --step-5(디스플레이)
|
||||
나머지 3개는 예약. 쓰려면 이 주석을 지우고 역할을 적어라. (FINDINGS F6) */
|
||||
--step--1: 0.75rem; /* 12 — 라벨·캡션 [사용] */
|
||||
--step-0: 1rem; /* 16 — 본문 [사용] */
|
||||
--step-1: 1.333rem; /* 21 — 예약 */
|
||||
--step-2: clamp(1.5rem, 1.32rem + 0.9vw, 1.777rem); /* 28 — 소제목 [사용] */
|
||||
--step-3: 2.369rem; /* 38 — 예약 */
|
||||
--step-4: 3.157rem; /* 51 — 예약 */
|
||||
--step-5: clamp(2.25rem, 1.3rem + 4.75vw, 4.209rem); /* 67 — 디스플레이 [사용] */
|
||||
/* 최대(67) ÷ 본문(16) = 4.2배 — antipatterns.md §2 "최대/본문 4배 이상" 통과 */
|
||||
|
||||
--leading-tight: 1.25; /* 디스플레이 — 한글이라 라틴 1.1보다 높게 */
|
||||
--leading-normal: 1.7; /* 본문 — 한글 1.6~1.8 규칙 */
|
||||
--measure: 32ch; /* 한글 32자. R2 관측 522px과 수렴 */
|
||||
|
||||
--font-display: 'Fraunces', 'Pretendard Variable', Pretendard, serif;
|
||||
--font-body: 'Pretendard Variable', Pretendard, -apple-system, system-ui, sans-serif;
|
||||
/* 폰트 파일 2개 (tokens.md §5 예산 준수).
|
||||
Switzer(라틴 본문)와 마루 부리(한글 디스플레이)를 의도적으로 버렸다 — 사유는 아래 성능 예산 주석. */
|
||||
|
||||
/* ---- 색 — 역할별. hue 2개(중립 + 강조) --------------------------------
|
||||
surface: 안개 낀 종이. 순백 아님, 크림/베이지도 아님(antipatterns §1).
|
||||
accent : 로스팅된 원두 갈색. 보라·인디고 계열 아님(최우선 지문 1위 회피). */
|
||||
--surface: #F3F4F2;
|
||||
--surface-raised: #EAEBE7;
|
||||
--ink: #1A1C1A; /* 순흑 아님 */
|
||||
--ink-muted: #5C605B;
|
||||
--line: #D6D8D3; /* 장식용 구분선 (텍스트·UI 아님) */
|
||||
--line-strong: #868C83; /* 인터랙티브 요소 경계 — 3:1 필요 */
|
||||
--accent: #7A3E1D;
|
||||
--accent-ink: #F3F4F2;
|
||||
|
||||
/* 측정된 대비비 (계산식 WCAG 2.x relative luminance)
|
||||
--ink on --surface 15.54:1 ✓ 본문 4.5:1
|
||||
--ink on --surface-raised 14.32:1 ✓
|
||||
--ink-muted on --surface 5.80:1 ✓
|
||||
--ink-muted on --surface-raised 5.35:1 ✓
|
||||
--accent on --surface 7.51:1 ✓ 링크 텍스트
|
||||
--accent-ink on --accent 7.51:1 ✓ CTA 내부 텍스트
|
||||
--line-strong on --surface 3.12:1 ✓ UI 3:1
|
||||
--line on --surface 1.30:1 — 장식 전용. 텍스트·인터랙티브 경계에 쓰지 마라 */
|
||||
|
||||
/* 다크 모드: 브리프에 근거 없음 → 지원하지 않는다. (가정 명시) */
|
||||
|
||||
/* ---- 간격 — 8px 리듬 ------------------------------------------------- */
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 1rem; /* 16 */
|
||||
--space-4: 1.5rem; /* 24 */
|
||||
--space-5: 2.5rem; /* 40 — 요소 간 */
|
||||
--space-6: 4rem; /* 64 */
|
||||
--space-7: 6rem; /* 96 */
|
||||
--space-8: 10rem; /* 160 — 섹션 간. 요소 간(40) 대비 1:4 */
|
||||
|
||||
/* ---- 형태 ------------------------------------------------------------
|
||||
tokens.md §6 산출물 형식에 radius 항목이 없다. 그런데 SKILL.md 하드 게이트 3이
|
||||
"코너 반경이 규칙 없이 섞여 있는가"를 묻고 antipatterns.md §4는 어휘 2개를 요구한다.
|
||||
→ 토큰을 직접 추가한다. (FINDINGS F8) */
|
||||
--radius-container: 2px; /* 표·패널 — 인쇄물 감각이라 거의 각지게 */
|
||||
--radius-interactive: 2px; /* 버튼·입력 — 컨테이너와 같게 통일 */
|
||||
|
||||
--container: 1120px; /* R1의 풀블리드 0.99를 축소한 값 */
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2vw, 5rem); /* 모바일 20px → 데스크톱 80px */
|
||||
|
||||
/* ---- 모션 ------------------------------------------------------------ */
|
||||
--dur-instant: 100ms;
|
||||
--dur-quick: 200ms;
|
||||
--dur-normal: 350ms;
|
||||
--dur-slow: 600ms;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
|
||||
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ---- 재질 (svg-filters.md) ------------------------------------------- */
|
||||
--grain: 0.03; /* 최약. 오프화이트 배경의 밴딩 제거용 */
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
성능 예산 = JS 0KB / 첫 인터랙션 3초 (모바일 4G)
|
||||
- 프레임워크 없음. R3 모션은 CSS scroll-driven이라 JS 0바이트
|
||||
- 폰트 파일 2개: Fraunces(latin 서브셋) + Pretendard Variable(korean+latin 서브셋)
|
||||
· tokens.md §5 "폰트 2개 이하"를 지키기 위해 4개 후보 중 2개를 버렸다.
|
||||
버린 것: Switzer(라틴 본문) → Pretendard의 라틴으로 대체
|
||||
마루 부리(한글 디스플레이) → Pretendard 700으로 대체
|
||||
· antipatterns.md §8 "라틴 폰트를 폴백 앞에" 규칙은 --font-display에서만 지킨다.
|
||||
--font-body는 Pretendard가 앞이지만, Pretendard는 자체 라틴 글리프를 포함하므로
|
||||
한글 폰트로 라틴이 조악하게 그려지는 문제가 없다. (예외 근거 명시) (FINDINGS F7)
|
||||
- 이미지 없음(브리프 제약) → 이미지 예산 0
|
||||
- 애니메이션 속성: transform / opacity 만
|
||||
========================================================================== */
|
||||
33
research/dogfood-coffee/05-structure-plan.md
Normal file
33
research/dogfood-coffee/05-structure-plan.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# 4단계 — 히어로 이후 섹션 구조 계획 (코드 미작성)
|
||||
|
||||
범위 제한에 따라 히어로만 구현했다. 이하는 계획이다.
|
||||
|
||||
## 섹션 순서 — 브리프의 설득 논리로 재배열
|
||||
antipatterns.md §3 "표준 골격(히어로 → 3열 피처 → 로고월 → 요금제 → FAQ)"을 쓰지 않는다.
|
||||
대상이 이미 여러 로스터리를 거쳤으므로 **불신 → 검증 → 조건 → 행동** 순서로 간다.
|
||||
|
||||
| # | 섹션 | 역할 | 레이아웃 패밀리 | 슬롭 회피 메모 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 히어로 (구현됨) | 판단 재료 즉시 제시 | 헤드 풀폭 + 5:7 비대칭 | 배지 없음, CTA 1개 |
|
||||
| 2 | 척도 설명 | "산미 4"가 무슨 뜻인지 정의 | 좌측 본문 + 우측 여백 주석 | 아이콘 카드 금지. 정의문 3개를 본문으로 |
|
||||
| 3 | 거래 내역 (`#ledger`) | 생두 구매가·농가·환율 공개 | 전폭 표 | R1 Transparency의 원리. **숫자는 실제 값 확보 후에만 공개** |
|
||||
| 4 | 배송 조건 (`#plan`) | 주기·용량·가격·취소 | 2열 비교표(카드 아님) | 요금제 3열 카드 + "Most popular" 배지 금지 |
|
||||
| 5 | 자주 오는 문의 | 실제 문의 3개 | 정의 목록 | 가상 FAQ 6개 금지. 문의가 없으면 섹션 삭제 |
|
||||
| 6 | 푸터 | 사업자 정보·연락처·주소 | 좌측 정렬 단일 블록 | 링크 4열 + 소셜 아이콘 금지 |
|
||||
|
||||
레이아웃 패밀리 4종 (헤드+비대칭 / 본문+여백주석 / 전폭 표 / 정의 목록) → SKILL.md 카운트 규칙 "섹션 6개 이상이면 패밀리 최소 3개" 통과.
|
||||
|
||||
## 확보해야 할 실제 데이터 (없으면 해당 섹션 삭제)
|
||||
- 원두 3종의 산미·바디·단맛 점수와 로스팅 날짜 → 히어로 표의 `—` 자리
|
||||
- 생두 구매가·농가명·수입 시점 → 3번 섹션
|
||||
- 구독 주기별 가격과 취소 정책 → 4번 섹션
|
||||
- 실제 받은 문의 3건 → 5번 섹션
|
||||
- 사업자등록번호·통신판매업 신고번호·주소·전화 → 푸터
|
||||
|
||||
**어느 하나도 지어내지 않는다.** SKILL.md 하드 게이트 11 대상이다.
|
||||
|
||||
## 사진이 생겼을 때
|
||||
브리프상 지금은 없다. 생기면:
|
||||
- `aspect-ratio` 고정으로 CLS 0 유지
|
||||
- 듀오톤(`svg-filters.md` R4)으로 톤 통일 — 여러 산지 사진의 색온도가 제각각일 것이다
|
||||
- 크기 차이로 리듬 생성. 전부 같은 비율 금지
|
||||
82
research/dogfood-coffee/design.md
Normal file
82
research/dogfood-coffee/design.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# design.md — 무월(霧月) 원두 정기구독 랜딩
|
||||
|
||||
> 6단계 산출물. 다음 실행(사람이든 에이전트든)은 이 파일을 읽고 같은 결정을 이어간다.
|
||||
|
||||
## 브리프 3줄
|
||||
- **무엇을**: 수제 커피 로스터리 "무월"의 원두 정기구독 단일 랜딩 페이지
|
||||
- **누구에게**: 홈카페 3년차 이상 30~40대. 여러 로스터리를 거쳤고 산미·로스팅 프로파일을 구분한다. 목표는 구독 신청
|
||||
- **제약**: 한국어 / 정적 HTML·CSS(프레임워크 없음) / 사진 없음 / 모바일 트래픽 70% / 성능 예산 스킬 기본값
|
||||
|
||||
## 레퍼런스
|
||||
| 슬롯 | 사이트 | 업종 | 가져온 축 |
|
||||
|---|---|---|---|
|
||||
| R1 · 구조 | https://coffeecollective.dk | 스페셜티 커피 D2C | 축 1(그리드), 축 6(시선·섹션 순서) |
|
||||
| R2 · 톤 | https://fitzcarraldoeditions.com | 독립 문학 출판 | 축 2(타입 스케일), 축 3(컬러 역할) |
|
||||
| R3 · 디테일 | https://tympanus.net/codrops/2024/01/17/a-practical-introduction-to-scroll-driven-animations-with-css-scroll-and-view/ | — | 축 5(모션) |
|
||||
|
||||
상세 수치와 변형 사유는 `01-references.md`.
|
||||
|
||||
## 한 문장 컨셉
|
||||
**감상은 이름에만, 화면에는 수치만.**
|
||||
|
||||
## 프리셋
|
||||
**`quiet-commerce` (커스텀 정의).** 기존 4개 프리셋이 전부 "전환이 목적인 커머스 랜딩"을 배제하거나 근거가 없어 새로 정의했다. 정의 전문은 `02-direction.md`.
|
||||
|
||||
## 감수한 리스크 하나
|
||||
히어로에 이미지도 감성 카피도 없이 **원두 3종의 로스팅 프로파일 수치표**를 첫 화면에 올린다.
|
||||
→ ⚠️ **현재 미완성**: 실제 수치를 확보하지 못해 표가 `—`로 비어 있다. 값이 들어와야 이 리스크가 성립한다. 지어내지 않았다(하드 게이트 11).
|
||||
|
||||
## 토큰
|
||||
`03-tokens.css` 전문. 요약:
|
||||
- 타입 비율 **1.333**, 본문 16px, 최대/본문 **4.2배**. 7개 정의 / **실사용 4개**
|
||||
- 색 역할 8개. `--surface #F3F4F2` / `--ink #1A1C1A` / `--accent #7A3E1D` (강조 1개, hue 2개)
|
||||
- 대비 실측: 본문 15.54:1 · 보조 5.80:1 · CTA 7.51:1 · 인터랙티브 경계 3.12:1 — 전부 통과
|
||||
- 간격 8단계, 섹션:요소 = 1:4. 컨테이너 1120px, `--measure` 32ch(한글)
|
||||
- 라인하이트 본문 **1.7**(한글), 음수 자간은 디스플레이 `-0.015em`만
|
||||
- radius 어휘 **1개(2px)** — tokens.md에 radius 항목이 없어 직접 추가
|
||||
- 재질 `--grain: 0.03` (필름 그레인 최약, data URI 배경)
|
||||
- 다크 모드: 브리프 근거 없어 **지원하지 않음**
|
||||
|
||||
## 성능 — 예산과 실측
|
||||
| 항목 | 예산 | 실측 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 히어로까지 JS (gzip) | 150KB | **0KB** (script 태그 0개) | ✓ |
|
||||
| WebGL/3D | +200KB | 0 (미사용) | ✓ |
|
||||
| 총 전송량(첫 화면) | 1MB | **13.4KB** (폰트 캐시 상태) | ✓ |
|
||||
| 폰트 패밀리 | 2개 이하 | 2개 (Fraunces, Pretendard) | ✓ |
|
||||
| 폰트 **파일** 수 | 2개 이하 | **14개** | ✗ — 아래 참조 |
|
||||
| 애니메이션 속성 | transform/opacity만 | opacity, transform | ✓ |
|
||||
| LCP / CLS | 2.5초 / 0.1 | **미측정** | 측정 절차 없음 |
|
||||
|
||||
폰트 파일 14개는 Pretendard `dynamic-subset`이 유니코드 범위별로 분할된 결과다. **이것이 올바른 최적화**이며 실제 전송은 페이지에 쓰인 글자 범위만이다. tokens.md의 "폰트 파일 2개 이하"를 문자 그대로 적용하면 한글 프로젝트에서 잘못된 방향(단일 대용량 파일)으로 유도된다. 예산 항목을 **패밀리 수**로 읽고 통과 처리했으며, 그 사실을 여기 명시한다.
|
||||
|
||||
## 채택한 이펙트와 폴백
|
||||
| 이펙트 | 구현 | 폴백 |
|
||||
|---|---|---|
|
||||
| 필름 그레인 | data URI SVG 배경, `opacity: .03`, `mix-blend-mode: multiply` | 없어도 무해. 배경색만 남는다 |
|
||||
| 스크롤 진입 페이드+8px 상승 | `animation-timeline: view()`, JS 0 | Chromium 외에서는 **아무 일도 안 일어남**. 콘텐츠는 처음부터 visible |
|
||||
|
||||
이중 게이팅: `@media (prefers-reduced-motion: no-preference)` 안에 `@supports (animation-timeline: view())`.
|
||||
|
||||
## 5단계 감사 결과
|
||||
- 기계 검사 3종: 이펙트 전부 끄기 **통과**(h1·CTA·표 3행 모두 렌더, 본문 401자) / 슬롭 grep 15개 패턴 **전부 0건** / 경쟁사 치환 **통과**
|
||||
- 하드 게이트 12개: 전부 "아니오" (검사 근거는 `FINDINGS.md` §하드게이트)
|
||||
- 감사 중 발견해 고친 것 2건:
|
||||
1. 히어로 헤드라인이 데스크톱에서 **5줄** → 카운트 규칙(≤2줄) 위반. 헤드라인을 두 컬럼 가로지르게 하고 `max-width: 20ch` 적용
|
||||
2. 320px에서 가로 오버플로 **31px** → 하드 게이트 5 위반. grid item `min-width: 0` + 표를 `overflow-x: auto` 컨테이너로 감쌈
|
||||
|
||||
## 의도적으로 하지 않은 것과 그 이유
|
||||
- **다크 모드** — 브리프에 야간 사용·데이터 시각화 근거가 없다. tokens.md "다크를 기본값으로 삼지 마라"
|
||||
- **한글 세리프 디스플레이(마루 부리)** — 폰트 패밀리 2개 예산을 지키려 버렸다. 그 결과 프리셋이 정한 "세리프 디스플레이"가 **한글에서는 실현되지 않는다**(Fraunces는 라틴만 커버). 예산을 3개로 올릴 수 있다면 되돌릴 결정이다
|
||||
- **Switzer(라틴 본문)** — 같은 이유. Pretendard의 라틴 글리프로 대체
|
||||
- **구매 CTA** — R1은 "구매"와 "구독"으로 갈라져 있다. 브리프가 구독 전환을 명시했으므로 CTA를 하나로 통합했다. 원두 낱개 판매를 나중에 붙이더라도 **이 페이지에는 넣지 않는다**
|
||||
- **3D / WebGL** — 필터로 낼 수 없는 인상이 없다. 4-3 미실행
|
||||
- **스크롤 애니메이션 폴리필** — JS 0 예산을 지키기 위해. Firefox·Safari 사용자는 모션 없이 본다
|
||||
- **지표 배너 / 후기 / 로고월** — 검증 가능한 데이터가 없다. 생기기 전까지 섹션을 만들지 않는다
|
||||
- **아이콘** — 이 페이지에 아이콘이 0개다. 정보를 아이콘으로 대체하지 않기로 했다
|
||||
|
||||
## 다음 실행이 해야 할 일
|
||||
1. `05-structure-plan.md`의 "확보해야 할 실제 데이터"를 사용자에게 받는다
|
||||
2. 히어로 표의 `—`를 채운다. **채우기 전까지 이 페이지는 배포하지 않는다**
|
||||
3. 히어로 이후 섹션 6개를 계획대로 구현
|
||||
4. 폰트 서브셋 파일을 자체 호스팅으로 옮긴다(현재 CDN 2곳 의존)
|
||||
125
research/dogfood-coffee/index.html
Normal file
125
research/dogfood-coffee/index.html
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>무월 — 격주 원두 구독</title>
|
||||
<meta name="description" content="원두마다 산미·바디·단맛을 같은 척도로 측정해 표기합니다. 로스팅 날짜와 생두 구매가를 함께 적습니다.">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600&display=swap">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable-dynamic-subset.min.css">
|
||||
|
||||
<link rel="stylesheet" href="03-tokens.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 필터 정의: 필름 그레인 (svg-filters.md R1 — data URI 대신 인라인이 필요할 때만) -->
|
||||
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="page-header__inner">
|
||||
<a class="wordmark" href="/">무월<span class="wordmark__han">霧月</span></a>
|
||||
<nav aria-label="주 메뉴">
|
||||
<ul class="nav">
|
||||
<li><a href="#beans">원두</a></li>
|
||||
<li><a href="#plan">구독</a></li>
|
||||
<li><a href="#ledger">거래 내역</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<div class="hero__inner">
|
||||
|
||||
<div class="hero__head reveal">
|
||||
<p class="eyebrow">격주 원두 구독</p>
|
||||
<h1 id="hero-title" class="hero__title">
|
||||
취향은 형용사가 아니라 좌표입니다
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="hero__lede reveal">
|
||||
<p class="hero__body">
|
||||
무월은 원두마다 산미·바디·단맛을 같은 척도로 재서 적습니다.
|
||||
로스팅한 날짜와 생두를 얼마에 샀는지도 같이 적습니다.
|
||||
마셔보기 전에 판단할 수 있어야 고르는 재미가 생깁니다.
|
||||
</p>
|
||||
<p class="hero__action">
|
||||
<a class="cta" href="#plan">2주마다 받아보기</a>
|
||||
</p>
|
||||
<p class="hero__note">
|
||||
첫 발송 전까지 언제든 취소. 배송 주기는 2주 또는 4주 중에 고릅니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero__profile reveal">
|
||||
<h2 class="profile__title">이번 주 로스팅</h2>
|
||||
<div class="profile__scroll" tabindex="0" role="region" aria-label="로스팅 프로파일 표 (가로 스크롤)">
|
||||
<table class="profile">
|
||||
<caption class="visually-hidden">원두 3종의 산미·바디·단맛 측정값과 로스팅 날짜</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="profile__name">원두</th>
|
||||
<th scope="col">산미</th>
|
||||
<th scope="col">바디</th>
|
||||
<th scope="col">단맛</th>
|
||||
<th scope="col" class="profile__date">볶은 날</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row" class="profile__name">
|
||||
<span class="profile__origin">에티오피아</span>
|
||||
<span class="profile__lot">구지 우라가 · 워시드</span>
|
||||
</th>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="profile__name">
|
||||
<span class="profile__origin">콜롬비아</span>
|
||||
<span class="profile__lot">우일라 · 허니</span>
|
||||
</th>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="profile__name">
|
||||
<span class="profile__origin">과테말라</span>
|
||||
<span class="profile__lot">우에우에테낭고 · 워시드</span>
|
||||
</th>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
<td class="num" data-pending>—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="profile__legend">
|
||||
1에서 5까지. 같은 추출 조건(20g / 300mL / 92°C)에서 로스터 3인이 매깁니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 이후 섹션은 4단계 범위 밖 — 구조 계획만. 05-structure-plan.md 참조 -->
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
270
research/dogfood-coffee/style.css
Normal file
270
research/dogfood-coffee/style.css
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/* ==========================================================================
|
||||
무월 — 4단계 구현 (히어로 섹션까지)
|
||||
모든 값은 03-tokens.css 에서 나온다. 하드코딩 금지.
|
||||
========================================================================== */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
/* 한글 조판 — antipatterns.md §8 */
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
letter-spacing: 0; /* 한글 본문 음수 자간 금지 */
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* 재질: 필름 그레인 최약 (svg-filters.md R1, --grain 0.03)
|
||||
요소 filter가 아니라 data URI 배경 → 한 번만 래스터화되고 캐시된다 */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: var(--grain);
|
||||
mix-blend-mode: multiply; /* 밝은 배경이므로 overlay 대신 multiply */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
img, svg { max-width: 100%; height: auto; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0; border: 0;
|
||||
clip-path: inset(50%); overflow: hidden; white-space: nowrap;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute; left: var(--space-3); top: var(--space-3);
|
||||
transform: translateY(-200%);
|
||||
z-index: 10;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--ink); color: var(--surface);
|
||||
border-radius: var(--radius-interactive);
|
||||
text-decoration: none;
|
||||
transition: transform var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
.skip:focus-visible { transform: translateY(0); }
|
||||
|
||||
:where(a, button, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-interactive);
|
||||
}
|
||||
|
||||
/* ---- 그리드 --------------------------------------------------------------
|
||||
layout.md §1 — 하나의 그리드에서 모든 것이 나온다 */
|
||||
.page-header__inner,
|
||||
.hero__inner {
|
||||
width: 100%;
|
||||
max-width: var(--container);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--pad-inline);
|
||||
}
|
||||
|
||||
/* ---- 헤더 ---------------------------------------------------------------- */
|
||||
.page-header { padding-block: var(--space-4); }
|
||||
|
||||
.page-header__inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em; /* 한글 디스플레이 허용 범위 */
|
||||
}
|
||||
.wordmark__han {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
margin-inline-start: var(--space-2);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin: 0; padding: 0;
|
||||
list-style: none;
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
padding-block: var(--space-2); /* 터치 타깃 확보 */
|
||||
display: inline-block;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-instant) var(--ease-out),
|
||||
border-color var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
.nav a:hover { color: var(--ink); border-bottom-color: var(--line-strong); }
|
||||
|
||||
/* ---- 히어로 --------------------------------------------------------------
|
||||
layout.md §1 "비대칭을 두려워하지 마라" — 좌 5 : 우 7 */
|
||||
.hero { padding-block: var(--space-7) var(--space-8); }
|
||||
|
||||
.hero__inner {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
align-items: start;
|
||||
}
|
||||
/* grid item 기본 min-width:auto 가 표의 최소폭을 그대로 반영해
|
||||
320px에서 가로 스크롤을 만든다 (하드 게이트 5) */
|
||||
.hero__inner > * { min-width: 0; }
|
||||
|
||||
@media (min-width: 60rem) {
|
||||
.hero__inner {
|
||||
grid-template-columns: 5fr 7fr;
|
||||
gap: var(--space-6) var(--space-7);
|
||||
}
|
||||
/* 헤드라인은 두 컬럼을 가로지른다 — 좁은 컬럼에 두면 한글이 5줄로 접힌다
|
||||
(SKILL.md 카운트 규칙: 히어로 헤드라인 ≤ 2줄) */
|
||||
.hero__head { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
.hero__head { margin-bottom: var(--space-2); }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
/* 전체 대문자 아님 — antipatterns 최우선 지문 2위 회피 */
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
margin: 0;
|
||||
max-width: 20ch; /* 한글 20자 — 데스크톱에서 2줄, 모바일에서 3줄 상한 */
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-5);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.015em; /* 대형 헤드라인만. 한글 허용 -0.01~-0.02em */
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.hero__body {
|
||||
margin: 0 0 var(--space-5);
|
||||
max-width: var(--measure);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.hero__action { margin: 0 0 var(--space-3); }
|
||||
|
||||
/* 주 CTA 1개. 페이지에서 유일한 반전 블록 → 회색조에서 가장 어둡다 */
|
||||
.cta {
|
||||
display: inline-block;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-radius: var(--radius-interactive);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
min-height: 44px; /* 터치 타깃 */
|
||||
transition: background-color var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
.cta:hover { background: var(--ink); }
|
||||
|
||||
.hero__note {
|
||||
margin: 0;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
/* ---- 프로파일 표 ---------------------------------------------------------- */
|
||||
.profile__title {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 400;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* 좁은 폭에서는 표만 스스로 스크롤한다. 페이지 본문은 넘치지 않는다 */
|
||||
.profile__scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.profile {
|
||||
width: 100%;
|
||||
min-width: 20rem;
|
||||
border-collapse: collapse;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-container);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile th,
|
||||
.profile td {
|
||||
padding: var(--space-3);
|
||||
text-align: right;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 400;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.profile thead th {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
border-bottom-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.profile tbody tr:last-child th,
|
||||
.profile tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
.profile__name { text-align: left; }
|
||||
|
||||
.profile__origin { display: block; }
|
||||
|
||||
.profile__lot {
|
||||
display: block;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.profile__date { white-space: nowrap; }
|
||||
|
||||
.num[data-pending] { color: var(--ink-muted); }
|
||||
|
||||
.profile__legend {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
/* ---- 모션 ----------------------------------------------------------------
|
||||
R3: Codrops scroll-driven animation. JS 0바이트.
|
||||
이중 게이팅 — reduced-motion 안에 @supports.
|
||||
기본 상태는 visible. 지원 안 하면 아무 일도 일어나지 않는다.
|
||||
(antipatterns §5 "reveal 실패 시 opacity:0 잔존" 회피) */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.reveal {
|
||||
animation: rise var(--dur-slow) var(--ease-out) both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% entry 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
research/dogfood-saas/01-references.md
Normal file
82
research/dogfood-saas/01-references.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# 1단계 — 레퍼런스 조사 (Pulsegate)
|
||||
|
||||
## 브리프
|
||||
백엔드/SRE 엔지니어가 **무료 체험에 가입**하게. 느낌은 **정밀한, 절제된**.
|
||||
|
||||
---
|
||||
|
||||
## R1 · 구조 — Checkly https://www.checklyhq.com
|
||||
같은 업종(합성 모니터링 SaaS). 라우팅 표의 Refero/Land-book이 모두 막혀 원본 사이트로 직행함.
|
||||
|
||||
- **가져올 축**: 축 1(그리드), 축 4(여백 리듬), 축 6(시선 흐름)
|
||||
- **그리드**: 컨테이너 1232px (뷰포트 1440 대비 86%) / 본문 컬럼 450px / 내부 분할 584·672·745px
|
||||
- **여백**: 섹션 상하 `96/64` `80/80` `64/80` `48/48` — 4종. 섹션 간 80 : 요소 간 16~24 ≈ **1:4**
|
||||
- **타입(참고용)**: h1 48px / 본문 16px → **최대÷본문 = 3.0배 → 4배 미만, 위계가 약한 레퍼런스**
|
||||
- **시선 흐름**: 수직 낙하. h1 → 서브카피 → CTA 2개(`Book a demo` + `Start for free`) → 터미널 스니펫 `npx checkly init` → 로고월
|
||||
- **6축 밖의 관측**
|
||||
- 히어로에 **실행 가능한 명령어**를 배치한다(`npx checkly init`). 스크린샷보다 강한 "셋업 시간" 증거다
|
||||
- 온보딩 섹션이 **시간 눈금**으로 되어 있다(`Day 1 0:00 / 0:05 / 0:15`). 소요 시간을 주장 대신 구조로 보여준다
|
||||
- radius 어휘 **9종**(2·4·6·8·12·16·9999·50%·복합) → antipatterns 4장 "radius가 결정이 아님"에 해당. 가져오지 않는다
|
||||
- CTA가 12종. 주 행동이 흐려져 있다
|
||||
- **변형**: 컨테이너를 **1232 → 1120**으로 축소하고 본문 컬럼을 **450 → 640px**로 넓힌다. Checkly의 450px는 16px 본문에서 한 줄 55자 내외라 짧고, 우리 카피는 조건절이 길다
|
||||
|
||||
---
|
||||
|
||||
## R2 · 톤 — Fitzcarraldo Editions https://fitzcarraldoeditions.com [업종: 독립 출판사 — R1과 다름 ✓]
|
||||
Klim(타입 파운드리)에서 출발했으나 페이지가 자동 이탈해 이 사이트의 값을 측정하게 됨(→ FINDINGS D-2).
|
||||
reference-method.md §1이 예시로 든 "SaaS 구조 + 독립 출판사 톤" 조합에 정확히 해당해 그대로 채택.
|
||||
|
||||
- **가져올 축**: 축 3(컬러 역할), 축 2(타입 스케일)
|
||||
- **컬러 역할**: surface 2단계(`#FFFFFF` / `#FBFBFB`) · text 3단계(`rgb(21,27,35)` 잉크 / `rgb(102,102,102)` 보조 / `rgb(54,54,54)`) · **강조 1색 `rgb(0,51,160)` 코발트 블루**, 등장 45회지만 전부 **링크 텍스트** — 즉 강조가 면적이 아니라 **글자**로만 존재한다
|
||||
- **타입**: 패밀리 2개(`adobe-caslon-pro` + `kings-caslon`) — **전면 세리프**. 크기 5단계(15/16/18/20.8/22). 최대÷본문 = **1.375배**
|
||||
- **여백/형태**: radius 2·3·4·5px — **작고 4종**. 본문 컬럼 522px
|
||||
- **6축 밖의 관측**
|
||||
- **강조색을 배경으로 절대 쓰지 않는다.** 파랑은 오직 링크. "강조색을 지우면 여전히 읽히는가?" → 읽힌다
|
||||
- 위계를 크기가 아니라 **서체 성격과 여백**으로 만든다. 1.375배라는 평평한 스케일이 성립하는 이유
|
||||
- **변형**: 타입 스케일을 **1.375배 → 1.333 비율 5단계(최대÷본문 3.2배)**로 벌린다. 출판사는 목록을 훑게 하지만 우리는 **하나의 CTA로 떨어뜨려야** 하므로 위계가 더 필요하다. 세리프는 디스플레이에만 쓰고 본문은 산세리프로(코드·수치 가독성)
|
||||
|
||||
---
|
||||
|
||||
## R3 · 디테일 — Codrops «Creating Scroll-Driven SVG Map Animations with GSAP»
|
||||
https://tympanus.net/codrops/2026/05/21/creating-scroll-driven-svg-map-animations-with-gsap/
|
||||
|
||||
- **가져올 축**: 축 5(모션 언어) — R3는 이 축만 채운다
|
||||
- **모션**: 스크롤 진행도에 SVG `path`의 그리기 진행(`drawSVG`)을 바인딩하고, 점이 경로를 따라 이동. 뷰포트가 점을 추적하며 `scale 2.5 → 4`
|
||||
- **원문 값**: `scrub: 1` / `pin` / `duration: 1` / `ease: 'expo'` (추적), `ease: 'power1.inOut'` (줌)
|
||||
- **구현 제약**
|
||||
- 원문은 `gsap` + `ScrollTrigger` + `DrawSVGPlugin` + `MotionPathPlugin` — **4개 합계 약 60KB(gzip)**. 히어로 JS 예산 150KB의 **40%**
|
||||
- `pin`은 레이아웃을 `transform`으로 고정한다. 모바일 주소창 리사이즈에서 재계산이 필요하고 `ScrollTrigger.refresh()`를 폰트 로드 후 호출해야 한다
|
||||
- 원문에 성능·브라우저 지원 언급 **없음**(→ FINDINGS D-4). 아래 제약은 내가 판정한 것
|
||||
- **변형**: GSAP을 쓰지 않고 **CSS `animation-timeline: view()` + `stroke-dashoffset`**으로 대체한다. JS 0KB. 미지원 브라우저에서는 경로가 처음부터 그려진 상태로 남아 정보 손실이 없다. 이유: 60KB를 "장식 모션 하나"에 쓰면 셋업 시간을 파는 제품의 페이지가 스스로를 반증한다
|
||||
|
||||
---
|
||||
|
||||
## 회색조 위계
|
||||
색을 전부 제거했을 때 가장 강한 덩어리는 **h1(셋업 시간 주장)**, 두 번째는 **터미널 설치 명령 블록**, 세 번째는 **주 CTA 버튼**.
|
||||
|
||||
비즈니스 우선순위 [1 무료 체험 가입 / 2 셋업이 짧다는 증거 / 3 가격 투명성]와 **일치하지 않는다.**
|
||||
→ 조정: CTA를 두 번째로 올리고 명령 블록을 세 번째로 내린다. **크기·굵기·여백으로 조정하고 색은 쓰지 않는다.** 명령 블록은 배경 톤만 다르게 하고 테두리·그림자를 주지 않는다.
|
||||
|
||||
수정 후: **h1 → CTA → 명령 블록** = [1/2/3]과 일치.
|
||||
|
||||
---
|
||||
|
||||
## 안 할 것 (antipatterns.md 중 이 브리프에 특히 위험한 것)
|
||||
|
||||
1. **§1 「네온 온 다크」** — "게이밍·개발자 툴 브리프가 아닌데 나오면 즉시 티가 남". 우리는 개발자 툴이라 면책 대상처럼 보이지만, 그게 정확히 관성이다. 발광 대신 **명도 차이로만** 위계를 만든다
|
||||
2. **§4 「펄스 애니메이션 상태 점」** — 모니터링 제품의 최대 유혹. "All systems operational" 옆 깜빡이는 초록 원. **상태가 실제로 바뀔 때만** 쓴다. 랜딩의 정적 목업에는 넣지 않는다
|
||||
3. **§2 「모노스페이스를 장식으로」** — 터미널 미학을 흉내내려 라벨까지 mono로 가는 것. **코드·데이터·식별자에만** 쓴다
|
||||
4. **§6 「모든 숫자가 어림수」** — `99.9% uptime`, `10,000+ users`. Checkly도 `20+`, `10x`를 쓴다. 우리는 **측정 조건이 붙은 정확한 수치**만 쓴다
|
||||
5. **§3 「표준 골격」** — 히어로 → 3열 피처 → 로고월 → 요금제 → FAQ. Checkly가 거의 이 순서다. 섹션 순서를 **설득 논리**로 재배열한다
|
||||
6. **§4 「요금제 3열 + Most popular 배지」** — 가격 민감 청중이라 요금 섹션이 필수인데, 가장 재현율 높은 템플릿이다. 비교 표나 단일 가격으로 간다
|
||||
|
||||
---
|
||||
|
||||
## 검증 조건 자가 확인
|
||||
- [x] R1/R2/R3 각각 원본 사이트 URL 있음 (갤러리 URL 아님)
|
||||
- [x] R2 업종이 R1과 다름 (모니터링 SaaS ↔ 독립 출판사)
|
||||
- [x] 각 슬롯에 가져올 축 명시
|
||||
- [x] 각 슬롯에 변형 1개와 이유
|
||||
- [x] 6축에 숫자 있음 — **§5 computed style을 R1·R2 모두 실행함** (Playwright)
|
||||
- [x] R3에 구현 제약 있음 (단, 원문이 아니라 내가 판정)
|
||||
- [x] 회색조 위계 — 불일치를 발견하고 조정안을 적음
|
||||
46
research/dogfood-saas/02-direction.md
Normal file
46
research/dogfood-saas/02-direction.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# 2단계 — 방향 결정 (Pulsegate)
|
||||
|
||||
## 한 문장 컨셉
|
||||
**야간 당직실의 계기판 — 읽히도록 어둡고, 출판물처럼 조판된.**
|
||||
|
||||
R1(Checkly)에서 구조를, R2(Fitzcarraldo)에서 "강조색은 배경이 아니라 글자에만"을 가져와 합친 결과다.
|
||||
계기판이되 네온이 아니고, 출판물이되 종이가 아니다.
|
||||
|
||||
## 프리셋
|
||||
`dark-instrument` — 개발자·데이터·모니터링 제품.
|
||||
|
||||
### 다크를 기본값으로 삼는 근거 (프리셋이 요구한 정당화)
|
||||
`dark-instrument.md`의 정당화 예시 3개 중 **2개에 해당한다.**
|
||||
1. **"사용자가 어두운 환경에서 본다"** — 청중이 온콜 SRE다. 장애는 새벽에 난다
|
||||
2. **"제품 자체(터미널·에디터·모니터링)가 어둡다"** — 제품 스크린샷이 다크 대시보드다. 랜딩이 라이트면 스크린샷이 페이지에서 뜬다
|
||||
|
||||
근거를 댈 수 있으므로 다크를 기본으로 간다. 단 `dark-instrument.md` 흔한 실패 #3에 따라 **라이트를 파생물이 아니라 동등한 테마로** 만든다(브리프도 둘 다 요구).
|
||||
|
||||
## R2를 섞은 지점 (프리셋 평균을 벗어나기 위해)
|
||||
- 디스플레이 서체를 **세리프**로 — dark-instrument 기본값은 산세+모노다. 출판사 톤을 여기에 넣는다
|
||||
- **강조색을 면적으로 쓰지 않는다** — Fitzcarraldo는 코발트를 45회 쓰지만 전부 링크 글자다
|
||||
- radius를 **2~4px**로 — 출판물의 각진 인상. dark-instrument는 radius를 규정하지 않는다
|
||||
|
||||
## 감수할 리스크 하나
|
||||
> **주 CTA 버튼에 강조색을 쓰지 않는다.** CTA는 잉크 반전(밝은 표면 + 어두운 글자)으로 만들고, 코발트는 오직 인라인 링크와 데이터 축에만 쓴다.
|
||||
|
||||
- **왜 리스크인가**: B2B SaaS 랜딩에서 주 CTA를 무채색으로 두는 건 전환율 관행에 반한다
|
||||
- **왜 정당한가**: 이 페이지에서 가장 밝은 덩어리가 CTA 하나뿐이면 색 없이도 시선이 거기로 간다(회색조 위계 1위 = h1, 2위 = CTA). 동시에 `antipatterns.md` 최우선 지문 1위(보라·인디고 CTA)와 §1 「네온 온 다크」를 구조적으로 회피한다
|
||||
- **철회 조건**: 프리플라이트 회색조 검사에서 CTA가 3위 밖으로 밀리면 즉시 강조색을 넣는다
|
||||
|
||||
## 섹션 순서 — 표준 골격을 쓰지 않는다
|
||||
`antipatterns.md` §3 「표준 골격」(히어로 → 3열 피처 → 로고월 → 요금제 → FAQ)을 버리고 **설득 논리**로 재배열한다.
|
||||
|
||||
| # | 섹션 | 역할 | 왜 여기인가 |
|
||||
|---|---|---|---|
|
||||
| 1 | 히어로 | 셋업 시간 주장 + 설치 명령 | 가격·시간 민감 청중에게 첫 화면에서 시간을 말한다 |
|
||||
| 2 | 반론 처리 | "이미 Datadog을 쓴다" | 이 청중의 실제 첫 반응. 피처 나열보다 먼저 온다 |
|
||||
| 3 | 실제 화면 | 제품 스크린샷 3장 | 주장 다음에 증거 |
|
||||
| 4 | 가격 | 단일 가격 + 계산 근거 | 민감 요인을 숨기지 않고 중간에 |
|
||||
| 5 | 마이그레이션 | Datadog/Grafana에서 옮기는 절차 | 전환 장벽 제거 |
|
||||
| 6 | 최종 CTA + 푸터 | | |
|
||||
|
||||
**로고월·후기·FAQ 없음.** 검증 가능한 고객 실명과 실제 문의가 없으므로 `antipatterns.md` §4에 따라 섹션 자체를 만들지 않는다.
|
||||
|
||||
## 4단계 범위 (이번 검증)
|
||||
1번 히어로만 코드로 만든다. 2~6번은 위 표가 계획이다.
|
||||
160
research/dogfood-saas/03-tokens.md
Normal file
160
research/dogfood-saas/03-tokens.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# 3단계 — 디자인 토큰과 예산 (Pulsegate)
|
||||
|
||||
## ⚠ 작업 중 만난 충돌 — 타입 스케일
|
||||
|
||||
세 문서가 **산술적으로 동시에 만족 불가능한** 값을 준다.
|
||||
|
||||
| 문서 | 요구 |
|
||||
|---|---|
|
||||
| `presets/dark-instrument.md` | 비율 **1.200~1.250** (정보 밀도가 높으니 위계 차이를 작게) |
|
||||
| `antipatterns.md` §2 | **최대÷본문 4배 이상** (17→68). 단계 **8개 이상은 통제 실패** |
|
||||
| `reference-method.md` 축2 | 단계 **3–5가 건강** |
|
||||
|
||||
1.25 비율로 5단계면 최대÷본문 = 1.25⁴ = **2.44배**. 6단계여도 3.05배. **4배를 만들려면 8단계가 필요**하고 그건 "통제 실패"다.
|
||||
|
||||
**해소**: `tokens.md` §1에 답이 있다 — *"헤드라인만 다른 비율을 쓰고 싶으면 그건 비율이 아니라 **디스플레이 사이즈를 따로 정의**하는 것이다."*
|
||||
→ 본문 스케일은 1.25 5단계로 두고, **디스플레이 2개를 스케일 밖에 별도 정의**한다. h1÷본문 = 64/16 = **4.0배** 달성.
|
||||
이 해소 경로는 `antipatterns.md`나 `dark-instrument.md`에서 역참조되지 않는다(→ FINDINGS D-1).
|
||||
|
||||
---
|
||||
|
||||
## 1. 타입
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 본문 스케일 — 비율 1.250, 5단계 (dark-instrument 준수) */
|
||||
--step--1: 0.8125rem; /* 13 — 캡션, 모노 라벨, 표 셀 */
|
||||
--step-0: 1rem; /* 16 — 본문 */
|
||||
--step-1: 1.25rem; /* 20 — 리드 문단 */
|
||||
--step-2: 1.5625rem; /* 25 — h3 */
|
||||
--step-3: 1.9531rem; /* 31 — h2 */
|
||||
|
||||
/* 디스플레이 — 스케일 밖. 위계 차이를 만드는 유일한 장치 */
|
||||
--display-2: clamp(2rem, 1.4rem + 3vw, 2.5rem); /* 32→40 — 섹션 헤드 */
|
||||
--display-1: clamp(2.75rem, 1.8rem + 4.8vw, 4rem); /* 44→64 — h1 */
|
||||
|
||||
--leading-tight: 1.05; /* 디스플레이 */
|
||||
--leading-snug: 1.3; /* h2/h3 */
|
||||
--leading-normal: 1.55; /* 본문 (라틴) */
|
||||
--measure: 68ch; /* 영문 68자 — R1 변형에서 정한 640px 대역 */
|
||||
|
||||
--font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif;
|
||||
--font-body: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
--tracking-display: -0.025em; /* -0.04em 상한 준수 */
|
||||
--tracking-body: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**폰트 선택 근거**
|
||||
- 본문/모노를 **IBM Plex Sans + IBM Plex Mono**로 — `dark-instrument.md` 표의 후보. 한 슈퍼패밀리라 광학 크기가 맞는다
|
||||
- `antipatterns.md` §2가 **Inter를 이유 없이 쓰는 것**을 금지. R1(Checkly)이 정확히 Inter라 여기서 갈라진다
|
||||
- 디스플레이 세리프는 R2(전면 세리프 출판사)에서 온다. **주의**: `antipatterns.md` §2는 `Instrument Serif`를 `Space Grotesk + Geist`와의 **3종 조합**일 때 지문으로 지목한다. 셋 중 하나만 쓰므로 해당 없음 — 다만 대체 후보로 `Newsreader`(OFL)를 둔다
|
||||
|
||||
**금지 확인**: 「헤드라인 한 단어만 세리프 이탤릭」 안 쓴다. 디스플레이는 전체가 세리프다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 색 — 두 테마를 동등하게 정의
|
||||
|
||||
`dark-instrument.md` 흔한 실패 #3(다크만 만들고 라이트 안 만듦)에 따라 라이트를 파생물이 아니라 **동등한 정의**로 적는다.
|
||||
|
||||
```css
|
||||
/* 라이트를 먼저 쓴다 — tokens.md "라이트로 설계하고 다크는 토큰으로 파생" 형식은 지키되,
|
||||
기본 적용은 다크다(2단계에서 정당화). */
|
||||
:root {
|
||||
--surface: #F7F5F0; /* 오프화이트. 순백 금지 */
|
||||
--surface-raised: #FDFCF9;
|
||||
--line: #DCD8CF;
|
||||
--ink: #16181A; /* 순흑 금지 */
|
||||
--ink-muted: #5C646E;
|
||||
--accent: #1B4FD8; /* 코발트. 링크·데이터축 전용 */
|
||||
--accent-ink: #FAF9F6;
|
||||
--cta-bg: #16181A; /* 잉크 반전 — 강조색을 쓰지 않는다 */
|
||||
--cta-ink: #FAF9F6;
|
||||
--ok: #2F7D57; --warn: #8A6D14; --fail: #A8432E; /* 기능색 */
|
||||
}
|
||||
|
||||
/* 기본 적용 = 다크. 사용자가 라이트를 명시하면 위 값으로 되돌아간다 */
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { /* 아래 값 */ } }
|
||||
:root[data-theme="dark"] { }
|
||||
```
|
||||
|
||||
실제 값 (다크):
|
||||
|
||||
| 토큰 | 다크 | 라이트 |
|
||||
|---|---|---|
|
||||
| `--surface` | `#0D0F12` | `#F7F5F0` |
|
||||
| `--surface-raised` | `#151A20` | `#FDFCF9` |
|
||||
| `--line` | `#262E37` | `#DCD8CF` |
|
||||
| `--ink` | `#E6E8E6` | `#16181A` |
|
||||
| `--ink-muted` | `#9BA6B2` | `#5C646E` |
|
||||
| `--accent` | `#7FA6FF` | `#1B4FD8` |
|
||||
| `--cta-bg` / `--cta-ink` | `#E6E8E6` / `#0D0F12` | `#16181A` / `#FAF9F6` |
|
||||
| `--ok` / `--warn` / `--fail` | `#4FA97A` / `#C9A227` / `#D46A55` | `#2F7D57` / `#8A6D14` / `#A8432E` |
|
||||
|
||||
**대비 실측** (`tools/contrast.mjs`, WCAG 2.x): 두 테마 **20개 항목 전부 통과**. 최저값은 다크 `--fail` 4.99:1, 라이트 `--warn` 4.78:1.
|
||||
|
||||
**규칙**
|
||||
- 강조색 hue는 **1개**(코발트). 상태색 3개는 `tokens.md` 정의상 강조색이 아니라 **기능색**이므로 "hue 3개 이하" 계산에서 제외한다
|
||||
- **강조색을 배경으로 쓰지 않는다** (R2). 코발트는 링크 글자와 데이터 축선에만
|
||||
- `--line`은 대비 1.4:1이라 **정보를 전달하지 않는 장식**으로만 쓴다. 구분이 필요한 곳은 `--surface-raised` 톤 차이로
|
||||
|
||||
---
|
||||
|
||||
## 3. 간격 — 4px 그리드 (dark-instrument 요구)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 1rem; /* 16 */
|
||||
--space-4: 1.5rem; /* 24 */
|
||||
--space-5: 2.5rem; /* 40 */
|
||||
--space-6: 4rem; /* 64 */
|
||||
--space-7: 6rem; /* 96 */
|
||||
--space-8: 10rem; /* 160 — 섹션 간 */
|
||||
--container: 70rem; /* 1120 — R1 변형 */
|
||||
--gutter: clamp(1rem, 4vw, 5rem);
|
||||
}
|
||||
```
|
||||
섹션 간 160 : 요소 간 24 = **1:6.7**. 여백 종류 8종은 스케일 전체이고, 히어로에서 실제로 쓰는 값은 4종(`--space-2/3/5/7`)이다.
|
||||
|
||||
## 4. 형태
|
||||
|
||||
```css
|
||||
:root { --radius-sm: 2px; --radius-md: 4px; }
|
||||
```
|
||||
**어휘 2종.** `rounded-2xl`·16px 균일·24px 이상 전부 회피. 그림자는 쓰지 않고 `--surface-raised` 톤 차이로 깊이를 만든다(1px 보더 + 그림자 동시 사용 금지).
|
||||
|
||||
## 5. 모션 (tokens.md 값 그대로)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--dur-instant: 100ms; --dur-quick: 200ms;
|
||||
--dur-normal: 350ms; --dur-slow: 600ms;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
|
||||
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
```
|
||||
`motion.md`가 없어 이 값만 쓴다(→ FINDINGS D-5).
|
||||
|
||||
## 6. 성능 예산 — 기본값 사용 (브리프 지시)
|
||||
|
||||
| 항목 | 예산 |
|
||||
|---|---|
|
||||
| 히어로까지 JS (gzip) | **150KB** |
|
||||
| WebGL/3D 추가분 | +200KB 이내, 아니면 채택 안 함 |
|
||||
| 첫 인터랙션 (모바일 4G) | 3초 |
|
||||
| 폰트 파일 | 2개 이하, 서브셋 |
|
||||
| 애니메이션 속성 | transform / opacity 만 |
|
||||
|
||||
폰트 3종(Plex Sans / Plex Mono / Instrument Serif)은 예산의 "2개 이하"를 넘는다.
|
||||
→ **해소**: 디스플레이 세리프는 h1 한 곳에만 쓰므로 `unicode-range` 서브셋(라틴 기본 + 숫자)으로 8KB 이하로 만들고, Plex Mono는 `font-synthesis` 없이 400 단일 웨이트만 로드한다. 실질 2.3개.
|
||||
|
||||
---
|
||||
|
||||
## 산출물 한 줄
|
||||
**성능 예산 = JS 150KB / 첫 인터랙션 3초** (기본값, 상향 없음)
|
||||
159
research/dogfood-saas/05-preflight.md
Normal file
159
research/dogfood-saas/05-preflight.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# 5단계 — 프리플라이트 감사 (히어로 실측)
|
||||
|
||||
측정 대상: `hero/preview.html` + `hero/src/styles/*.css` (Hero.jsx와 동일 CSS, 마크업 복제)
|
||||
도구: Playwright(Chromium), `tools/contrast.mjs`
|
||||
|
||||
---
|
||||
|
||||
## 0. 기계 검사 3종
|
||||
|
||||
### A. 이펙트 전부 끄기 — **통과**
|
||||
`filter/backdrop-filter/animation/transition/transform` 전부 무력화한 상태에서:
|
||||
- h1 렌더 높이 > 0 ✓ / CTA 렌더 높이 > 0 ✓
|
||||
- 본문 텍스트 그대로 읽힘 ✓
|
||||
- 구조 유지 (그리드는 이펙트가 아님)
|
||||
|
||||
### B. 슬롭 지문 grep — **통과 (1건 검출 후 해소)**
|
||||
16개 패턴 중 **1건 검출**: `Space Grotesk|Instrument Serif|Geist` ← `Instrument Serif` 사용.
|
||||
→ `Newsreader`(OFL, 다른 파운드리)로 교체. **재검 0건.**
|
||||
*주의: antipatterns §2 산문은 "이 셋의 3종 조합"만 지문이라 하고 "셋 중 하나만 쓰라"고 허용한다. §9 grep은 OR이라 단독 사용도 잡는다 → FINDINGS D-3*
|
||||
|
||||
### C. 카피 경쟁사 치환 — **통과**
|
||||
|
||||
| 문장 | Datadog으로 치환하면 | 판정 |
|
||||
|---|---|---|
|
||||
| "Add every host you want. You pay for checks." | 거짓 (Datadog은 호스트 과금) | **통과** |
|
||||
| "…pages you when latency drifts. No agents to install." | 거짓 (Datadog은 에이전트 필요) | **통과** |
|
||||
| CTA "Start the free trial" | 성립함 | **경계** — 금지어(`Get Started`/`Learn More`)는 아니지만 결과를 말하지 않는다. 후속에서 "Start monitoring free"로 검토 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 접근성
|
||||
|
||||
| 항목 | 실측 | 판정 |
|
||||
|---|---|---|
|
||||
| 키보드 도달 | 6개 인터랙티브 전부 순회. Tab 순서 = 시각 순서 (Pricing → Docs → Dark → CTA → billing → Copy) | 통과 |
|
||||
| 포커스 표시 | `outline: 2px solid var(--accent)` + offset 3px, `:focus-visible` | 통과 |
|
||||
| 대비 | `tools/contrast.mjs` — 다크·라이트 **20항목 전부 4.5:1 이상**. 최저 다크 `--fail` 4.99:1 / 라이트 `--warn` 4.78:1 | 통과 |
|
||||
| 이미지 대체텍스트 | `<img>` 1개, 의미 있는 alt 있음, `width/height` 지정 | 통과 |
|
||||
| 폼 레이블 | 폼 없음 | N/A |
|
||||
| 제목 구조 | h1 1개, 건너뜀 없음 | 통과 |
|
||||
| 모션 감소 | `prefers-reduced-motion` 블록 있음. 애초에 위치 이동·자동재생 없음 | 통과 |
|
||||
| 자동재생 | 없음 | 통과 |
|
||||
| 캔버스/WebGL 실패 | WebGL 미채택. JS 꺼도 텍스트·레이아웃 유지(테마 토글·복사만 비활성) | 통과 |
|
||||
|
||||
**색만으로 정보 전달 안 함**: 링크는 밑줄 병행. 상태색은 이번 히어로에 미사용.
|
||||
|
||||
---
|
||||
|
||||
## 2. 성능 — 예산은 `tokens.md` §5
|
||||
|
||||
| 항목 | 예산 | 실측 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 히어로까지 JS (gzip) | 150KB | **미측정** — Vite 빌드 미실행(환경에 미설치). preview는 JS 0바이트 로드, React 버전은 react+react-dom ≈ 45KB gz **추정** | **미검증** |
|
||||
| WebGL/3D 추가분 | +200KB | **0KB** (4-3에서 미채택) | 통과 |
|
||||
| 총 전송량 (첫 화면) | 1MB | **12.4KB** (문서 2.9K + CSS 9.5K + 이미지 placeholder) | 통과 |
|
||||
| 첫 인터랙션 (모바일 4G) | 3초 | **미측정** (스로틀링 환경 없음) | **미검증** |
|
||||
| LCP | 2.5초 | **미측정** | **미검증** |
|
||||
| CLS | 0.1 | `aspect-ratio` + `width/height`로 이미지 자리 확보. 지연 삽입 요소 없음 → **구조적으로 0** | 통과(추론) |
|
||||
| 폰트 파일 수 | 2개 이하 | **3종 선언**(Newsreader / Plex Sans / Plex Mono), 실제 로드 0(시스템 폴백). 서브셋 계획은 03-tokens §6 | **초과, 사유 명시** |
|
||||
|
||||
**정직하게**: JS·LCP·TTI 3개는 **측정하지 못했다.** `preflight.md`가 "측정하지 않았으면 통과가 아니다"라고 했으므로 이 셋은 **미통과 상태로 남긴다.** 빌드 파이프라인이 생기면 재측정해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 반응형 (실측)
|
||||
|
||||
| 항목 | 실측 | 판정 |
|
||||
|---|---|---|
|
||||
| 320px 가로 스크롤 | 1차 **실패** (`.theme-toggle`가 nav flex에서 밀림, scrollW 318 > clientW 305) → `flex-wrap: wrap` 적용 후 **scrollW 305 = clientW 305** | 수정 후 통과 |
|
||||
| 1920px 늘어짐 | `--container: 70rem` 상한 | 통과 |
|
||||
| 중간 뷰포트 768~1024 | `max-width: 54rem`에서 1열 전환 | 통과 |
|
||||
| 터치 타깃 44×44 | 1차 **실패 5건** (Pricing 40×21, Docs 29×21, Dark 61×30, billing 링크 207×25, Copy 62×30) → `min-height/min-width: 44px` 적용 후 **0건** | 수정 후 통과 |
|
||||
| 호버 전용 기능 | 없음 | 통과 |
|
||||
| 긴 단어 넘침 | 영문 짧은 단어. `{{REGION_COUNT}}`도 넘치지 않음 | 통과 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 콘텐츠
|
||||
|
||||
- [x] 히어로가 무엇인지 말한다 — "watches your endpoints", "pages you"
|
||||
- [x] 로렘입숨 없음
|
||||
- [ ] **실제 콘텐츠 길이 미검증** — `{{REGION_COUNT}}`가 placeholder다. 실제 값이 두 자리면 서브 문장이 한 줄 늘어난다
|
||||
- [ ] **빈/오류/로딩 상태 없음** — 히어로만 만들었으므로 범위 밖. 후속 필수
|
||||
- [x] 링크 텍스트가 목적지를 말함 ("See how billing is calculated")
|
||||
- [x] 이미지 실패 시 레이아웃 유지 (`aspect-ratio` + `--surface-raised` 배경)
|
||||
|
||||
---
|
||||
|
||||
## 5. 하드 게이트 12개 — 전부 "아니오"여야 한다
|
||||
|
||||
| # | 질문 | 답 | 근거 |
|
||||
|---|---|---|---|
|
||||
| 1 | 토큰 밖 인라인 색상/폰트가 있는가 | **아니오** | 모든 색·폰트가 `tokens.css` 변수. grep으로 hex 0건 |
|
||||
| 2 | 강조색이 2개 이상인가 | **아니오** | 코발트 1개. 상태색 3개는 tokens.md 정의상 기능색 |
|
||||
| 3 | 코너 반경이 규칙 없이 섞였는가 | **아니오** | `--radius-sm: 2px` / `--radius-md: 4px` 2종만 |
|
||||
| 4 | 페이지 중간에 테마가 뒤집히는가 | **아니오** | 단일 테마, 토글로만 전환 |
|
||||
| 5 | 320~1920 어느 폭에서 가로 스크롤이 생기는가 | **아니오** (수정 후) | 320px 실측 scrollW = clientW |
|
||||
| 6 | 버튼·내비 라벨이 2줄로 접히는 폭이 있는가 | **아니오** | CTA `white-space: nowrap`, nav는 wrap으로 줄바꿈 대신 행 이동 |
|
||||
| 7 | 버튼 대비가 4.5:1 미만인가 | **아니오** | 다크 15.58:1 / 라이트 16.91:1 |
|
||||
| 8 | transform/opacity 외를 애니메이션하는가 | **아니오** | 브라우저에서 non-zero duration 전수 검사 → 0건 |
|
||||
| 9 | `100vh`를 썼는가 | **아니오** | `min(84dvh, 46rem)`. 스타일시트 전문에 `100vh` 0건, `dvh` 검출 |
|
||||
| 10 | `scroll` 리스너를 썼는가 | **아니오** | 스크롤 관련 JS 없음 |
|
||||
| 11 | 사용자가 주지 않은 수치가 있는가 | **아니오** | 브리프가 수치를 주지 않아 `{{REGION_COUNT}}` placeholder로 둠 |
|
||||
| 12 | div로 만든 가짜 스크린샷이 있는가 | **아니오** (수정 후) | 초안의 `.gauge` div 목업을 발견해 `<img>`로 교체 |
|
||||
|
||||
## 카운트 규칙
|
||||
|
||||
| 규칙 | 실측 | 판정 |
|
||||
|---|---|---|
|
||||
| eyebrow 라벨 ≤ ceil(섹션/3) | **0개** | 통과 |
|
||||
| 동일 이미지+텍스트 스플릿 연속 ≤ 2 | 1회 | 통과 |
|
||||
| 마퀴 ≤ 1 | 0 | 통과 |
|
||||
| 섹션 6개 이상이면 레이아웃 패밀리 3개 | 히어로만 구현 | N/A |
|
||||
| 히어로 헤드라인 ≤ 2줄 | 1440px에서 2줄, 320px에서 4줄 | **경계** — 모바일에서 4줄. `text-wrap: balance` 적용했으나 44px에서 문장이 길다 |
|
||||
| 서브텍스트 ≤ 20단어 | **17단어** | 통과 |
|
||||
| 히어로 텍스트 요소 ≤ 4개 | h1 / sub / CTA+보조링크 / install = **4** (보조링크를 CTA와 한 묶음으로 셈) | **경계** — 보조링크를 별개로 세면 5 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 디자인 자체 (정직하게)
|
||||
|
||||
1. **다른 회사 것으로 바꿔도 쓸 수 있는가?** → **아니오.** 헤드라인이 과금 모델을 주장하고 있어 호스트 과금 경쟁사가 못 쓴다
|
||||
2. **한 문장 컨셉이 화면에 보이는가?** → **부분적으로.** "계기판"은 모노스페이스 명령 블록과 `tabular-nums`로만 드러난다. 제품 스크린샷이 placeholder라 계기판 인상의 절반이 비어 있다
|
||||
3. **감수한 리스크가 실제로 들어갔는가?** → **예.** CTA가 강조색 없이 잉크 반전이다. 회색조 위계 검사에서 CTA가 2위를 유지해 철회 조건에 걸리지 않았다
|
||||
4. **R2(다른 업종 톤)의 흔적이 있는가?** → **예.** 디스플레이 세리프, 강조색을 링크 글자에만, radius 2~4px
|
||||
5. **강조색이 하나인가?** → **예**
|
||||
6. **여백이 의도적인가?** → **예.** 섹션 160 : 요소 24 = 1:6.7, 히어로에서 실제 사용 4종
|
||||
|
||||
---
|
||||
|
||||
## 7. antipatterns 자가 채점표
|
||||
|
||||
| 카테고리 | 걸린 항목 | 조치 |
|
||||
|---|---|---|
|
||||
| 0 최우선 3종 | 0 | — |
|
||||
| 1 컬러 | 0 | — |
|
||||
| 2 타이포 | 1 (`Instrument Serif` grep) | Newsreader로 교체 후 0 |
|
||||
| 3 레이아웃 | 0 | — |
|
||||
| 4 컴포넌트 | 0 | — |
|
||||
| 5 모션 | 0 (모션 없음) | — |
|
||||
| 6 카피 | 1 경계 (CTA가 결과를 말하지 않음) | 후속 검토로 남김 |
|
||||
| 7 이미지 | 0 | — |
|
||||
| 8 한글 조판 | N/A (영문 브리프) | — |
|
||||
|
||||
**최종 걸린 개수: 1 (경계)** → 판정 **통과** (0–1)
|
||||
|
||||
---
|
||||
|
||||
## 8. 최종 게이트
|
||||
|
||||
- [x] 0장 기계 검사 3종 통과
|
||||
- [x] 접근성 표 전 항목 통과
|
||||
- [ ] **성능 예산 — JS/LCP/TTI 3개 미측정. 빌드 환경 확보 후 재검 필요**
|
||||
- [x] 반응형 체크리스트 통과 (실패 2건 수정 후)
|
||||
- [ ] **콘텐츠 — 빈/오류/로딩 상태 없음 (히어로 범위 밖)**
|
||||
- [x] 5장 여섯 질문 정직하게 답함
|
||||
- [x] antipatterns 자가 채점표 통과
|
||||
|
||||
**판정: 조건부 통과.** 미측정 3항목과 상태 화면 부재를 남긴 채로는 출시 불가.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue