From 8808c672dcdd75817376d20b7360cba8d12f2cbc Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Thu, 20 Aug 2026 10:48:00 +0900 Subject: [PATCH] =?UTF-8?q?designpaca=20=EC=B4=88=EA=B8=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=E2=80=94=20=EC=8A=A4=ED=82=AC=20=C2=B7=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98=20CLI=20=C2=B7=20=EB=B0=B0=ED=8F=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹 디자인 파이프라인 스킬과 이를 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) --- .changeset/config.json | 11 + .forgejo/workflows/ci.yml | 59 + .forgejo/workflows/release.yml | 97 + .gitignore | 12 + .npmrc | 3 + README.md | 74 + apps/site/astro.config.mjs | 9 + apps/site/package.json | 15 + apps/site/src/pages/index.astro | 19 + apps/site/tsconfig.json | 5 + build/ci/lint-skill.mjs | 80 + build/ci/publish-npm.sh | 67 + build/ci/upload-release-asset.sh | 56 + build/ci/verify-node.sh | 43 + docs/DEPLOYMENT_PLAN.md | 130 + package.json | 31 + packages/cli/README.md | 116 + packages/cli/package.json | 32 + packages/cli/scripts/bundle-skill.mjs | 30 + packages/cli/scripts/verify-package.mjs | 29 + packages/cli/src/commands/doctor.ts | 55 + packages/cli/src/commands/install.ts | 90 + packages/cli/src/commands/list.ts | 30 + packages/cli/src/commands/uninstall.ts | 30 + packages/cli/src/commands/update.ts | 55 + packages/cli/src/index.ts | 177 + packages/cli/src/skill.ts | 28 + packages/cli/src/tui/onboard.ts | 125 + packages/cli/src/ui.ts | 55 + packages/cli/src/update-check.ts | 56 + packages/cli/test/cli.test.ts | 70 + packages/cli/tsconfig.json | 9 + packages/cli/tsup.config.ts | 15 + packages/core/package.json | 19 + packages/core/src/fsx.ts | 65 + packages/core/src/index.ts | 8 + packages/core/src/installer.ts | 185 + packages/core/src/manifest.ts | 105 + packages/core/src/marker.ts | 51 + packages/core/src/paths.ts | 25 + packages/core/src/skill-source.ts | 62 + packages/core/src/targets/agents-md.ts | 71 + packages/core/src/targets/claude-code.ts | 34 + packages/core/src/targets/codex.ts | 34 + packages/core/src/targets/common.ts | 60 + packages/core/src/targets/cursor.ts | 57 + packages/core/src/targets/index.ts | 17 + packages/core/src/targets/windsurf.ts | 66 + packages/core/src/types.ts | 96 + packages/core/test/installer.test.ts | 163 + packages/core/test/marker.test.ts | 50 + packages/core/test/skill-source.test.ts | 23 + packages/core/tsconfig.json | 5 + packages/skill/SKILL.md | 261 + packages/skill/package.json | 11 + packages/skill/references/antipatterns.md | 271 + .../skill/references/experimental-canvas.md | 350 ++ packages/skill/references/galleries.md | 173 + packages/skill/references/layout.md | 113 + packages/skill/references/motion.md | 504 ++ packages/skill/references/preflight.md | 137 + packages/skill/references/presets/README.md | 17 + .../skill/references/presets/anti-grid.md | 58 + .../references/presets/dark-instrument.md | 62 + .../skill/references/presets/editorial.md | 56 + .../references/presets/quiet-commerce.md | 69 + .../skill/references/presets/swiss-minimal.md | 56 + packages/skill/references/reference-method.md | 254 + packages/skill/references/svg-filters.md | 369 ++ packages/skill/references/three.md | 598 +++ packages/skill/references/tokens.md | 245 + pnpm-lock.yaml | 4522 +++++++++++++++++ pnpm-workspace.yaml | 7 + research/canvas/01-api-spec.md | 397 ++ research/canvas/02-availability.md | 144 + research/canvas/03-code-examples.md | 700 +++ research/canvas/04-fallbacks.md | 165 + research/canvas/05-sources.md | 107 + research/canvas/_raw/awesome.md | 36 + research/canvas/_raw/ex-complex-text.html | 50 + research/canvas/_raw/ex-pie-chart.html | 87 + research/canvas/_raw/ex-text-input.html | 68 + research/canvas/_raw/ex-webGL.html | 193 + research/canvas/_raw/ex-webGLSetup.js | 288 ++ research/canvas/_raw/explainer.md | 379 ++ research/canvas/_raw/htex-ex.html | 182 + research/canvas/_raw/htmltex.js | 74 + research/canvas/_raw/jelly.ts | 938 ++++ research/canvas/_raw/secpriv.md | 85 + research/dogfood-coffee/01-references.md | 63 + research/dogfood-coffee/02-direction.md | 77 + research/dogfood-coffee/03-tokens.css | 100 + research/dogfood-coffee/05-structure-plan.md | 33 + research/dogfood-coffee/design.md | 82 + research/dogfood-coffee/index.html | 125 + research/dogfood-coffee/style.css | 270 + research/dogfood-saas/01-references.md | 82 + research/dogfood-saas/02-direction.md | 46 + research/dogfood-saas/03-tokens.md | 160 + research/dogfood-saas/05-preflight.md | 159 + research/dogfood-saas/design.md | 91 + research/dogfood-saas/hero/preview.html | 74 + .../dogfood-saas/hero/src/components/Hero.jsx | 109 + .../dogfood-saas/hero/src/styles/hero.css | 216 + .../dogfood-saas/hero/src/styles/tokens.css | 100 + research/dogfood-saas/tools/contrast.mjs | 52 + research/motion/01-principles.md | 552 ++ research/motion/02-css-native.md | 1530 ++++++ research/motion/03-js-stacks.md | 1797 +++++++ research/references/01-gallery-catalog.md | 359 ++ research/references/02-methodology.md | 410 ++ research/references/03-trends-2026.md | 325 ++ research/references/04-ai-slop-signatures.md | 267 + research/references/05-sources.md | 181 + research/site-brief.md | 827 +++ research/skills/01-local-skill-teardown.md | 750 +++ research/skills/02-prompt-techniques.md | 1626 ++++++ research/skills/03-format-specs.md | 632 +++ research/skills/04-sources.md | 190 + research/skills/05-recommendations.md | 722 +++ research/skills/06-skill-audit.md | 1306 +++++ research/svg/01-primitives-reference.md | 865 ++++ research/svg/02-recipes.md | 1670 ++++++ research/svg/03-performance-and-pitfalls.md | 493 ++ research/svg/04-sources.md | 239 + research/three/01-stack-and-setup.md | 1047 ++++ research/three/02-patterns.md | 3385 ++++++++++++ research/three/03-shaders.md | 1296 +++++ research/three/04-performance.md | 1248 +++++ research/three/05-sources.md | 221 + research/youtube/SUMMARY.md | 86 + research/youtube/nomad-html.description | 17 + research/youtube/transcript-ko-compact.txt | 1 + research/youtube/transcript-ko.txt | 1 + tsconfig.base.json | 21 + 135 files changed, 38838 insertions(+) create mode 100644 .changeset/config.json create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .forgejo/workflows/release.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 README.md create mode 100644 apps/site/astro.config.mjs create mode 100644 apps/site/package.json create mode 100644 apps/site/src/pages/index.astro create mode 100644 apps/site/tsconfig.json create mode 100644 build/ci/lint-skill.mjs create mode 100644 build/ci/publish-npm.sh create mode 100644 build/ci/upload-release-asset.sh create mode 100644 build/ci/verify-node.sh create mode 100644 docs/DEPLOYMENT_PLAN.md create mode 100644 package.json create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/scripts/bundle-skill.mjs create mode 100644 packages/cli/scripts/verify-package.mjs create mode 100644 packages/cli/src/commands/doctor.ts create mode 100644 packages/cli/src/commands/install.ts create mode 100644 packages/cli/src/commands/list.ts create mode 100644 packages/cli/src/commands/uninstall.ts create mode 100644 packages/cli/src/commands/update.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/skill.ts create mode 100644 packages/cli/src/tui/onboard.ts create mode 100644 packages/cli/src/ui.ts create mode 100644 packages/cli/src/update-check.ts create mode 100644 packages/cli/test/cli.test.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/tsup.config.ts create mode 100644 packages/core/package.json create mode 100644 packages/core/src/fsx.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/installer.ts create mode 100644 packages/core/src/manifest.ts create mode 100644 packages/core/src/marker.ts create mode 100644 packages/core/src/paths.ts create mode 100644 packages/core/src/skill-source.ts create mode 100644 packages/core/src/targets/agents-md.ts create mode 100644 packages/core/src/targets/claude-code.ts create mode 100644 packages/core/src/targets/codex.ts create mode 100644 packages/core/src/targets/common.ts create mode 100644 packages/core/src/targets/cursor.ts create mode 100644 packages/core/src/targets/index.ts create mode 100644 packages/core/src/targets/windsurf.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/test/installer.test.ts create mode 100644 packages/core/test/marker.test.ts create mode 100644 packages/core/test/skill-source.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/skill/SKILL.md create mode 100644 packages/skill/package.json create mode 100644 packages/skill/references/antipatterns.md create mode 100644 packages/skill/references/experimental-canvas.md create mode 100644 packages/skill/references/galleries.md create mode 100644 packages/skill/references/layout.md create mode 100644 packages/skill/references/motion.md create mode 100644 packages/skill/references/preflight.md create mode 100644 packages/skill/references/presets/README.md create mode 100644 packages/skill/references/presets/anti-grid.md create mode 100644 packages/skill/references/presets/dark-instrument.md create mode 100644 packages/skill/references/presets/editorial.md create mode 100644 packages/skill/references/presets/quiet-commerce.md create mode 100644 packages/skill/references/presets/swiss-minimal.md create mode 100644 packages/skill/references/reference-method.md create mode 100644 packages/skill/references/svg-filters.md create mode 100644 packages/skill/references/three.md create mode 100644 packages/skill/references/tokens.md create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 research/canvas/01-api-spec.md create mode 100644 research/canvas/02-availability.md create mode 100644 research/canvas/03-code-examples.md create mode 100644 research/canvas/04-fallbacks.md create mode 100644 research/canvas/05-sources.md create mode 100644 research/canvas/_raw/awesome.md create mode 100644 research/canvas/_raw/ex-complex-text.html create mode 100644 research/canvas/_raw/ex-pie-chart.html create mode 100644 research/canvas/_raw/ex-text-input.html create mode 100644 research/canvas/_raw/ex-webGL.html create mode 100644 research/canvas/_raw/ex-webGLSetup.js create mode 100644 research/canvas/_raw/explainer.md create mode 100644 research/canvas/_raw/htex-ex.html create mode 100644 research/canvas/_raw/htmltex.js create mode 100644 research/canvas/_raw/jelly.ts create mode 100644 research/canvas/_raw/secpriv.md create mode 100644 research/dogfood-coffee/01-references.md create mode 100644 research/dogfood-coffee/02-direction.md create mode 100644 research/dogfood-coffee/03-tokens.css create mode 100644 research/dogfood-coffee/05-structure-plan.md create mode 100644 research/dogfood-coffee/design.md create mode 100644 research/dogfood-coffee/index.html create mode 100644 research/dogfood-coffee/style.css create mode 100644 research/dogfood-saas/01-references.md create mode 100644 research/dogfood-saas/02-direction.md create mode 100644 research/dogfood-saas/03-tokens.md create mode 100644 research/dogfood-saas/05-preflight.md create mode 100644 research/dogfood-saas/design.md create mode 100644 research/dogfood-saas/hero/preview.html create mode 100644 research/dogfood-saas/hero/src/components/Hero.jsx create mode 100644 research/dogfood-saas/hero/src/styles/hero.css create mode 100644 research/dogfood-saas/hero/src/styles/tokens.css create mode 100644 research/dogfood-saas/tools/contrast.mjs create mode 100644 research/motion/01-principles.md create mode 100644 research/motion/02-css-native.md create mode 100644 research/motion/03-js-stacks.md create mode 100644 research/references/01-gallery-catalog.md create mode 100644 research/references/02-methodology.md create mode 100644 research/references/03-trends-2026.md create mode 100644 research/references/04-ai-slop-signatures.md create mode 100644 research/references/05-sources.md create mode 100644 research/site-brief.md create mode 100644 research/skills/01-local-skill-teardown.md create mode 100644 research/skills/02-prompt-techniques.md create mode 100644 research/skills/03-format-specs.md create mode 100644 research/skills/04-sources.md create mode 100644 research/skills/05-recommendations.md create mode 100644 research/skills/06-skill-audit.md create mode 100644 research/svg/01-primitives-reference.md create mode 100644 research/svg/02-recipes.md create mode 100644 research/svg/03-performance-and-pitfalls.md create mode 100644 research/svg/04-sources.md create mode 100644 research/three/01-stack-and-setup.md create mode 100644 research/three/02-patterns.md create mode 100644 research/three/03-shaders.md create mode 100644 research/three/04-performance.md create mode 100644 research/three/05-sources.md create mode 100644 research/youtube/SUMMARY.md create mode 100644 research/youtube/nomad-html.description create mode 100644 research/youtube/transcript-ko-compact.txt create mode 100644 research/youtube/transcript-ko.txt create mode 100644 tsconfig.base.json diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..e1a0732 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [["designpaca", "@designpaca/core", "@designpaca/skill"]], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["@designpaca/site"] +} diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..02e6a36 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,59 @@ +# designpaca CI — 자체 러너(linux-builder, host 모드)에서 돈다. +# host 러너에는 Node 가 없을 수 있으므로 uses: 액션을 쓰지 않고 전부 run: 으로 처리한다. +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: linux-builder + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}" + url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git" + [ -d .git ] || git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "$url" + git fetch -q --depth 1 origin "$GITHUB_REF" + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: 환경 확인 + run: ./build/ci/verify-node.sh + + - name: 의존성 설치 + run: pnpm install --frozen-lockfile + + - name: 스킬 문서 검사 + run: node build/ci/lint-skill.mjs packages/skill + + - name: 타입 검사 + run: pnpm typecheck + + - name: 테스트 + run: pnpm test + + - name: 빌드 + run: pnpm build + + - name: 배포물 점검 (npx 경로 검증) + run: | + node packages/cli/scripts/verify-package.mjs + # 실제 사용자가 겪는 경로 그대로 확인한다 — tarball 을 만들어 설치까지 해본다 + cd packages/cli + tarball="$(npm pack --silent)" + tmp="$(mktemp -d)" + tar -xzf "$tarball" -C "$tmp" + test -f "$tmp/package/dist/skill/SKILL.md" || { echo "tarball 에 스킬이 없다" >&2; exit 1; } + node "$tmp/package/dist/index.js" --version + node "$tmp/package/dist/index.js" list + rm -rf "$tmp" "$tarball" + + - name: 사이트 빌드 + run: pnpm build:site diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..4f8e3a6 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,97 @@ +# designpaca 릴리스 — 태그 v* 푸시로 발동한다. +# 1) 게이트: 검사·테스트·빌드 +# 2) npmjs 배포 + Forgejo 사설 레지스트리 미러 +# 3) Forgejo draft 릴리스에 tarball 첨부 (QA 후 수동 Publish 승격) +# 4) 소개 페이지 Cloudflare Pages 배포 +# +# 필요한 시크릿: NPM_TOKEN · FORGEJO_NPM_TOKEN · RELEASE_TOKEN · CF_API_TOKEN · CF_ACCOUNT_ID +name: release + +on: + push: + tags: ["v*"] + workflow_dispatch: # 수동 리허설 (ci-rehearsal prerelease 로 나간다) + +jobs: + release: + runs-on: linux-builder + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + proto="${GITHUB_SERVER_URL%%://*}"; host="${GITHUB_SERVER_URL#*://}" + url="$proto://actions:${CI_TOKEN}@${host%/}/${GITHUB_REPOSITORY}.git" + [ -d .git ] || git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "$url" + git fetch -q --depth 1 origin "$GITHUB_REF" + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: 환경 확인 + run: ./build/ci/verify-node.sh + + - name: 릴리스 정보 확정 + run: | + case "$GITHUB_REF" in + refs/tags/v*) + tag="${GITHUB_REF#refs/tags/}" + echo "RELTAG=$tag" >> "$GITHUB_ENV" + echo "RELEASE_PRERELEASE=false" >> "$GITHUB_ENV" + echo "DO_PUBLISH=1" >> "$GITHUB_ENV" + # 태그와 package.json 버전이 어긋난 채 배포되는 사고를 막는다 + pkgver="$(node -p 'require("./packages/cli/package.json").version')" + [ "v$pkgver" = "$tag" ] || { echo "태그($tag)와 package.json(v$pkgver)이 다르다" >&2; exit 1; } + ;; + *) + echo "RELTAG=ci-rehearsal" >> "$GITHUB_ENV" + echo "RELEASE_PRERELEASE=true" >> "$GITHUB_ENV" + echo "DO_PUBLISH=0" >> "$GITHUB_ENV" + ;; + esac + + - name: 의존성 설치 + run: pnpm install --frozen-lockfile + + - name: 검사 · 테스트 · 빌드 + run: | + node build/ci/lint-skill.mjs packages/skill + pnpm typecheck + pnpm test + pnpm build + node packages/cli/scripts/verify-package.mjs + + - name: tarball 생성 + run: | + mkdir -p out + cd packages/cli + tarball="$(npm pack --silent)" + mv "$tarball" "../../out/$tarball" + echo "TARBALL=out/$tarball" >> "$GITHUB_ENV" + + - name: npm 배포 (npmjs + Forgejo 미러) + if: env.DO_PUBLISH == '1' + env: + NPM_TOKEN: "${{ secrets.NPM_TOKEN }}" + FORGEJO_NPM_TOKEN: "${{ secrets.FORGEJO_NPM_TOKEN }}" + FORGEJO_NPM_OWNER: "${{ vars.FORGEJO_NPM_OWNER }}" + run: ./build/ci/publish-npm.sh packages/cli + + - name: 릴리스 업로드 (draft) + env: { RELEASE_TOKEN: "${{ secrets.RELEASE_TOKEN }}" } + run: ./build/ci/upload-release-asset.sh "$RELTAG" "$TARBALL" + + - name: 소개 페이지 배포 (Cloudflare Pages) + env: + CLOUDFLARE_API_TOKEN: "${{ secrets.CF_API_TOKEN }}" + CLOUDFLARE_ACCOUNT_ID: "${{ secrets.CF_ACCOUNT_ID }}" + run: | + if [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then + echo "CF_API_TOKEN 이 없다 — 페이지 배포를 건너뛴다" + exit 0 + fi + pnpm build:site + npx --yes wrangler@latest pages deploy apps/site/dist \ + --project-name designpaca \ + --branch main \ + --commit-dirty=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41fb99f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +.turbo/ +*.tgz +.DS_Store +.env +.env.local +apps/site/.astro/ +apps/site/dist/ +# 리서치 원본 자막(용량) — 요약 문서는 커밋한다 +research/youtube/*.srt +research/youtube/*.vtt diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..8c269a5 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +auto-install-peers=true +strict-peer-dependencies=false +# 릴리스 프로비넌스는 CI에서만 켠다(로컬 publish 금지) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e40eca3 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# designpaca — 모노레포 + +웹 디자인 파이프라인 스킬과 그 설치 CLI. +사용자용 문서는 [`packages/cli/README.md`](packages/cli/README.md) 를 봐라 — npm 페이지에 그대로 실린다. + +```bash +npx designpaca +``` + +## 구조 + +``` +packages/ + skill/ @designpaca/skill — SKILL.md 와 references/. 이 저장소의 실질 가치 + core/ @designpaca/core — 설치 엔진: 타깃 어댑터, 매니페스트, 드리프트 감지 + cli/ designpaca — npx 진입점 + 온보딩 TUI. 유일하게 npm 에 배포되는 패키지 +apps/ + site/ @designpaca/site — 소개 페이지 (Astro → Cloudflare Pages) +build/ci/ — CI 스크립트 (Node 검증, npm 배포, 릴리스 업로드, 스킬 검사) +research/ — 스킬의 근거 자료. 약 250개 웹 소스 조사 결과 +docs/DEPLOYMENT_PLAN.md — 배포·릴리스 운영 절차 +``` + +`core` 와 `skill` 은 private 이다. CLI 빌드 시 `core` 는 번들에, `skill` 은 `dist/skill/` 로 복사된다. +사용자는 `npx designpaca` 한 번으로 전부 받는다. + +## 개발 + +```bash +pnpm install +pnpm build # 스킬 번들 + CLI 빌드 +pnpm test # core 14 + cli 6 +pnpm typecheck +pnpm dev:site # 소개 페이지 로컬 +``` + +### CLI 를 실제 경로로 테스트 + +`npx` 가 겪는 경로를 그대로 재현하려면 tarball 로 확인한다. + +```bash +pnpm build +cd packages/cli && npm pack +npx ./designpaca-0.1.0.tgz --help +``` + +설치 왕복을 안전하게 시험하려면 홈 디렉터리를 바꿔라. + +```bash +HOME=/tmp/dp USERPROFILE=/tmp/dp node packages/cli/dist/index.js install -t claude-code -s user -y +``` + +### 스킬 문서를 고쳤다면 + +```bash +node build/ci/lint-skill.mjs packages/skill +``` + +프론트매터 필수 필드, description 길이, 본문이 가리키는 참조 문서의 존재, 참조되지 않는 죽은 문서를 검사한다. CI 가 같은 것을 돌린다. + +## 릴리스 + +```bash +pnpm changeset # 변경 기록 +pnpm version # 버전 확정 (fixed 그룹이라 세 패키지가 함께 움직인다) +git tag v0.1.0 && git push origin main --tags +``` + +태그 푸시가 Forgejo Actions 를 발동시킨다: 검사 → 테스트 → 빌드 → npmjs 배포 + 사설 레지스트리 미러 → draft 릴리스 → Cloudflare Pages. +시크릿 등록과 러너 전제는 [`docs/DEPLOYMENT_PLAN.md`](docs/DEPLOYMENT_PLAN.md) 에 있다. + +## 라이선스 + +MIT diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs new file mode 100644 index 0000000..db59b90 --- /dev/null +++ b/apps/site/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from "astro/config"; + +export default defineConfig({ + site: "https://designpaca.chanpaca.net", + // 정적 출력 — Cloudflare Pages 로 그대로 올린다 + output: "static", + build: { format: "file" }, + devToolbar: { enabled: false }, +}); diff --git a/apps/site/package.json b/apps/site/package.json new file mode 100644 index 0000000..d40373e --- /dev/null +++ b/apps/site/package.json @@ -0,0 +1,15 @@ +{ + "name": "@designpaca/site", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "typecheck": "echo \"(사이트는 build 로 검증한다)\"" + }, + "dependencies": { + "astro": "^5.1.1" + } +} diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro new file mode 100644 index 0000000..119515f --- /dev/null +++ b/apps/site/src/pages/index.astro @@ -0,0 +1,19 @@ +--- +const version = "0.1.0"; +--- + + + + + + designpaca + + + +
+

designpaca

+

웹 디자인 파이프라인 스킬 — v{version}

+ npx designpaca +
+ + diff --git a/apps/site/tsconfig.json b/apps/site/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/apps/site/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/build/ci/lint-skill.mjs b/build/ci/lint-skill.mjs new file mode 100644 index 0000000..36ebb12 --- /dev/null +++ b/build/ci/lint-skill.mjs @@ -0,0 +1,80 @@ +// 스킬 문서의 최소 규약을 검사한다. CI 와 `pnpm test` 가 함께 쓴다. +// - SKILL.md 존재와 프론트매터 필수 필드 +// - description 길이(트리거 정확도에 직접 영향) +// - references 링크가 실제 파일을 가리키는지 +import fs from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(process.argv[2] ?? "."); +const errors = []; +const warnings = []; + +const skillPath = path.join(root, "SKILL.md"); +let md; +try { + md = await fs.readFile(skillPath, "utf8"); +} catch { + console.error(`SKILL.md 를 찾을 수 없다: ${skillPath}`); + process.exit(1); +} + +const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md); +if (!fmMatch) { + errors.push("프론트매터(---)가 없다"); +} else { + const fm = Object.fromEntries( + fmMatch[1] + .split(/\r?\n/) + .map((l) => /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(l)) + .filter(Boolean) + .map((m) => [m[1], m[2].replace(/^["']|["']$/g, "")]), + ); + if (fm.name !== "designpaca") errors.push(`name 이 designpaca 가 아니다: ${fm.name}`); + if (!fm.description) errors.push("description 이 없다"); + else { + const len = fm.description.length; + if (len < 80) warnings.push(`description 이 짧다(${len}자) — 트리거 정확도가 떨어진다`); + if (len > 700) warnings.push(`description 이 길다(${len}자) — 요약해라`); + } +} + +// 본문이 참조하는 파일이 실제로 있는지 확인한다 (references/xxx.md 형태) +const refs = [...md.matchAll(/references\/[A-Za-z0-9._/-]+\.md/g)].map((m) => m[0]); +for (const rel of new Set(refs)) { + try { + await fs.access(path.join(root, rel)); + } catch { + errors.push(`본문이 가리키는 참조 문서가 없다: ${rel}`); + } +} + +// 참조 문서가 본문 어디에서도 언급되지 않으면 죽은 문서다 +async function walk(dir, base = dir) { + const out = []; + let entries = []; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) out.push(...(await walk(full, base))); + else out.push(path.relative(base, full).split(path.sep).join("/")); + } + return out; +} +const refDir = path.join(root, "references"); +for (const f of await walk(refDir, root)) { + if (!f.endsWith(".md")) continue; + const rel = f.split(path.sep).join("/"); + if (!md.includes(rel)) warnings.push(`본문에서 참조되지 않는 문서: ${rel}`); +} + +for (const w of warnings) console.warn(` 경고: ${w}`); +if (errors.length > 0) { + for (const e of errors) console.error(` 오류: ${e}`); + console.error(`\n스킬 검사 실패 — 오류 ${errors.length}건`); + process.exit(1); +} +console.log(`스킬 검사 통과 (경고 ${warnings.length}건)`); diff --git a/build/ci/publish-npm.sh b/build/ci/publish-npm.sh new file mode 100644 index 0000000..c1ce01a --- /dev/null +++ b/build/ci/publish-npm.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# designpaca 패키지를 npmjs 에 배포하고 Forgejo 사설 레지스트리에 미러링한다. +# +# 필요 환경: +# NPM_TOKEN npmjs 자동화 토큰 (필수) +# FORGEJO_NPM_TOKEN Forgejo 개인 액세스 토큰 (선택 — 없으면 미러를 건너뛴다) +# FORGEJO_NPM_OWNER Forgejo 패키지 소유자 (기본: yunchan) +# GITHUB_SERVER_URL Forgejo 주소 (Actions 가 준다) +# +# 이미 배포된 버전이면 조용히 건너뛴다 — 워크플로 재실행이 실패로 끝나지 않게. +set -euo pipefail + +PKG_DIR="${1:-packages/cli}" +cd "$PKG_DIR" + +name="$(node -p 'require("./package.json").name')" +version="$(node -p 'require("./package.json").version')" +echo "배포 대상: ${name}@${version}" + +published() { # + npm view "${name}@${version}" version --registry "$1" >/dev/null 2>&1 +} + +# ── 1) npmjs ── +if [ -z "${NPM_TOKEN:-}" ]; then + echo "NPM_TOKEN 이 없다 — npmjs 배포를 건너뛴다" >&2 +else + npmrc="$(mktemp)" + echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$npmrc" + export NPM_CONFIG_USERCONFIG="$npmrc" + + if published "https://registry.npmjs.org/"; then + echo "npmjs: ${version} 은 이미 배포돼 있다 — 건너뛴다" + else + npm publish --access public --registry "https://registry.npmjs.org/" --provenance=false + echo "npmjs 배포 완료: ${name}@${version}" + fi + rm -f "$npmrc" + unset NPM_CONFIG_USERCONFIG +fi + +# ── 2) Forgejo 미러 ── +if [ -z "${FORGEJO_NPM_TOKEN:-}" ]; then + echo "FORGEJO_NPM_TOKEN 이 없다 — 사설 레지스트리 미러를 건너뛴다" + exit 0 +fi + +owner="${FORGEJO_NPM_OWNER:-yunchan}" +server="${GITHUB_SERVER_URL:-https://git.chanpaca.net}" +host="${server#*://}"; host="${host%/}" +registry="${server%/}/api/packages/${owner}/npm/" + +npmrc="$(mktemp)" +{ + echo "registry=${registry}" + # 스킴을 뺀 형태로 적어야 Forgejo 가 인증을 인식한다 + echo "//${host}/api/packages/${owner}/npm/:_authToken=${FORGEJO_NPM_TOKEN}" +} > "$npmrc" +export NPM_CONFIG_USERCONFIG="$npmrc" + +if published "$registry"; then + echo "Forgejo: ${version} 은 이미 미러돼 있다 — 건너뛴다" +else + npm publish --registry "$registry" + echo "Forgejo 미러 완료: ${registry}" +fi +rm -f "$npmrc" diff --git a/build/ci/upload-release-asset.sh b/build/ci/upload-release-asset.sh new file mode 100644 index 0000000..75077ee --- /dev/null +++ b/build/ci/upload-release-asset.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등). +# 사용: build/ci/upload-release-asset.sh <릴리스태그> <파일...> +# 필요 환경: RELEASE_TOKEN + (Actions 기본 제공) GITHUB_SERVER_URL, GITHUB_REPOSITORY +# RELEASE_REPO 를 주면 해당 저장소로 업로드(공개 배포 저장소용), 없으면 현재 저장소 +# RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용) +set -euo pipefail +[ $# -ge 2 ] || { echo "사용법: $0 <태그> <파일...>" >&2; exit 2; } +[ -n "${RELEASE_TOKEN:-}" ] || { echo "RELEASE_TOKEN 환경변수가 없습니다" >&2; exit 2; } +tag="$1"; shift +api="${GITHUB_SERVER_URL%/}/api/v1/repos/${RELEASE_REPO:-$GITHUB_REPOSITORY}" +auth="Authorization: token ${RELEASE_TOKEN}" + +# draft 릴리스는 tags/ 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다 +# tr -d '\r': Windows Git Bash 에서 python 출력이 CRLF 로 나와 id 가 오염되는 것 방지 +find_release() { + curl -sf -H "$auth" "$api/releases?limit=50" | python3 -c ' +import json, sys +tag = sys.argv[1] +for r in json.load(sys.stdin): + if r.get("tag_name") == tag: + print(r["id"]); break' "$tag" | tr -d '\r' +} + +rid="$(find_release || true)" +if [ -z "$rid" ]; then + body=$(printf '{"tag_name":"%s","name":"%s","draft":true,"prerelease":%s}' \ + "$tag" "$tag" "${RELEASE_PRERELEASE:-false}") + rid=$(curl -s -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" \ + | python3 -c 'import json,sys +d = json.load(sys.stdin) +print(d["id"] if isinstance(d, dict) and "id" in d else "")' | tr -d '\r' || true) + [ -n "$rid" ] || rid="$(find_release || true)" # 병렬 잡 동시 생성 경합 대비 +fi +[ -n "$rid" ] || { echo "릴리스 확보 실패: $tag" >&2; exit 1; } +echo "릴리스 #$rid ($tag)" + +for f in "$@"; do + name=$(basename "$f") + # 재실행 멱등성: 동명 자산 제거 후 업로드 + curl -sf -H "$auth" "$api/releases/$rid/assets" | python3 -c ' +import json, sys +for a in json.load(sys.stdin): + if a.get("name") == sys.argv[1]: + print(a["id"])' "$name" | tr -d '\r' | while read -r aid; do + [ -n "$aid" ] && curl -sf -X DELETE -H "$auth" "$api/releases/$rid/assets/$aid" || true + done + resp=$(mktemp) + code=$(curl -s -o "$resp" -w '%{http_code}' -X POST -H "$auth" \ + -F "attachment=@$f" "$api/releases/$rid/assets?name=$name") + if [ "$code" != 201 ]; then + echo "업로드 실패($code): $name" >&2; cat "$resp" >&2; rm -f "$resp"; exit 1 + fi + rm -f "$resp" + echo "업로드 완료: $name ($(du -h "$f" | cut -f1))" +done diff --git a/build/ci/verify-node.sh b/build/ci/verify-node.sh new file mode 100644 index 0000000..1ffdaaf --- /dev/null +++ b/build/ci/verify-node.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# host 모드 러너에는 Node 가 없을 수 있다. 여기서 명확히 실패시켜 CI 로그를 읽을 수 있게 한다. +# 필요하면 러너에 nvm/fnm 으로 설치된 Node 를 PATH 에 올려준다. +set -euo pipefail + +MIN_MAJOR=20 + +add_path() { echo "$1" >> "${GITHUB_PATH:-/dev/null}"; export PATH="$1:$PATH"; } + +if ! command -v node >/dev/null 2>&1; then + # 러너에 흔히 있는 위치를 훑는다 + for cand in "$HOME/.nvm/versions/node"/*/bin "$HOME/.local/share/fnm/node-versions"/*/installation/bin "$HOME/.volta/bin"; do + [ -d "$cand" ] && add_path "$cand" && break + done +fi + +if ! command -v node >/dev/null 2>&1; then + cat >&2 <<'MSG' +Node 를 찾을 수 없다. + +이 워크플로는 self-hosted host 러너에서 돈다. 러너 머신에 Node 20 이상을 설치하고 +러너 사용자의 PATH 에 올려라. 예: fnm install 22 && fnm default 22 +MSG + exit 1 +fi + +major="$(node -p 'process.versions.node.split(".")[0]')" +if [ "$major" -lt "$MIN_MAJOR" ]; then + echo "Node $MIN_MAJOR 이상이 필요하다 (현재 $(node -v))" >&2 + exit 1 +fi + +# pnpm 은 corepack 으로 확보한다 — 러너에 전역 설치를 요구하지 않는다 +if ! command -v pnpm >/dev/null 2>&1; then + corepack enable >/dev/null 2>&1 || true + corepack prepare pnpm@10.5.2 --activate >/dev/null 2>&1 || true +fi +if ! command -v pnpm >/dev/null 2>&1; then + echo "pnpm 을 확보하지 못했다. corepack 이 막혀 있으면 npm i -g pnpm@10.5.2 로 설치해라" >&2 + exit 1 +fi + +echo "node $(node -v) / pnpm $(pnpm -v) / npm $(npm -v)" diff --git a/docs/DEPLOYMENT_PLAN.md b/docs/DEPLOYMENT_PLAN.md new file mode 100644 index 0000000..a55fbe6 --- /dev/null +++ b/docs/DEPLOYMENT_PLAN.md @@ -0,0 +1,130 @@ +# designpaca 배포 운영 계획 + +이 문서는 designpaca를 git.chanpaca.net(Forgejo)에서 빌드·배포·릴리스하기 위한 전제와 절차를 적는다. +한 번 읽고 그대로 따라 하면 첫 릴리스까지 갈 수 있어야 한다. + +## 0. 전제 — 러너 환경 + +CI는 self-hosted **`linux-builder`** 러너(host 모드)에서 돈다. host 모드라 러너 머신에 있는 것만 쓸 수 있다. + +| 필요 | 확인 | 없을 때 | +|---|---|---| +| Node 20 이상 | `node -v` | `fnm install 22 && fnm default 22` 후 러너 재시작 | +| pnpm 10 | `pnpm -v` | `corepack enable && corepack prepare pnpm@10.5.2 --activate` | +| git, curl, tar, python3 | `command -v` | 배포판 패키지로 설치 | + +`build/ci/verify-node.sh`가 매 잡 첫 스텝에서 이걸 검사하고, 없으면 **명확한 메시지로 즉시 실패**시킨다. +nvm/fnm/volta의 흔한 설치 경로는 자동으로 PATH에 올려본다. + +> host 러너에는 Node가 없을 수 있으므로 워크플로에서 `uses:` 액션을 쓰지 않는다. +> 체크아웃도 `run:` 스텝의 git 명령으로 처리한다 (videodownloader 파이프라인과 동일한 패턴). + +## 1. 저장소 + +``` +git.chanpaca.net//designpaca +``` + +- 소스 공개 여부는 자유. 단 **npm 배포는 npmjs 공개 레지스트리**이므로 코드는 어차피 tarball로 공개된다 +- 기본 브랜치: `main` +- 태그 규칙: `v` (예: `v0.1.0`) — 태그 푸시가 릴리스 트리거 + +## 2. 시크릿 등록 + +저장소 → Settings → Actions → Secrets 에 등록한다. + +| 이름 | 용도 | 발급처 | +|---|---|---| +| `NPM_TOKEN` | npmjs 배포 | npmjs.com → Access Tokens → **Automation** 타입 | +| `FORGEJO_NPM_TOKEN` | 사설 레지스트리 미러 | Forgejo → 설정 → 애플리케이션 → 액세스 토큰 (`write:package` 권한) | +| `RELEASE_TOKEN` | 릴리스 생성·자산 업로드 | Forgejo 액세스 토큰 (`write:repository`) | +| `CF_API_TOKEN` | Cloudflare Pages 배포 | Cloudflare → API Tokens → **Edit Cloudflare Workers** 템플릿 | +| `CF_ACCOUNT_ID` | Cloudflare 계정 식별 | Cloudflare 대시보드 우측 하단 Account ID | + +변수(Variables)에 선택적으로: + +| 이름 | 기본값 | 설명 | +|---|---|---| +| `FORGEJO_NPM_OWNER` | `yunchan` | 사설 npm 패키지 소유자 | + +> **시크릿이 없어도 파이프라인은 죽지 않는다.** `publish-npm.sh`는 `FORGEJO_NPM_TOKEN`이 없으면 미러를 건너뛰고, +> 릴리스 스텝은 `CF_API_TOKEN`이 없으면 페이지 배포를 건너뛴다. 하나씩 붙여가며 굴릴 수 있다. + +## 3. 릴리스 절차 + +### 3.1 변경 기록 +```bash +pnpm changeset # 무엇이 바뀌었는지, patch/minor/major 중 무엇인지 기록 +git add .changeset && git commit -m "changeset: <요약>" +``` + +### 3.2 버전 확정 +```bash +pnpm version # changeset version + lockfile 갱신 +# packages/*/package.json 과 CHANGELOG.md 가 갱신된다 +git add -A && git commit -m "release: v<새 버전>" +``` +`designpaca` · `@designpaca/core` · `@designpaca/skill`은 changesets의 **fixed 그룹**이라 항상 같은 버전으로 움직인다. +스킬 내용과 CLI 버전이 어긋나면 업그레이드 판정(`.designpaca_version` 비교)이 깨지기 때문이다. + +### 3.3 태그 푸시 = 릴리스 발동 +```bash +git tag v0.1.0 && git push origin main --tags +``` + +파이프라인이 순서대로 수행한다: +1. `verify-node.sh` → 의존성 설치 +2. **태그와 `packages/cli/package.json` 버전 일치 확인** (다르면 즉시 실패) +3. 스킬 문서 검사 → 타입 검사 → 테스트 → 빌드 → 배포물 점검 +4. `npm publish` (npmjs) → Forgejo 레지스트리 미러. **이미 배포된 버전이면 조용히 건너뛴다**(재실행 안전) +5. Forgejo **draft 릴리스** 생성 + tarball 첨부 +6. Cloudflare Pages 배포 + +### 3.4 승격 +draft 릴리스를 확인하고 수동으로 **Publish**한다. draft는 익명에게 보이지 않으므로 QA 시간을 벌 수 있다. + +### 3.5 리허설 +태그 없이 돌려볼 수 있다. Actions → release → **Run workflow**. +`ci-rehearsal` prerelease 릴리스로 전 과정을 검증하되 **npm 배포는 건너뛴다**(`DO_PUBLISH=0`). + +## 4. 사용자 설치 경로 + +```bash +npx designpaca # 온보딩 TUI +npx designpaca install -t claude-code,codex -s user -y +``` + +사설 레지스트리에서 직접 받으려면: +```bash +npx --registry=https://git.chanpaca.net/api/packages//npm/ designpaca +``` + +## 5. 소개 페이지 + +- 배포 대상: Cloudflare Pages 프로젝트 **`designpaca`** +- 도메인: `designpaca.chanpaca.net` (Cloudflare DNS에 CNAME → Pages) +- 최초 1회만 수동: Cloudflare 대시보드에서 Pages 프로젝트 생성(빈 프로젝트) → 커스텀 도메인 연결. + 이후에는 릴리스 워크플로의 `wrangler pages deploy`가 알아서 올린다 + +## 6. 장애 대응 + +| 증상 | 원인 | 조치 | +|---|---|---| +| `Node 를 찾을 수 없다` | host 러너에 Node 미설치 | 러너 머신에 fnm으로 Node 22 설치 후 러너 재시작 | +| `태그와 package.json 이 다르다` | `pnpm version` 없이 태그를 밀었다 | 태그 삭제 → 3.2부터 다시 | +| npm publish 403 | 토큰 만료 / 이름 선점 | `NPM_TOKEN` 재발급, `npm view designpaca` 로 소유 확인 | +| Forgejo 미러 401 | 토큰에 `write:package` 없음 | 토큰 권한 재발급 | +| 릴리스 자산 업로드 실패 | `RELEASE_TOKEN` 권한 부족 | `write:repository` 부여 | +| Pages 배포 스킵 | `CF_API_TOKEN` 미등록 | 시크릿 등록 (없어도 나머지는 성공한다) | + +## 7. 롤백 + +npm은 배포 취소가 사실상 불가능하다(72시간 이내 unpublish만 가능, 같은 버전 재사용 금지). +따라서 **되돌리기가 아니라 앞으로 감는다**: + +```bash +# 문제 버전을 latest 에서 내린다 +npm dist-tag add designpaca@<직전 정상 버전> latest +# 수정 후 새 패치 릴리스 +``` +사용자 쪽 복구는 `npx designpaca@<정상버전> update --force`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..8830d45 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "designpaca-monorepo", + "version": "0.0.0", + "private": true, + "description": "designpaca — 웹 디자인 파이프라인 스킬과 설치 CLI", + "packageManager": "pnpm@10.5.2", + "engines": { + "node": ">=20.11" + }, + "scripts": { + "build": "pnpm -r --filter \"./packages/**\" build", + "build:site": "pnpm --filter @designpaca/site build", + "dev": "pnpm --filter designpaca dev", + "dev:site": "pnpm --filter @designpaca/site dev", + "typecheck": "pnpm -r --filter \"./packages/**\" typecheck", + "test": "pnpm -r test", + "lint": "pnpm -r lint", + "changeset": "changeset", + "version": "changeset version && pnpm install --lockfile-only", + "release": "pnpm build && changeset publish" + }, + "devDependencies": { + "@changesets/cli": "^2.27.10", + "typescript": "^5.7.2" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..19ea25d --- /dev/null +++ b/packages/cli/README.md @@ -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` 처럼 사용자 문서에 주입하는 경우, 마커 블록(``) **안쪽만** 교체한다. 문서의 나머지는 손대지 않는다. 제거할 때도 블록만 빼간다. + +## 옵션 + +| 옵션 | 설명 | +|---|---| +| `-t, --target ` | `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 diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..fd9903d --- /dev/null +++ b/packages/cli/package.json @@ -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" + } +} diff --git a/packages/cli/scripts/bundle-skill.mjs b/packages/cli/scripts/bundle-skill.mjs new file mode 100644 index 0000000..8458abc --- /dev/null +++ b/packages/cli/scripts/bundle-skill.mjs @@ -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`); diff --git a/packages/cli/scripts/verify-package.mjs b/packages/cli/scripts/verify-package.mjs new file mode 100644 index 0000000..5f868ac --- /dev/null +++ b/packages/cli/scripts/verify-package.mjs @@ -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})`); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..66f1e0c --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -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 { + 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; +} diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 0000000..5d717e5 --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -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 { + 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")}`)); +} diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts new file mode 100644 index 0000000..458f8c0 --- /dev/null +++ b/packages/cli/src/commands/list.ts @@ -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 { + 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; +} diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts new file mode 100644 index 0000000..26aba00 --- /dev/null +++ b/packages/cli/src/commands/uninstall.ts @@ -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 { + 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; +} diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 0000000..aeb3014 --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -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 { + 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; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..bd0d5a6 --- /dev/null +++ b/packages/cli/src/index.ts @@ -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 설치 대상: ${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 { + 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; + }); diff --git a/packages/cli/src/skill.ts b/packages/cli/src/skill.ts new file mode 100644 index 0000000..4e0c035 --- /dev/null +++ b/packages/cli/src/skill.ts @@ -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 { + 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 { + return (await getSkill()).version; +} diff --git a/packages/cli/src/tui/onboard.ts b/packages/cli/src/tui/onboard.ts new file mode 100644 index 0000000..4a621be --- /dev/null +++ b/packages/cli/src/tui/onboard.ts @@ -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 { + 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({ + 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({ + 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; +} diff --git a/packages/cli/src/ui.ts b/packages/cli/src/ui.ts new file mode 100644 index 0000000..935de37 --- /dev/null +++ b/packages/cli/src/ui.ts @@ -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"); +} diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts new file mode 100644 index 0000000..0396a94 --- /dev/null +++ b/packages/cli/src/update-check.ts @@ -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 { + 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; + } +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..b0c7f59 --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -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)); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..ad9cd97 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -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"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..6daa14d --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -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" }, +}); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..28b40d3 --- /dev/null +++ b/packages/core/package.json @@ -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" + } +} diff --git a/packages/core/src/fsx.ts b/packages/core/src/fsx.ts new file mode 100644 index 0000000..532e431 --- /dev/null +++ b/packages/core/src/fsx.ts @@ -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 { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +export async function readIfExists(p: string): Promise { + try { + return await fs.readFile(p, "utf8"); + } catch { + return null; + } +} + +/** 임시 파일에 쓴 뒤 rename — 중간에 죽어도 반쯤 쓰인 파일이 남지 않는다 */ +export async function writeAtomic(p: string, content: string): Promise { + 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 { + 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 { + 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 { + /* 남아있으면 그대로 둔다 */ + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..1fe9b33 --- /dev/null +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/installer.ts b/packages/core/src/installer.ts new file mode 100644 index 0000000..18e4ed0 --- /dev/null +++ b/packages/core/src/installer.ts @@ -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 { + 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 { + 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 { + 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(); + 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 { + 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 }; +} diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts new file mode 100644 index 0000000..b162dac --- /dev/null +++ b/packages/core/src/manifest.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/packages/core/src/marker.ts b/packages/core/src/marker.ts new file mode 100644 index 0000000..ac36f42 --- /dev/null +++ b/packages/core/src/marker.ts @@ -0,0 +1,51 @@ +/** + * AGENTS.md 같은 사용자 소유 문서에 우리 블록만 안전하게 심고 빼기 위한 마커 처리. + * 블록 밖의 내용은 절대 건드리지 않는다. + */ + +export function startTag(marker: string): string { + return ``; +} +export function endTag(marker: string): string { + return ``; +} + +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(); +} diff --git a/packages/core/src/paths.ts b/packages/core/src/paths.ts new file mode 100644 index 0000000..3476e58 --- /dev/null +++ b/packages/core/src/paths.ts @@ -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; +} diff --git a/packages/core/src/skill-source.ts b/packages/core/src/skill-source.ts new file mode 100644 index 0000000..407df47 --- /dev/null +++ b/packages/core/src/skill-source.ts @@ -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; body: string } { + const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(md); + if (!m) return { fm: {}, body: md }; + const fm: Record = {}; + 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 { + 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 { + const skillMd = await fs.readFile(path.join(root, "SKILL.md"), "utf8"); + const { fm, body } = splitFrontmatter(skillMd); + + const files = new Map(); + 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"] ?? "웹 디자인 파이프라인 스킬", + }; +} diff --git a/packages/core/src/targets/agents-md.ts b/packages/core/src/targets/agents-md.ts new file mode 100644 index 0000000..aa2c02f --- /dev/null +++ b/packages/core/src/targets/agents-md.ts @@ -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` 가 있으면 그것이 최상위다. 스킬 기본값을 덮는다.", + "- 참조 문서는 각 단계에서 지시하는 것만 그때 연다. 처음부터 전부 읽지 마라.", + "", + ``, + ].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")), + }; + }, +}; diff --git a/packages/core/src/targets/claude-code.ts b/packages/core/src/targets/claude-code.ts new file mode 100644 index 0000000..0b57867 --- /dev/null +++ b/packages/core/src/targets/claude-code.ts @@ -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")), + }; + }, +}; diff --git a/packages/core/src/targets/codex.ts b/packages/core/src/targets/codex.ts new file mode 100644 index 0000000..074c0b2 --- /dev/null +++ b/packages/core/src/targets/codex.ts @@ -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")), + }; + }, +}; diff --git a/packages/core/src/targets/common.ts b/packages/core/src/targets/common.ts new file mode 100644 index 0000000..2142248 --- /dev/null +++ b/packages/core/src/targets/common.ts @@ -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 { + 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); +} diff --git a/packages/core/src/targets/cursor.ts b/packages/core/src/targets/cursor.ts new file mode 100644 index 0000000..f320638 --- /dev/null +++ b/packages/core/src/targets/cursor.ts @@ -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), + }; + }, +}; diff --git a/packages/core/src/targets/index.ts b/packages/core/src/targets/index.ts new file mode 100644 index 0000000..a70adfd --- /dev/null +++ b/packages/core/src/targets/index.ts @@ -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 }; diff --git a/packages/core/src/targets/windsurf.ts b/packages/core/src/targets/windsurf.ts new file mode 100644 index 0000000..a4ca7e6 --- /dev/null +++ b/packages/core/src/targets/windsurf.ts @@ -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 를 줄여야 한다.`, + } + : {}), + }; + }, +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..f11c1c1 --- /dev/null +++ b/packages/core/src/types.ts @@ -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; + /** 설치 계획 수립 — 파일을 쓰지 않는다 */ + plan(ctx: TargetContext): Promise; +} + +export interface TargetContext { + scope: Scope; + /** 홈 디렉터리 */ + home: string; + /** 현재 작업 디렉터리(프로젝트 범위일 때 기준) */ + cwd: string; + /** 번들된 스킬 소스 */ + skill: SkillSource; +} + +/** 번들에 포함된 스킬 원본 */ +export interface SkillSource { + version: string; + /** SKILL.md 본문 (프론트매터 포함) */ + skillMd: string; + /** references/ 이하 상대경로 → 내용 */ + files: Map; + /** 프론트매터를 제외한 본문 — 프론트매터를 쓰지 않는 포맷용 */ + body: string; + /** 프론트매터에서 뽑은 description */ + description: string; +} diff --git a/packages/core/test/installer.test.ts b/packages/core/test/installer.test.ts new file mode 100644 index 0000000..e070f6e --- /dev/null +++ b/packages/core/test/installer.test.ts @@ -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, /상한/); +}); diff --git a/packages/core/test/marker.test.ts b/packages/core/test/marker.test.ts new file mode 100644 index 0000000..00edb19 --- /dev/null +++ b/packages/core/test/marker.test.ts @@ -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); +}); diff --git a/packages/core/test/skill-source.test.ts b/packages/core/test/skill-source.test.ts new file mode 100644 index 0000000..0c435c6 --- /dev/null +++ b/packages/core/test/skill-source.test.ts @@ -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"); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..fc880ef --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "types": ["node"], "noEmit": true }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/skill/SKILL.md b/packages/skill/SKILL.md new file mode 100644 index 0000000..6b97f3e --- /dev/null +++ b/packages/skill/SKILL.md @@ -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. `
` 로 만든 가짜 스크린샷·가짜 브라우저바·가짜 폰 프레임이 있는가? + +#### 카운트 규칙 + +세어봐라. 넘으면 고친다. + +- 작은 대문자 라벨(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단계 — 한글 조판 / 슬롭 검출 | + +## 작업 중 지켜야 할 것 + +- **단계를 보고하며 진행해라.** 사용자는 어느 단계인지 알아야 개입할 수 있다 +- **가정은 소리 내서 말해라.** 브리프에 없어서 정한 것은 명시한다 +- **되돌릴 수 있게 만들어라.** 토큰을 바꾸면 전체가 따라 바뀌는 구조로 짠다. 값을 하드코딩하면 수정 요청 한 번에 무너진다 +- **모르면 열어봐라.** 레퍼런스 사이트도, 참조 문서도, 실제로 읽고 나서 결정해라 diff --git a/packages/skill/package.json b/packages/skill/package.json new file mode 100644 index 0000000..5b4fab1 --- /dev/null +++ b/packages/skill/package.json @@ -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 ." + } +} diff --git a/packages/skill/references/antipatterns.md b/packages/skill/references/antipatterns.md new file mode 100644 index 0000000..2c750c7 --- /dev/null +++ b/packages/skill/references/antipatterns.md @@ -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 better products`) | 가장 널리 퍼진 "성의 표시" 관용구. 이제는 성의가 아니라 지문 | 강조는 줄바꿈·크기·색·여백으로 | +| **페이지 전체에 폰트 패밀리 1개** | 위계를 크기로만 만들게 되어 밋밋 | 디스플레이 1 + 본문 1. 성격 차이를 크게 | +| **타입 스케일이 평평함** (16/18/20/24, 비율 1.15 미만) | 모든 게 똑같이 중요해 보임 | 비율 1.25 또는 1.333. **최대/본문 4배 이상** (17→68). 비율만으로 4배가 안 나오면(1.25 5단계 = 2.44배) **디스플레이 사이즈를 스케일 밖에 따로 정의한다**(`tokens.md` §1). **단계 수를 늘려서 맞추지 마라** — 그건 아래 행에 걸린다 | +| **타입 스케일 단계 8개 이상** | 통제 실패 | 3~5단계로 제한하고 각 단계에 역할 이름 부여 | +| **히어로 = pill 배지 + 거대 헤드라인** (`✨ Now in beta` + H1) | 가장 알아보기 쉬운 구조 지문 | 배지를 지워라. 정말 필요하면 문장 안이나 내비 옆으로 | +| **`_01_ _02_ _03_` 장식 번호 라벨** | 에디토리얼 흉내인데 구조는 없음 | 번호가 순서를 의미할 때만 | +| **본문 letter-spacing 0.05em 이상** | 단어 인식이 느려짐 | 본문 0. 소형 대문자 라벨에만 +0.08em | +| **대형 헤드라인 음수 자간 -0.06em 이상** | 글자가 서로 먹힘 | -0.02em ~ -0.04em | +| **본문 line-height 1.3 미만 / 본문 12px 이하** | 가독성·접근성 실패 | 라틴 본문 line-height 1.5–1.7, 크기 16–18px 권장. **예외**: 고밀도 라틴 UI(대시보드·개발자 도구)에서 13–14px는 의도된 선택일 수 있다 — bun.sh 본문 최다 크기가 13.5px다. 단 **한글에는 이 예외를 적용하지 마라.** 한글은 같은 px에서 라틴보다 작아 보여 16px가 하한이다 | +| **모노스페이스를 장식으로** (코드 아닌 라벨에 `font-mono`) | "테크 느낌"의 저비용 흉내 | 모노는 코드·데이터·식별자에만 | + +--- + +## 3. 레이아웃 · 구조 + +| 지문 | 왜 문제 | 대신 | +|---|---|---| +| **표준 골격**: 히어로 → 3열 피처 → 로고월 → 요금제 → FAQ → 푸터 | shadcn/ui 예제·Tailwind UI·Vercel 템플릿의 순서 그대로 | 섹션 순서를 브리프의 설득 논리로 재배열. 예: 문제 제시 → 실제 사용 화면 → 반론 처리 → 가격 | +| **중앙 정렬 히어로** (텍스트 가운데 + CTA 2개 + 아래 스크린샷) | 가장 안전해서 가장 흔함 | 비대칭 그리드(5:7, 7:5), 좌측 정렬 대형 타입, 또는 텍스트를 화면 하단 1/3로 | +| **정확히 3열, 균등 폭 피처 카드** | 콘텐츠가 3개여서가 아니라 3이 예쁘게 떨어져서 3인 것 | 항목 수를 콘텐츠가 정하게. 폭도 중요도에 따라 다르게 | +| **아이콘이 카드 상단 중앙**에 있는 피처 카드 (아이콘 타일 + 제목 + 2문장) | 가장 확실한 단일 지문 | 아이콘을 없애고 숫자·스크린샷·실제 UI 조각으로. 쓸 거면 좌측 인라인 | +| **카드 좌측 3–4px 컬러 스트립** (`border-l-4 border-purple-500`) | em-dash에 맞먹는 신뢰도의 지문 | 삭제. 구분이 필요하면 배경 톤 차이나 여백 | +| **카드 안의 카드 안의 카드** | 시각적 소음. 깊이가 정보와 무관 | 중첩 최대 1단계. 안쪽은 구분선이나 여백으로 | +| **근거 없는 지표 배너** (`10,000+ users · 99.9% uptime · 4.9★`) | 검증 불가한 숫자 나열은 신뢰를 깎는다 | 숫자 1개만, 출처와 함께. 없으면 섹션 삭제 | +| **모든 여백이 같은 값** (전부 `p-6`, 전부 `gap-4`) | 리듬이 없어 강약이 사라짐 | 8pt 그리드 위 4~6종. 섹션 간 : 요소 간 ≈ 1:5 | +| **섹션마다 동일한 상하 패딩** (`py-20` 반복) | 어디가 중요한지 알 수 없음 | 중요한 섹션에 더 많은 여백. 여백이 강조다 | +| **벤토 그리드를 기본 선택지로** | 2026년 기준 표준이라 차별화가 아니다. "고민 안 했음"의 새 신호 | 타일 크기 차이가 정보 위계를 반영할 때만. 아니면 단순 리스트 | +| **콘텐츠가 뷰포트 가장자리에 붙음** | 모바일에서 특히 조악 | 모바일 16–24px, 데스크톱 24–80px 좌우 패딩 | +| **본문 컬럼이 화면 전체 폭** | 한 줄이 100자를 넘어 읽기가 무너짐 | 영문 `max-width: 65ch`. 국문은 25–40자 폭 | +| **푸터가 링크 4열 + 소셜 아이콘 + 카피라이트뿐** | AI가 가장 성의 없이 만드는 곳 | 실제 정보(연락처, 주소, 사업자 정보)를 넣어라. 링크가 4개뿐이면 4열로 만들지 마라 | + +--- + +## 4. 컴포넌트 + +| 지문 | 왜 문제 | 대신 | +|---|---|---| +| **`rounded-2xl shadow-lg p-6` 를 손대지 않고 사용** | shadcn/ui 기본값. AI가 복붙하도록 설계된 값 | radius를 프로젝트 고유값으로(4px 또는 12px). 큰 그림자 대신 1px 보더 + 배경 톤 차이 | +| **모든 요소 border-radius 16px 균일 / 24px 이상 과대** | radius가 결정이 아니라 기본값임을 드러냄. 24px+ 는 블롭처럼 보임 | radius 어휘 2개 (컨테이너 12px, 인터랙티브 8px). 또는 0으로 통일 | +| **1px 회색 보더 + 넓게 퍼진 그림자 동시 사용** | 두 깊이 표현을 겹친 것. 광원 논리 없음 | 하나만 선택 | +| **글래스모피즘 전면 사용** (`backdrop-blur` 카드 다수) | 실기기 FPS 15~30% 하락 | 내비 바·모달로 제한. 카드에는 쓰지 마라 | +| **이모지를 아이콘 대신** (🚀 ⚡ 🎯 ✨) | 즉각적인 아마추어 신호 | 아이콘 세트 하나 고정 (Lucide, Phosphor, Radix). 이모지는 카피 안에서만 | +| **버튼이 항상 2개 나란히** (`Get started` + `Learn more`) | 주 행동을 정하지 못했다는 뜻 | 주 CTA 1개. 보조는 텍스트 링크로 격하 | +| **회색조 로고 6~8개 일렬 로고월** | 대부분 무관하거나 검증 불가 | 실제 고객이면 한 곳의 사례를 문장으로. 없으면 섹션 삭제 | +| **이니셜 원형 아바타 + 텍스트 후기 3개** | 검증 불가한 후기는 신뢰를 깎는다 | 실명 + 직함 + 원문 링크. 없으면 넣지 마라 | +| **가상 질문으로 채운 FAQ 6개** (`How does it work?`) | 실제로 받은 질문이 아님 | 실제 문의 3개. 답변에 구체적 숫자·조건 | +| **요금제 3열 카드 + 가운데 "Most popular" 배지** | 가장 재현율 높은 SaaS 템플릿 | 비교 표, 단일 가격, 또는 계산기 | +| **펄스 애니메이션 상태 점** (초록 원 깜빡임 + "All systems operational") | 정적 정보에 장식 애니메이션 | 상태가 실제로 바뀔 때만 | +| **자동 스크롤 마퀴** (로고·후기·태그가 끝없이 흐름) | 읽기를 방해하고 주의를 강탈 | 정적 그리드. 많으면 페이지네이션 | +| **hover가 아무 반응 없거나 전 요소가 동일하게 `scale(1.02)`** | 인터랙션을 설계하지 않았다는 신호 | 요소마다 다른 반응(링크는 밑줄, 카드는 배경 톤, 버튼은 명도). **hover는 가장 먼저 설계한다** | +| **편집 불가 히어로 카피 뒤 깜빡이는 커서 `|`** | 타이핑 흉내. 정보 없음 | 삭제 | + +--- + +## 5. 모션 + +| 지문 | 왜 문제 | 대신 | +|---|---|---| +| **모든 요소에 동일한 fade-in-up** (`opacity 0→1`, `translateY 20px`, 같은 delay) | 모션 언어가 없다는 뜻. 페이지가 계속 떠오르기만 함 | 중요한 것에만. 순차 등장이 필요하면 stagger 40–60ms | +| **인터페이스에 bounce / elastic 이징** | 2010년대 감성. 즉시 촌스러움 | `ease-out` 또는 `cubic-bezier(0.2, 0, 0, 1)` | +| **이미지 hover 시 `scale` 또는 `rotate`** | 생성 UI의 반복 지문 | 이미지는 두고 캡션·오버레이·보더를 변화시켜라 | +| **duration이 전부 300ms** | 마이크로와 연출을 구분 못 함 | 마이크로 100–200 / 트랜지션 200–400 / 연출 600–1200ms | +| **스크롤 reveal 실패 시 콘텐츠가 `opacity: 0`으로 남음** | JS 실패 시 빈 페이지가 됨 | 기본을 visible로 두고 JS가 숨긴 뒤 보여주는 방식. 또는 CSS scroll-driven animation | +| **`prefers-reduced-motion` 미대응** | 접근성 실패 | 모션 제거 경로를 반드시 제공 | +| **전 섹션 패럴랙스** | 스크롤 감각이 어긋나고 성능 저하 | 한 섹션만, 이동량 10–20px | +| **히어로에 3D/Spline 씬을 이유 없이** | JS 런타임 800KB~2MB. 모바일 4G에서 Core Web Vitals 실패 | 브리프가 요구할 때만. 아니면 정적 렌더 이미지 + 미세 모션 | +| **로딩할 게 없는데 프리로더 카운터** | 없는 대기를 만듦 | 삭제 | + +--- + +## 6. 카피 — 경쟁사 치환 테스트 + +**AI 티는 시각보다 문장에서 먼저 난다.** + +### 테스트 +카피에서 제품·회사 이름을 **경쟁사 이름으로 바꿔 읽는다.** +**여전히 자연스러우면 그 카피는 아무것도 말하지 않은 것이다. 다시 써라.** + +### 구문 지문 + +| 지문 | 왜 문제 | 대신 | +|---|---|---| +| **`It's not X, it's Y` / `단순한 X가 아니라 Y입니다`** | ChatGPT 최대 지문. 400단어에 3번씩 나옴 | 그냥 Y를 말해라. 대조가 필요하면 비교 대상을 실명으로 | +| **스타카토 3연타** (`No fluff. No filler. No BS.` / `빠르게. 정확하게. 간단하게.`) | 리듬으로 내용 없음을 감춤 | 한 문장으로 구체적으로 | +| **em-dash(—) 한 문단에 2회 이상** | AI 문장 리듬의 대표 지문 | 마침표로 끊거나 쉼표로. 한국어에서 특히 부자연스럽다 | +| **`In today's fast-paced digital landscape...`** 도입 | 아무 말도 하지 않는 문단 | 첫 문장부터 본론. 도입 문단 삭제 | + +### 어휘 지문 + +| 지문 | 왜 문제 | 대신 | +|---|---|---| +| `Streamline / Empower / Supercharge / Unlock / Leverage / Seamless / Elevate` | 아무 제품에나 붙는 동사. 정보량 0 | 제품이 실제로 하는 동작 동사 (`정산한다`, `병합한다`, `4일을 6시간으로 줄인다`) | +| `world-class / cutting-edge / enterprise-grade / best-in-class` | 자기 평가 형용사는 증거가 아니다 | 인증명, 벤치마크 수치, 고객사 실명 | +| `Build the future of X` / `Your all-in-one platform` / `Scale without limits` | 경쟁사 이름으로 바꿔도 성립 | 치환 테스트를 통과하는 문장 (예: "Financial infrastructure for the internet") | +| 헤지 표현 (`may help you`, `~할 수 있습니다`) | 확신 없음 = 신뢰 없음 | 단정하거나 조건을 명시 (`10명 이하 팀에서는`) | +| CTA가 `Get Started` / `Learn More` / `시작하기` | 무엇이 시작되는지 알 수 없음 | 결과를 말하는 CTA (`무료로 30일 써보기`, `가격표 보기`) | +| 모든 숫자가 어림수 (`10,000+`, `99.9%`, `50% faster`) | 검증 불가 | 정확한 수치와 측정 조건 (`p95 응답 240ms, 2026-07 기준`) | +| 모든 헤딩이 명사구 (`Powerful Features`, `Simple Pricing`) | 헤딩이 정보를 전달하지 않음 | 헤딩에 주장을 담아라 (`엑셀 없이 정산이 끝난다`) | +| 레이블·서브레이블·헬퍼가 같은 말 3번 (`이메일` / `이메일 주소` / `이메일 주소를 입력하세요`) | 화면 소음 | 하나만 남긴다 | +| 이모지로 시작하는 불릿 (`✅ 빠른 속도`) | 정보 위계를 이모지로 대체 | 일반 불릿 또는 문장으로 | + +--- + +## 7. 이미지 + +| 지문 | 왜 문제 | 대신 | +|---|---|---| +| **랩탑 앞에서 웃는 다국적 팀 스톡 사진** | 방문자는 스톡을 알아보고 **신뢰도가 실제로 하락**한다 | 실제 팀 사진, 실제 작업 공간, 또는 사진 없이 타입으로 | +| **떠 있는 3D 추상 블롭·기하 도형** | 콘텐츠 부재를 덮는 장식 | 제품 실제 화면, 데이터 시각화, 또는 여백 | +| **AI 생성 일러스트** (지나치게 매끄럽고 대칭적) | 결함이 없어도 톤에서 티가 남 | 일러스트 시스템을 직접 정의하거나 아예 쓰지 않는다 | +| **일반 SVG 도형을 조합한 히어로 그래픽** | 플레이스홀더 클립아트처럼 읽힘 | 제품 UI 조각을 실제로 렌더 | +| **아이콘 세트가 섞임** (라인·필·이모지 혼재) | 시스템이 없다는 증거 | 세트 하나 고정. 굵기·크기·광학 정렬 통일 | +| **`src`가 비었거나 깨진 이미지 태그** | 검증 없이 배포된 흔적 | 배포 전 이미지 로드 전수 확인 | +| **모든 이미지가 같은 비율의 둥근 사각형** | 그리드를 이미지에 강제 | 콘텐츠에 맞는 비율. 풀블리드 1장을 섞으면 리듬이 생긴다 | + +--- + +## 8. 한글 조판 (한국어 프로젝트 필수) + +서구 갤러리에는 없는 규칙이다. **영문 기준을 그대로 쓰면 전부 깨진다.** + +| 규칙 | 값 | 이유 | +|---|---|---| +| **한글 폰트 미지정 금지** | `Pretendard` 또는 `본고딕` 명시 | 지정하지 않으면 시스템 기본(맑은 고딕 / Apple SD 산돌고딕)으로 떨어지고, 그 자체가 완성도 미달 신호다 | +| **line-height를 영문보다 높게** | 본문 **1.6–1.8** | 한글은 글자 밀도가 높아 1.5로는 답답하다 | +| **음수 자간 금지** | 본문 0, 대형 헤드라인만 -0.01 ~ -0.02em | 한글은 고정폭에 가까워 음수 자간이 즉시 뭉개진다 | +| **`word-break: keep-all`** | `overflow-wrap: break-word`와 함께 | 기본값은 단어 중간에서 줄바꿈되어 어색하다 | +| **한 줄 25–40자** | 영문 45–75자 기준을 쓰면 너무 길다 | 컨테이너 폭을 좁혀라 | +| **라틴 폰트를 폴백 앞에** | `font-family: 'Satoshi', 'Pretendard', sans-serif` | 한글 폰트를 앞에 두면 라틴 문자까지 그 폰트로 그려진다 | +| **번역투 금지** | `~를 통해`, `~에 대한`, `~에 있어서` | 영문 AI 카피를 기계 번역한 티가 난다. `~로`, `~의`, `~에서`로 | + +**한글 폰트 선택** +- 기본값: **Pretendard** (SIL OFL, Thin~Black 9단계, 가변). CDN: `https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css` +- 명조·에디토리얼: **마루 부리** (세리프 부활 트렌드의 한글 대응) +- 수치 많은 UI: **Spoqa Han Sans Neo** +- 다국어 안정성: **본고딕 / Noto Sans KR** +- 캠페인 헤드라인: 배민 도현체·여기어때 잘난체 — **B2C 한정. B2B에 쓰면 즉시 아마추어** +- 나눔고딕은 너무 흔하다. 폴백으로만. + +--- + +## 9. grep 코드 지문 (프리플라이트 자동 검사) + +``` +# 색 +indigo-|violet-|purple-|fuchsia- +#6366f1|#818cf8|#a855f7|#8b5cf6|#c4b5fd|#a78bfa|#22d3ee +from-purple|to-blue-|from-violet|via-purple +bg-slate-900|bg-gray-900|text-gray-400 +#ffffff|#000000 + +# 컴포넌트 기본값 +rounded-2xl|rounded-3xl +shadow-lg|shadow-xl|shadow-2xl +border-l-4|border-t-4 +backdrop-blur + +# 타이포 +font-family:.*Inter +(Space Grotesk.*Instrument Serif)|(Instrument Serif.*Geist)|(Space Grotesk.*Geist) +uppercase tracking-wide + +# 카피 +Get Started|Learn More|Empower|Streamline|Supercharge|Seamless +world-class|cutting-edge|enterprise-grade|best-in-class +It's not .* it's + +# 이모지 아이콘 (텍스트 노드) +🚀|⚡|✨|🎯|🔥|💡|✅ +``` + +**한글 프로젝트 추가 검사** + +``` +# 있어야 하는 것 — 없으면 실패 +word-break:\s*keep-all +Pretendard|Noto Sans KR|본고딕|마루부리 + +# 없어야 하는 것 +letter-spacing:\s*-0\.0[3-9] (한글 본문) +~를 통해|~에 대한|에 있어서 +``` + +--- + +## 10. 자가 채점표 + +| 카테고리 | 걸린 항목 | 조치 | +|---|---|---| +| 0 최우선 3종 | | | +| 1 컬러 | | | +| 2 타이포 | | | +| 3 레이아웃 | | | +| 4 컴포넌트 | | | +| 5 모션 | | | +| 6 카피 | | | +| 7 이미지 | | | +| 8 한글 조판 | | | + +**통과 조건 — 전부 예여야 한다** +- [ ] 최우선 3종(보라 CTA / 전체 대문자 / 1·2·3 단계) 중 걸린 것이 없다 +- [ ] grep 검사에서 나온 항목을 전부 해소했다 +- [ ] 카피가 경쟁사 치환 테스트를 통과한다 +- [ ] 모션을 전부 끄고도 페이지가 작동한다 +- [ ] 강조색을 지워도 페이지가 읽힌다 +- [ ] 색을 회색조로 바꿔도 위계 순서가 비즈니스 우선순위와 일치한다 +- [ ] 키보드만으로 전 인터랙션이 가능하고 포커스 링이 보인다 +- [ ] (한국어) 한글 폰트가 명시되어 있고 `word-break: keep-all`이 있다 + +**판정** + +| 걸린 개수 | 판정 | +|---|---| +| 0–1 | 통과 | +| 2–3 | 해당 항목 수정 후 재검 | +| **4 이상** | **폐기. reference-method.md §3부터 다시** | + +> 근거: research/references/04-ai-slop-signatures.md, 03-trends-2026.md (조사일 2026-08-20) diff --git a/packages/skill/references/experimental-canvas.md b/packages/skill/references/experimental-canvas.md new file mode 100644 index 0000000..399ee62 --- /dev/null +++ b/packages/skill/references/experimental-canvas.md @@ -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 표면 + +`` 를 선언하고, 그릴 요소를 **직계 자식**으로 둔다. 손자는 그릴 수 없다. + +| 용도 | 호출 | +|---|---| +| 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 `