feat: 브리프 인터뷰 단계와 브라우저 도구를 넣고 온보딩 말투를 고친다
세 가지를 고친다. 전부 실제 사용에서 드러난 것이다.
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:
parent
eb9f61e907
commit
4e1e5f0073
15 changed files with 486 additions and 39 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 스킬이 자동으로 잡힌다`));
|
||||||
|
|
|
||||||
67
packages/cli/src/commands/tools.ts
Normal file
67
packages/cli/src/commands/tools.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
packages/cli/test/tools.test.ts
Normal file
29
packages/cli/test/tools.test.ts
Normal 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, "도움말에 명령조 표현이 남아 있다");
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
129
packages/core/src/tools.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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` 참조.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue