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:
Yun Chan 2026-08-20 10:48:00 +09:00
commit 8808c672dc
135 changed files with 38838 additions and 0 deletions

116
packages/cli/README.md Normal file
View 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
View 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"
}
}

View 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`);

View 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})`);

View 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;
}

View 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")}`));
}

View 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;
}

View 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;
}

View 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
View 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
View 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;
}

View 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
View 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");
}

View 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;
}
}

View 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));
});

View 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"]
}

View 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" },
});

View 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
View 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 {
/* 남아있으면 그대로 둔다 */
}
}

View 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";

View 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 };
}

View 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;
}

View 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();
}

View 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;
}

View 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"] ?? "웹 디자인 파이프라인 스킬",
};
}

View 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")),
};
},
};

View 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")),
};
},
};

View 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")),
};
},
};

View 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);
}

View 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),
};
},
};

View 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 };

View 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 를 줄여야 한다.`,
}
: {}),
};
},
};

View 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;
}

View 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, /상한/);
});

View 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);
});

View 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");
});

View 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
View 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단계 — 한글 조판 / 슬롭 검출 |
## 작업 중 지켜야 할 것
- **단계를 보고하며 진행해라.** 사용자는 어느 단계인지 알아야 개입할 수 있다
- **가정은 소리 내서 말해라.** 브리프에 없어서 정한 것은 명시한다
- **되돌릴 수 있게 만들어라.** 토큰을 바꾸면 전체가 따라 바뀌는 구조로 짠다. 값을 하드코딩하면 수정 요청 한 번에 무너진다
- **모르면 열어봐라.** 레퍼런스 사이트도, 참조 문서도, 실제로 읽고 나서 결정해라

View 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 ."
}
}

View 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.51.7, 크기 1618px 권장. **예외**: 고밀도 라틴 UI(대시보드·개발자 도구)에서 1314px는 의도된 선택일 수 있다 — 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 조각으로. 쓸 거면 좌측 인라인 |
| **카드 좌측 34px 컬러 스트립** (`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년 기준 표준이라 차별화가 아니다. "고민 안 했음"의 새 신호 | 타일 크기 차이가 정보 위계를 반영할 때만. 아니면 단순 리스트 |
| **콘텐츠가 뷰포트 가장자리에 붙음** | 모바일에서 특히 조악 | 모바일 1624px, 데스크톱 2480px 좌우 패딩 |
| **본문 컬럼이 화면 전체 폭** | 한 줄이 100자를 넘어 읽기가 무너짐 | 영문 `max-width: 65ch`. 국문은 2540자 폭 |
| **푸터가 링크 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 4060ms |
| **인터페이스에 bounce / elastic 이징** | 2010년대 감성. 즉시 촌스러움 | `ease-out` 또는 `cubic-bezier(0.2, 0, 0, 1)` |
| **이미지 hover 시 `scale` 또는 `rotate`** | 생성 UI의 반복 지문 | 이미지는 두고 캡션·오버레이·보더를 변화시켜라 |
| **duration이 전부 300ms** | 마이크로와 연출을 구분 못 함 | 마이크로 100200 / 트랜지션 200400 / 연출 6001200ms |
| **스크롤 reveal 실패 시 콘텐츠가 `opacity: 0`으로 남음** | JS 실패 시 빈 페이지가 됨 | 기본을 visible로 두고 JS가 숨긴 뒤 보여주는 방식. 또는 CSS scroll-driven animation |
| **`prefers-reduced-motion` 미대응** | 접근성 실패 | 모션 제거 경로를 반드시 제공 |
| **전 섹션 패럴랙스** | 스크롤 감각이 어긋나고 성능 저하 | 한 섹션만, 이동량 1020px |
| **히어로에 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.61.8** | 한글은 글자 밀도가 높아 1.5로는 답답하다 |
| **음수 자간 금지** | 본문 0, 대형 헤드라인만 -0.01 ~ -0.02em | 한글은 고정폭에 가까워 음수 자간이 즉시 뭉개진다 |
| **`word-break: keep-all`** | `overflow-wrap: break-word`와 함께 | 기본값은 단어 중간에서 줄바꿈되어 어색하다 |
| **한 줄 2540자** | 영문 4575자 기준을 쓰면 너무 길다 | 컨테이너 폭을 좁혀라 |
| **라틴 폰트를 폴백 앞에** | `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`이 있다
**판정**
| 걸린 개수 | 판정 |
|---|---|
| 01 | 통과 |
| 23 | 해당 항목 수정 후 재검 |
| **4 이상** | **폐기. reference-method.md §3부터 다시** |
> 근거: research/references/04-ai-slop-signatures.md, 03-trends-2026.md (조사일 2026-08-20)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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. 상품 수가 적고 사양이 설득의 주체 |
**어느 것도 맞지 않으면 새로 정의해라.** 브리프가 프리셋보다 우선한다.
새로 정의할 때도 이 문서들의 항목 구조(타이포/색/간격/재질/모션/실패)는 그대로 채워야 한다.

View 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 브리프에 적용** → 프리셋 선택 자체가 실패
## 리스크 후보
- 히어로 텍스트를 화면 밖으로 절반 흘려보내기
- 스크롤에 따라 그리드 자체가 재배치되기
- 배경을 원색 하나로 덮고 이미지를 흑백으로

View 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 전체를 무채색으로
- 히어로를 실제 제품 화면(터미널·차트)으로 대체
- 모노스페이스를 본문까지 확장

View 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단계에서 하나 고른다)
- 히어로에 이미지 없이 **타이포그래피만**
- 본문 폭을 극단적으로 좁히고 여백에 주석을 흘리기
- 페이지 번호·러닝헤드 같은 **인쇄물 장치**를 웹에 가져오기

View 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)

View 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와 링크와 배지에 다 씀** → 강조가 셋이면 강조가 없다
## 리스크 후보
- 히어로에서 이미지를 완전히 빼고 **텍스트와 여백만**
- 색을 무채색 + 딱 한 색으로 극단적으로 제한
- 섹션 구분선을 전부 없애고 여백만으로 구획

View 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 — 레이아웃 그리드
컬럼 수·거터 / 콘텐츠 최대폭과 뷰포트 비 / 대칭·비대칭(비율) / 그리드를 깨는 요소 / 본문 한 줄 글자 수(영문 4575, 국문 2540)
→ *"무엇을 정렬시키고 무엇을 일부러 어긋나게 했나?"*
### 축 2 — 타입 스케일
실제 사용된 크기 목록 / 그 사이 비율(1.25 또는 1.333이 표준) / 단계 수(35가 건강, 8+ 는 통제 실패) / 패밀리 수(2개 표준) / 굵기 대비 / line-height / letter-spacing
*"최대 글자와 본문의 비는 몇 배인가?"* (4배 미만이면 위계 약함)
### 축 3 — 컬러 역할
색상값이 아니라 **역할**로 적는다. surface 단계 수 / text 3단계 유무 / accent 면적 %(10% 이하가 정상) / border 존재 여부 / state 색 분리 여부
*"강조색을 지우면 여전히 읽히는가?"* (읽혀야 정상)
### 축 4 — 여백 리듬
기본 단위(4 또는 8px) / 섹션 간 수직 여백(80160px) / 요소 간 : 섹션 간 비율(1:4~1:6) / 여백 값 종류 수(46종이 건강) / 좌우 패딩
→ *"가장 큰 빈 공간은 어디이고 왜 거기인가?"*
### 축 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)

View 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)

View 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)

View 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)