feat: 브리프 인터뷰 단계와 브라우저 도구를 넣고 온보딩 말투를 고친다
All checks were successful
ci / build (push) Successful in 25s
release / release (push) Successful in 46s

세 가지를 고친다. 전부 실제 사용에서 드러난 것이다.

1) 0단계에서 가정으로 채우던 것을 인터뷰로 바꾼다

   "꽃집 사이트 만들어보자"를 받고 업종 성격·목표 행동·톤·이름을 혼자 정했다.
   물어보니 넷 중 넷이 달랐다(일상 구독 → 하이엔드 스튜디오, 문의 하나 → 넷 다,
   톤 미정 → 에디토리얼, 이름 지어냄 → 목요일의 화원).
   그대로 갔으면 레퍼런스 세 개를 전부 틀린 방향에서 골랐다.

   SKILL.md 0단계에 질문 도구로 한 번에 묻는 절차를 넣었다. 무엇을 묻고
   무엇을 묻지 않는지, 답이 모순될 때 어떻게 정리하는지까지 적었다.

2) 레퍼런스가 막히면 브라우저를 띄운다

   Aesop(403) → Kinto(404) → Hasami(DNS) → MUJI(타임아웃)로 네 번 왕복하고
   톤 레퍼런스를 하나도 못 얻었다. 좋은 레퍼런스일수록 봇을 막는다.
   galleries.md 에 headed 브라우저로 직접 열어 스크린샷과 실측값을 받는
   방법을 넣고, 접근 실패 2회면 바로 전환하도록 규칙을 세웠다.

   Playwright 를 선택 의존성으로 잡았다(core/tools.ts).
   - 온보딩 마지막에 설치 여부를 묻는다. 건너뛰어도 스킬은 동작한다
   - `designpaca tools` 로 상태 확인, `--yes` 로 설치
   - 수백 MB 라 --yes 없이는 상태만 보여준다
   - MCP 서버는 설치만 하고 등록 명령은 안내만 한다(에이전트 설정을
     대신 건드리지 않는다)

3) 온보딩 말투

   "이제 브리프를 던져라", "설치해라", "건너뛴다" — 사용자를 향한 문구가
   명령조였다. 스킬 문서의 단정한 반말은 의도지만 CLI UI 는 다르다.
   전부 존댓말로 바꾸고, 각 단계가 왜 필요한지 설명을 붙였다.
   명령조가 다시 섞이지 않도록 검사하는 테스트를 넣었다(테스트 22 → 25개).
This commit is contained in:
Yun Chan 2026-08-21 03:05:06 +09:00
parent eb9f61e907
commit 4e1e5f0073
15 changed files with 486 additions and 39 deletions

View file

@ -1,16 +1,35 @@
{ {
"name": "designpaca", "name": "designpaca",
"version": "0.1.0", "version": "0.2.0",
"description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다", "description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다",
"keywords": ["design", "web-design", "claude-code", "codex", "cursor", "agent-skill", "svg-filter", "threejs"], "keywords": [
"design",
"web-design",
"claude-code",
"codex",
"cursor",
"agent-skill",
"svg-filter",
"threejs"
],
"license": "MIT", "license": "MIT",
"author": "Yun Chan", "author": "Yun Chan",
"homepage": "https://designpaca.chanpaca.net", "homepage": "https://designpaca.chanpaca.net",
"repository": { "type": "git", "url": "git+https://git.chanpaca.net/yunchan/designpaca.git" }, "repository": {
"type": "git",
"url": "git+https://git.chanpaca.net/yunchan/designpaca.git"
},
"type": "module", "type": "module",
"bin": { "designpaca": "./dist/index.js" }, "bin": {
"files": ["dist", "README.md"], "designpaca": "./dist/index.js"
"engines": { "node": ">=20.11" }, },
"files": [
"dist",
"README.md"
],
"engines": {
"node": ">=20.11"
},
"scripts": { "scripts": {
"build": "node scripts/bundle-skill.mjs && tsup", "build": "node scripts/bundle-skill.mjs && tsup",
"dev": "node scripts/bundle-skill.mjs && tsup --watch", "dev": "node scripts/bundle-skill.mjs && tsup --watch",

View file

@ -18,7 +18,7 @@ export async function runDoctor(): Promise<number> {
if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`)); if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`));
if (manifest.installs.length === 0) { if (manifest.installs.length === 0) {
console.log(warn("설치된 곳이 없다. `npx designpaca install` 로 설치해라.")); console.log(warn("아직 설치된 곳이 없습니다. `npx designpaca install` 로 설치하실 수 있습니다."));
return 0; return 0;
} }
@ -40,7 +40,7 @@ export async function runDoctor(): Promise<number> {
if (rec.version !== version) problems++; if (rec.version !== version) problems++;
if (missing.length > 0) { if (missing.length > 0) {
problems++; problems++;
console.log(bad(`파일 ${missing.length}개가 사라졌다 — \`designpaca update --force\` 로 복구해라`)); console.log(bad(`파일 ${missing.length}개가 보이지 않습니다 — \`designpaca update --force\` 로 복구하실 수 있습니다`));
for (const m of missing.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`)); for (const m of missing.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
} }
if (modified.length > 0) { if (modified.length > 0) {

View file

@ -60,7 +60,7 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void
const verb = dryRun ? "쓸 파일" : "설치됨"; const verb = dryRun ? "쓸 파일" : "설치됨";
console.log(ok(`${o.target}${verb} ${o.written}${dim(tildify(o.root))}`)); console.log(ok(`${o.target}${verb} ${o.written}${dim(tildify(o.root))}`));
if (o.skipped.length > 0) { if (o.skipped.length > 0) {
console.log(warn(` 직접 수정한 파일이라 건드리지 않았다 (--force 로 덮어쓴다):`)); console.log(warn(` 직접 수정하신 파일이라 그대로 두었습니다 (--force 로 덮어쓸 수 있습니다):`));
for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`)); for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`));
} }
} }
@ -70,7 +70,7 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void
export function printNextSteps(targets: TargetId[]): void { export function printNextSteps(targets: TargetId[]): void {
console.log(heading("다음 단계")); console.log(heading("다음 단계"));
if (targets.includes("claude-code")) { if (targets.includes("claude-code")) {
console.log(info(`Claude Code 를 새로 열고 ${dim("/designpaca")} 를 실행해라`)); console.log(info(`Claude Code 를 새로 연 뒤 ${dim("/designpaca")} 를 실행해 주세요`));
} }
if (targets.includes("codex")) { if (targets.includes("codex")) {
console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`)); console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`));

View file

@ -0,0 +1,67 @@
import { checkTools } from "@designpaca/core";
import { bad, dim, heading, info, ok, table, warn } from "../ui.ts";
/**
* , .
*
* , .
* `--yes` ** **
* MB .
*/
export async function runTools(opts: { install: boolean }): Promise<number> {
const status = await checkTools();
console.log(heading("브라우저 도구"));
console.log(
table(
status.map(({ tool, installed }) => [
tool.label,
installed ? "설치됨" : dim("설치되지 않음"),
]),
),
);
const missing = status.filter((s) => !s.installed);
if (missing.length === 0) {
console.log(ok("필요한 도구가 모두 준비돼 있습니다."));
return 0;
}
console.log();
for (const { tool } of missing) {
console.log(`${tool.label} ${dim(`· ${tool.size}`)}`);
console.log(` ${dim(tool.why)}`);
}
if (!opts.install) {
console.log();
console.log(info("설치하시려면 `designpaca tools --yes` 를 실행해 주세요."));
return 0;
}
console.log();
let failed = 0;
const followUps: string[] = [];
for (const { tool } of missing) {
process.stdout.write(`${tool.label} 설치 중… `);
try {
await tool.install();
console.log("완료");
if (tool.followUp) followUps.push(tool.followUp);
} catch (err) {
failed++;
console.log("실패");
const first = err instanceof Error ? (err.message.split("\n")[0] ?? err.message) : String(err);
console.log(` ${bad(first)}`);
}
}
if (followUps.length > 0) {
console.log();
console.log(warn("아래는 에이전트 설정을 바꾸는 것이라 직접 실행해 주세요."));
for (const f of followUps) console.log(` ${f}`);
}
return failed > 0 ? 1 : 0;
}

View file

@ -11,7 +11,7 @@ export async function runUninstall(opts: {
); );
if (targets.length === 0) { if (targets.length === 0) {
console.log(warn("제거할 설치 기록이 없다")); console.log(warn("제거할 설치 기록이 없습니다"));
return 0; return 0;
} }

View file

@ -12,7 +12,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise<number>
const manifest = await readManifest(); const manifest = await readManifest();
if (manifest.installs.length === 0) { if (manifest.installs.length === 0) {
console.log(warn("설치 기록이 없다. `npx designpaca install` 을 먼저 실행해.")); console.log(warn("설치 기록이 없습니다. `npx designpaca install` 을 먼저 실행해 주세요."));
return 1; return 1;
} }
@ -20,7 +20,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise<number>
const stale = manifest.installs.filter((i) => i.version !== version); const stale = manifest.installs.filter((i) => i.version !== version);
if (stale.length === 0) { if (stale.length === 0) {
console.log(ok("모든 설치가 이미 최신이다")); console.log(ok("설치된 스킬이 모두 최신 버전입니다"));
// 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다. // 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다.
let broken = 0; let broken = 0;

View file

@ -3,6 +3,7 @@ import { getVersion } from "./skill.ts";
import { checkForUpdate } from "./update-check.ts"; import { checkForUpdate } from "./update-check.ts";
import { printNextSteps, printOutcomes, runInstall } from "./commands/install.ts"; import { printNextSteps, printOutcomes, runInstall } from "./commands/install.ts";
import { runDoctor } from "./commands/doctor.ts"; import { runDoctor } from "./commands/doctor.ts";
import { runTools } from "./commands/tools.ts";
import { runUpdate } from "./commands/update.ts"; import { runUpdate } from "./commands/update.ts";
import { runUninstall } from "./commands/uninstall.ts"; import { runUninstall } from "./commands/uninstall.ts";
import { runList } from "./commands/list.ts"; import { runList } from "./commands/list.ts";
@ -70,14 +71,15 @@ ${bold("사용법")}
npx designpaca uninstall npx designpaca uninstall
npx designpaca doctor npx designpaca doctor
npx designpaca list npx designpaca list
npx designpaca tools ${dim("(--yes 로 설치)")}
${bold("옵션")} ${bold("옵션")}
-t, --target <id[,id]> 대상: ${VALID_TARGETS.join(", ")} -t, --target <id[,id]> 대상: ${VALID_TARGETS.join(", ")}
-s, --scope <범위> user | project ${dim("(기본: user)")} -s, --scope <범위> user | project ${dim("(기본: user)")}
-y, --yes -y, --yes
-f, --force ${dim("(.orig 로 백업)")} -f, --force ${dim("(.orig 로 백업)")}
--dry-run --dry-run
--no-update-check --no-update-check
-h, --help -h, --help
-v, --version -v, --version
@ -85,6 +87,7 @@ ${bold("예시")}
${dim("npx designpaca install -t claude-code,codex -s user -y")} ${dim("npx designpaca install -t claude-code,codex -s user -y")}
${dim("npx designpaca install -t cursor -s project")} ${dim("npx designpaca install -t cursor -s project")}
${dim("npx designpaca update --force")} ${dim("npx designpaca update --force")}
${dim("npx designpaca tools --yes")}
`.trimStart(); `.trimStart();
} }
@ -146,12 +149,16 @@ async function main(): Promise<number> {
case "doctor": case "doctor":
code = await runDoctor(); code = await runDoctor();
break; break;
case "tools":
// 수백 MB 를 받는 일이라 --yes 없이는 상태만 보여준다
code = await runTools({ install: args.yes });
break;
case "list": case "list":
case "ls": case "ls":
code = await runList(); code = await runList();
break; break;
default: default:
console.error(bad(`알 수 없는 명령: ${args.command}`)); console.error(bad(`알 수 없는 명령입니다: ${args.command}`));
console.log(usage(version)); console.log(usage(version));
return 2; return 2;
} }
@ -160,7 +167,7 @@ async function main(): Promise<number> {
const latest = await checkForUpdate(version); const latest = await checkForUpdate(version);
if (latest) { if (latest) {
console.log( console.log(
"\n" + warn(`새 버전 ${bold(`v${latest}`)} 이 있다 — ${dim("npx designpaca@latest update")}`), "\n" + warn(`새 버전 v${latest} 이 나왔습니다 — ${dim("npx designpaca@latest update")}`),
); );
} }
return code; return code;

View file

@ -2,6 +2,7 @@ import * as p from "@clack/prompts";
import path from "node:path"; import path from "node:path";
import { import {
ADAPTERS, ADAPTERS,
checkTools,
detectTargets, detectTargets,
getAdapter, getAdapter,
planInstall, planInstall,
@ -27,19 +28,21 @@ export async function onboard(): Promise<number> {
p.note( p.note(
[ [
"브리프에서 시작해 레퍼런스 조사 · 방향 결정 · 디자인 토큰 ·", "웹 디자인을 순서대로 진행하게 만드는 스킬입니다.",
"구현 · 셀프 감사까지 끌고 가는 웹 디자인 파이프라인 스킬.", "브리프 정리 → 레퍼런스 조사 → 방향 결정 → 디자인 토큰 →",
"구현 → 셀프 감사까지, 단계마다 통과 조건을 두고 진행합니다.",
"", "",
`${dim("SVG 필터 · three.js · 인터랙티브 모션을 기본 재료로 쓴다.")}`, dim("설치하면 에이전트에서 /designpaca 로 부를 수 있습니다."),
dim("스킬 파일만 복사하며, 기존 설정은 건드리지 않습니다."),
].join("\n"), ].join("\n"),
"무엇을 설치하나", "designpaca 는 무엇인가요",
); );
// 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다 // 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다
const detected = await detectTargets(skill); const detected = await detectTargets(skill);
const targets = await p.multiselect<TargetId>({ const targets = await p.multiselect<TargetId>({
message: "어디에 설치할까?", message: "어디에 설치할까?",
options: ADAPTERS.map((a) => ({ options: ADAPTERS.map((a) => ({
value: a.id, value: a.id,
label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""), label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""),
@ -51,10 +54,18 @@ export async function onboard(): Promise<number> {
if (p.isCancel(targets)) return cancel(); if (p.isCancel(targets)) return cancel();
const wanted = await p.select<Scope>({ const wanted = await p.select<Scope>({
message: "설치 범위", message: "어느 범위에 설치할까요?",
options: [ options: [
{ value: "user", label: "전역", hint: "홈 디렉터리 — 모든 프로젝트에서 쓴다" }, {
{ value: "project", label: "이 프로젝트만", hint: tildify(process.cwd()) }, value: "user",
label: "전역 (권장)",
hint: "홈 디렉터리에 두어 모든 프로젝트에서 쓸 수 있습니다",
},
{
value: "project",
label: "이 프로젝트만",
hint: `${tildify(process.cwd())} · 팀과 함께 쓰려면 이쪽이 좋습니다`,
},
], ],
initialValue: "user", initialValue: "user",
}); });
@ -67,7 +78,7 @@ export async function onboard(): Promise<number> {
downgraded downgraded
.map((t) => { .map((t) => {
const a = getAdapter(t); const a = getAdapter(t);
return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원한다 → ${effectiveScope(t, wanted)} 로 설치한`; return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원해서, ${effectiveScope(t, wanted)} 범위로 설치됩니`;
}) })
.join("\n"), .join("\n"),
); );
@ -91,35 +102,126 @@ export async function onboard(): Promise<number> {
].join("\n"), ].join("\n"),
); );
} }
p.note(previews.join("\n\n"), "설치 계획"); p.note(
[
previews.join("\n\n"),
"",
dim("아래 경로에 스킬 파일을 복사합니다."),
dim("직접 수정하신 파일이 있으면 덮어쓰지 않고 알려드립니다."),
].join("\n"),
"이렇게 설치됩니다",
);
const go = await p.confirm({ message: "이대로 설치할까?", initialValue: true }); const go = await p.confirm({ message: "이대로 진행할까요?", initialValue: true });
if (p.isCancel(go) || !go) return cancel(); if (p.isCancel(go) || !go) return cancel();
const s = p.spinner(); const s = p.spinner();
s.start("설치 중"); s.start("설치하고 있습니다");
const outcomes = []; const outcomes = [];
try { try {
for (const t of targets) { for (const t of targets) {
const scope = effectiveScope(t, wanted); const scope = effectiveScope(t, wanted);
s.message(`설치 중 — ${getAdapter(t).label}`); s.message(`${getAdapter(t).label} 에 설치하고 있습니다`);
outcomes.push(...(await runInstall({ targets: [t], scope }))); outcomes.push(...(await runInstall({ targets: [t], scope })));
} }
s.stop("설치 완료"); s.stop("설치가 끝났습니다");
} catch (err) { } catch (err) {
s.stop("설치 실패", 1); s.stop("설치하지 못했습니다", 1);
p.log.error(err instanceof Error ? err.message : String(err)); p.log.error(err instanceof Error ? err.message : String(err));
return 1; return 1;
} }
printOutcomes(outcomes); printOutcomes(outcomes);
await offerTools();
printNextSteps(targets); printNextSteps(targets);
p.outro(`${accent("designpaca")} 준비됨 — 이제 브리프를 던져라`); p.outro(`${accent("designpaca")} 준비됐습니다 — 만들고 싶은 것을 말씀해 주세요`);
return 0; return 0;
} }
/**
* .
* . .
*/
export async function offerTools(): Promise<void> {
const status = await checkTools();
const missing = status.filter((s) => !s.installed);
if (missing.length === 0) {
p.log.success(
`브라우저 도구가 이미 준비돼 있습니다 ${dim("— 레퍼런스 조사에서 바로 활용됩니다")}`,
);
return;
}
p.note(
[
"이 스킬은 디자인을 시작하기 전에 실제 사이트를 찾아 분석합니다.",
"그런데 참고할 만한 사이트일수록 자동 접근을 막아두는 경우가 많습니다.",
"",
"브라우저 도구가 있으면 그런 사이트도 직접 열어서",
"화면을 캡처하고 글자 크기·여백 같은 값을 실제로 잴 수 있습니다.",
"",
dim("설치하지 않아도 스킬은 정상 동작합니다."),
dim("나중에 `designpaca tools` 로 언제든 추가하실 수 있습니다."),
].join("\n"),
"브라우저 도구를 함께 설치할까요?",
);
const picked = await p.multiselect<string>({
message: "설치할 도구를 골라주세요 (스페이스로 선택 / 엔터로 확인)",
options: missing.map(({ tool }) => ({
value: tool.id,
label: tool.label,
hint: `${tool.why} · ${tool.size}`,
})),
initialValues: missing.map((m) => m.tool.id),
required: false,
});
if (p.isCancel(picked) || picked.length === 0) {
p.log.info(
`건너뛰겠습니다. ${dim("필요해지면 `designpaca tools` 로 설치하실 수 있습니다.")}`,
);
return;
}
const s = p.spinner();
const followUps: string[] = [];
for (const id of picked) {
const entry = missing.find((m) => m.tool.id === id);
if (!entry) continue;
s.start(`${entry.tool.label} 을(를) 설치하고 있습니다 ${dim(`(${entry.tool.size})`)}`);
try {
await entry.tool.install();
s.stop(`${entry.tool.label} 설치 완료`);
if (entry.tool.followUp) followUps.push(entry.tool.followUp);
} catch (err) {
// 도구 설치 실패로 스킬 설치까지 실패시키지 않는다. 스킬은 이미 깔렸다.
s.stop(`${entry.tool.label} 을(를) 설치하지 못했습니다`, 1);
p.log.warn(
[
err instanceof Error ? err.message.split("\n")[0] : String(err),
dim("스킬 설치는 정상적으로 끝났습니다. 이 도구만 나중에 다시 시도하셔도 됩니다."),
].join("\n"),
);
}
}
if (followUps.length > 0) {
p.note(
[
"아래 명령은 에이전트 설정을 바꾸는 것이라 직접 실행해 주세요.",
"",
...followUps,
].join("\n"),
"마지막 한 단계",
);
}
}
function cancel(): number { function cancel(): number {
p.cancel("설치를 취소했다. 아무것도 바꾸지 않았다."); p.cancel("설치를 취소했습니다. 아무것도 변경하지 않았습니다.");
return 130; return 130;
} }

View file

@ -0,0 +1,29 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import { fileURLToPath } from "node:url";
const exec = promisify(execFile);
const CLI = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../dist/index.js");
test("tools 는 --yes 없이 아무것도 설치하지 않는다", async () => {
// 수백 MB 를 받는 명령이라 조용히 시작하면 안 된다.
// 상태만 보이거나, 이미 다 있으면 그렇다고 말해야 한다.
const { stdout } = await exec(process.execPath, [CLI, "tools", "--no-update-check"]);
assert.match(stdout, /브라우저 도구/);
assert.doesNotMatch(stdout, /설치 중/);
});
test("도움말에 tools 명령이 나온다", async () => {
const { stdout } = await exec(process.execPath, [CLI, "--help"]);
assert.match(stdout, /designpaca tools/);
});
test("사용자에게 보이는 문구가 명령조가 아니다", async () => {
// 스킬 문서는 단정한 반말이 의도지만, 사용자를 향한 CLI 문구는 다르다.
const { stdout } = await exec(process.execPath, [CLI, "--help"]);
const rude = /(해라|써라|봐라|던져라|가라)/;
assert.doesNotMatch(stdout, rude, "도움말에 명령조 표현이 남아 있다");
});

View file

@ -1,6 +1,6 @@
{ {
"name": "@designpaca/core", "name": "@designpaca/core",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지", "description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지",
"type": "module", "type": "module",

View file

@ -5,4 +5,5 @@ export * from "./marker.ts";
export * from "./manifest.ts"; export * from "./manifest.ts";
export * from "./skill-source.ts"; export * from "./skill-source.ts";
export * from "./installer.ts"; export * from "./installer.ts";
export * from "./tools.ts";
export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts"; export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts";

129
packages/core/src/tools.ts Normal file
View file

@ -0,0 +1,129 @@
/**
* .
*
* . (403)
* SPA , .
* .
* headed .
*
* ** **. .
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const exec = promisify(execFile);
export interface OptionalTool {
id: string;
label: string;
/** 이게 없으면 무엇을 못 하는가 — 고르는 사람이 판단할 수 있게 */
why: string;
/** 설치 크기 어림값. 수백 MB 를 말없이 받게 하지 않는다 */
size: string;
detect(): Promise<boolean>;
install(): Promise<void>;
/** 설치 후 사람이 직접 해야 하는 것이 있으면 여기에 */
followUp?: string;
}
/** npm 전역 루트. Windows 경로에 공백이 있어도 안전하게 얻는다 */
async function npmRoot(): Promise<string | null> {
try {
const { stdout } = await exec("npm", ["root", "-g"], { shell: true });
return stdout.trim() || null;
} catch {
return null;
}
}
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
/**
* Playwright .
*
* .
* .
*/
const playwright: OptionalTool = {
id: "playwright",
label: "Playwright (headed 브라우저)",
why: "봇 차단(403)·SPA 갤러리를 직접 열어 스크린샷과 실측값을 받는다. 5단계 실렌더 검사에도 쓴다",
size: "약 180MB (크로미움 포함)",
async detect() {
const root = await npmRoot();
if (!root || !(await exists(path.join(root, "playwright")))) return false;
// 브라우저 바이너리까지 확인한다 — 라이브러리만 있으면 첫 실행에서 실패한다
const cacheDir =
process.platform === "win32"
? path.join(os.homedir(), "AppData", "Local", "ms-playwright")
: process.platform === "darwin"
? path.join(os.homedir(), "Library", "Caches", "ms-playwright")
: path.join(os.homedir(), ".cache", "ms-playwright");
try {
const entries = await fs.readdir(cacheDir);
return entries.some((e) => e.startsWith("chromium"));
} catch {
return false;
}
},
async install() {
await exec("npm", ["install", "-g", "playwright"], { shell: true, maxBuffer: 1 << 24 });
await exec("npx", ["--yes", "playwright", "install", "chromium"], {
shell: true,
maxBuffer: 1 << 24,
});
},
};
/**
* Playwright MCP Claude Code .
*
* ** .** MCP
* , . .
*/
const playwrightMcp: OptionalTool = {
id: "playwright-mcp",
label: "Playwright MCP 서버",
why: "에이전트가 브라우저를 직접 조작한다. 스크립트를 매번 쓰지 않아도 된다",
size: "약 2MB",
followUp: "claude mcp add playwright -- npx @playwright/mcp@latest",
async detect() {
const root = await npmRoot();
if (!root) return false;
return exists(path.join(root, "@playwright", "mcp"));
},
async install() {
await exec("npm", ["install", "-g", "@playwright/mcp"], { shell: true, maxBuffer: 1 << 24 });
},
};
export const OPTIONAL_TOOLS: OptionalTool[] = [playwright, playwrightMcp];
export interface ToolStatus {
tool: OptionalTool;
installed: boolean;
}
export async function checkTools(): Promise<ToolStatus[]> {
return Promise.all(
OPTIONAL_TOOLS.map(async (tool) => ({ tool, installed: await tool.detect() })),
);
}
export function getTool(id: string): OptionalTool | undefined {
return OPTIONAL_TOOLS.find((t) => t.id === id);
}

View file

@ -88,7 +88,48 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬.
**애매하면 긴 쪽으로.** 단 국소 조건에 해당하면 국소로 가라 — 버튼 하나에 갤러리 3곳을 여는 것은 사용자가 이 스킬을 끄게 만든다. **애매하면 긴 쪽으로.** 단 국소 조건에 해당하면 국소로 가라 — 버튼 하나에 갤러리 3곳을 여는 것은 사용자가 이 스킬을 끄게 만든다.
**국소로 시작했다가 조건이 깨지면 멈추고 올린다.** 토큰을 새로 정의하게 됐거나, 손댄 섹션이 3개를 넘었거나, 방향을 바꿔야 하면. **올렸다고 말해라. 조용히 국소에 머무는 것이 이 스킬의 최대 실패다.** **국소로 시작했다가 조건이 깨지면 멈추고 올린다.** 토큰을 새로 정의하게 됐거나, 손댄 섹션이 3개를 넘었거나, 방향을 바꿔야 하면. **올렸다고 말해라. 조용히 국소에 머무는 것이 이 스킬의 최대 실패다.**
**막혔을 때**: 브리프가 비어 있으면 추측하지 말고 물어라. 단 **한 번에 다 묻지 마라.** 결과를 가장 크게 바꾸는 것 하나만 묻고, 나머지는 가정을 명시하고 진행한다. **브리프가 비어 있으면 인터뷰해라. 가정으로 채우지 마라.**
이 단계에서 추측한 것은 전부 기본값이고, 기본값의 총합이 슬롭이다.
실측 사례 — "꽃집 홍보 사이트 하나 만들어보자"를 받고 업종 성격·목표 행동·톤·이름을
전부 혼자 정했다. 실제로 물어보니 **넷 중 넷이 달랐다.**
| 내가 가정한 것 | 사용자의 실제 답 |
|---|---|
| 일상 꽃 · 정기구독 | **하이엔드 플로럴 스튜디오** |
| 문의 유도 하나 | 문의 · 구독 · 방문 · 인스타 **넷 다** |
| (묻지 않음) | **에디토리얼 · 잡지** |
| (내가 지어냄) | **목요일의 화원** |
이 상태로 1단계에 들어갔으면 **레퍼런스 세 개를 전부 틀린 방향에서 골랐을 것이다.**
**질문 도구(AskUserQuestion)로 한 번에 묻는다.** 하나씩 캐물으면 사용자가 지친다 —
결과를 크게 바꾸는 축을 골라 **선택지와 함께** 한 화면에 낸다. 각 선택지에는
"이걸 고르면 무엇이 달라지는지"를 적어라. 고르는 사람이 결과를 예상할 수 있어야 한다.
거의 모든 브리프에서 다음 넷이 결과를 가장 크게 바꾼다.
| 축 | 무엇이 달라지는가 |
|---|---|
| **업종·성격** | 정보 구조 전체. 같은 "꽃집"도 구독형과 하이엔드 스튜디오는 다른 사이트다 |
| **목표 행동** | CTA 의 수와 위치, 어떤 섹션이 필요하고 어떤 게 군더더기인지 |
| **톤** | 2단계 프리셋과 감수할 리스크가 여기서 결정된다 |
| **고유명사·실제 값** | 이름·지역·가격·연락처. 지어내면 하드 게이트 11에 걸린다 |
넷을 `multiSelect` 로 낼지 단일 선택으로 낼지 구분해라 — **목표 행동은 대개 복수**고,
톤과 성격은 하나여야 한다. 둘 다 고르면 방향이 서지 않는다.
**물어도 되는 것과 물으면 안 되는 것**
- 묻는다: 결과를 바꾸는 결정(위 표), 사용자만 아는 사실(실제 수치·이름·재고)
- 묻지 않는다: 검색하면 나오는 것, 코드를 읽으면 아는 것, 관례가 명확한 것
**답이 모순되면 그 자리에서 정리해라.** 위 사례에서 목표 행동 넷이 다 선택됐는데,
하이엔드 스튜디오에서 "정기구독"과 "문의 상담"은 성격이 다르다.
**우선순위를 제안하고 확인받는다** — 넷을 같은 무게로 놓으면 CTA 가 넷이 되고 페이지가 무너진다.
예외: 사용자가 "알아서 해줘"라고 명시했거나, 되돌리기 쉬운 습작이면 가정하고 진행해도 된다.
**단 가정한 항목을 목록으로 말하고, 6단계 `design.md` 의 미확정 목록에 올린다.**
**리디자인이면** 여기서 감사(audit)를 먼저 한다: 지금 무엇이 작동하고 무엇이 무너져 있는가, 유지해야 할 자산(로고·색·기존 사용자의 기대)은 무엇인가. 감사 없는 리디자인은 파괴다. **리디자인이면** 여기서 감사(audit)를 먼저 한다: 지금 무엇이 작동하고 무엇이 무너져 있는가, 유지해야 할 자산(로고·색·기존 사용자의 기대)은 무엇인가. 감사 없는 리디자인은 파괴다.
@ -115,6 +156,11 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된
**갤러리 목록 페이지가 아니라 원본 사이트를 열어라.** 이미지를 볼 수 없어도 구조는 읽을 수 있다. **갤러리 목록 페이지가 아니라 원본 사이트를 열어라.** 이미지를 볼 수 없어도 구조는 읽을 수 있다.
**403·404·타임아웃을 두 번 만나면 텍스트 페처를 버리고 브라우저를 띄워라.**
좋은 레퍼런스일수록 봇을 막는다. 대체 갤러리를 찾아 헤매는 것은 시간 낭비이고,
찾아낸 대체 소스는 애초에 보려던 것보다 나쁘다. `designpaca tools` 로 설치된
headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다 — 방법은 `galleries.md` §5.
> 통과 조건: R1/R2/R3 각각의 URL과, 그것에서 **무엇을 가져올지** 한 줄씩. 형식은 `reference-method.md` 참조. > 통과 조건: R1/R2/R3 각각의 URL과, 그것에서 **무엇을 가져올지** 한 줄씩. 형식은 `reference-method.md` 참조.
--- ---

View file

@ -1,6 +1,6 @@
{ {
"name": "@designpaca/skill", "name": "@designpaca/skill",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서", "description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서",
"scripts": { "scripts": {

View file

@ -170,4 +170,51 @@ Awwwards는 **타이포 완성도와 야심의 상한선**으로만 쓰고, 섹
| adfolio.design | 429 | Love The Work More | | adfolio.design | 429 | Love The Work More |
| **commercecream.com** | **HTTPS 연결 거부 (방치 상태)** | **ecomm.design로 대체. 쓰지 마라** | | **commercecream.com** | **HTTPS 연결 거부 (방치 상태)** | **ecomm.design로 대체. 쓰지 마라** |
### 막히면 갤러리를 갈아타지 말고 **브라우저로 직접 들어가라**
위 표는 "대체 소스"를 적어뒀지만, 그건 차선책이다. **보려던 그 사이트를 봐야 한다.**
403·404·타임아웃을 만나면 대체 갤러리를 찾아 헤매지 말고 실제 브라우저를 띄워라.
실측에서 Aesop(403) → Kinto(404) → Hasami(DNS 실패) → MUJI(타임아웃)로 네 번 왕복하고도
**톤 레퍼런스를 하나도 못 얻었다.** 브라우저를 열었으면 첫 번째에 끝났다.
```js
// playwright 가 없으면: npm i -g playwright && npx playwright install chromium
const path = require('path');
const { chromium } = require(path.join(process.env.PW_ROOT, 'playwright'));
const b = await chromium.launch({ headless: false }); // headed 여야 봇 차단을 덜 받는다
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
await p.goto(url, { waitUntil: 'networkidle', timeout: 45000 });
await p.screenshot({ path: 'ref-1.png', fullPage: true });
```
**스크린샷은 텍스트 요약보다 낫다.** `WebFetch` 는 마크다운으로 바꾸면서
여백·비율·크기 대비를 전부 버린다 — 6축 중 절반이 그 정보다.
그림을 보고 나서 **재라**: 히어로가 첫 화면의 몇 %인지, 제목이 몇 px 인지, 칼럼이 몇 개인지.
```js
// 눈으로 보지 말고 숫자로 받아라
await p.evaluate(() => {
const h1 = document.querySelector('h1');
return {
h1px: h1 && getComputedStyle(h1).fontSize,
h1가족: h1 && getComputedStyle(h1).fontFamily.split(',')[0],
본문px: getComputedStyle(document.body).fontSize,
배경: getComputedStyle(document.body).backgroundColor,
첫화면_이미지비율: (() => {
const im = [...document.querySelectorAll('img,video,canvas')]
.filter(e => e.getBoundingClientRect().top < innerHeight);
const a = im.reduce((s, e) => { const r = e.getBoundingClientRect(); return s + r.width * r.height; }, 0);
return (a / (innerWidth * innerHeight) * 100).toFixed(0) + '%';
})(),
};
});
```
**headed 로 띄우는 이유**: 헤드리스는 Cloudflare·Akamai 봇 판정에 자주 걸린다.
그래도 막히면 그때 대체 소스로 간다 — 순서가 반대다.
> 규칙: **접근 실패 2회 → 브라우저를 띄운다.** 대체 갤러리 탐색은 브라우저도 막힌 뒤의 일이다.
---
> 근거: research/references/01-gallery-catalog.md, 03-trends-2026.md (조사일 2026-08-20) > 근거: research/references/01-gallery-catalog.md, 03-trends-2026.md (조사일 2026-08-20)