diff --git a/apps/site/public/work/thursday-flowers/assets/flower-01.webp b/apps/site/public/work/thursday-flowers/assets/flower-01.webp new file mode 100644 index 0000000..8c08048 Binary files /dev/null and b/apps/site/public/work/thursday-flowers/assets/flower-01.webp differ diff --git a/apps/site/public/work/thursday-flowers/assets/flower-02.webp b/apps/site/public/work/thursday-flowers/assets/flower-02.webp new file mode 100644 index 0000000..7f9a906 Binary files /dev/null and b/apps/site/public/work/thursday-flowers/assets/flower-02.webp differ diff --git a/apps/site/public/work/thursday-flowers/assets/flower-03.webp b/apps/site/public/work/thursday-flowers/assets/flower-03.webp new file mode 100644 index 0000000..f63bf20 Binary files /dev/null and b/apps/site/public/work/thursday-flowers/assets/flower-03.webp differ diff --git a/apps/site/public/work/thursday-flowers/assets/flower-04.webp b/apps/site/public/work/thursday-flowers/assets/flower-04.webp new file mode 100644 index 0000000..b418c45 Binary files /dev/null and b/apps/site/public/work/thursday-flowers/assets/flower-04.webp differ diff --git a/apps/site/public/work/thursday-flowers/assets/flower-05.webp b/apps/site/public/work/thursday-flowers/assets/flower-05.webp new file mode 100644 index 0000000..fb333e8 Binary files /dev/null and b/apps/site/public/work/thursday-flowers/assets/flower-05.webp differ diff --git a/apps/site/public/work/thursday-flowers/index.html b/apps/site/public/work/thursday-flowers/index.html new file mode 100644 index 0000000..ca54211 --- /dev/null +++ b/apps/site/public/work/thursday-flowers/index.html @@ -0,0 +1,301 @@ + + + + + + 목요일의 화원 · 플로럴 스튜디오 + + + + + + + + + + + + + + + + + + +
+ + 목요일의 화원 + + +
+ +
+ +
+
+
+

플로럴 스튜디오 / 서울 서촌 / 2019

+ +
+

+ 그 주에 좋은 꽃만
들입니다 +

+ +

+ 목요일 아침에 시장에서 그 주의 꽃을 고르고, 주문받은 만큼만 만듭니다. + 남는 꽃이 없어서 시들 일도 없습니다. +

+ + + 상담 신청하기 + +
+
+ +
+
+ 흰 라넌큘러스와 작약, 유칼립투스로 묶은 꽃다발 +
+
+ 꽃 한 송이의 꽃잎 결이 보이는 접사 +
+
+ 작업대 위에 놓인 꽃 줄기와 가위, 노끈 +
+

+ 주문 제작 / 정기 구독 / 공간 장식 +

+
+
+
+ + +
+
+
+

+ 일주일에 하루만 문을 엽니다 +

+

+ 수요일 밤에 경매가 끝나고, 목요일 새벽 시장에 그 주의 꽃이 나옵니다. + 그날 좋은 것만 골라 그날 만듭니다. +

+

+ 나머지 엿새는 만들지 않습니다. 미리 사다 두면 파는 날까지 꽃이 기다려야 + 하고, 기다린 꽃은 이미 한 번 시든 꽃입니다. +

+
+
+
여는 날
+
매주 목요일 10:00–19:00
+
+
+
주문 마감
+
수요일 18:00
+
+
+
그 주 제작 수
+
최대 20건
+
+
+
+
+ 꽃 줄기를 가위로 다듬는 손 +
목요일 아침, 손질
+
+
+
+ + +
+
+

작업

+

같은 꽃을 두 번 쓰지 않습니다

+

+ 주에 따라 들어오는 꽃이 다르니 결과도 매번 다릅니다. 지난 작업을 그대로 + 다시 만들어 달라는 요청은 받지 않습니다. +

+
+ +
+

+ 더 많은 작업은 + 인스타그램에 + 주 단위로 올립니다. +

+
+
+ + +
+
+
+

격주 목요일, 같은 자리에

+

+ 사무실 응접실이나 매장 카운터처럼 꽃이 늘 있어야 하는 자리를 위한 + 것입니다. 격주로 그 주의 꽃을 가져다 놓고, 지난 것을 가져옵니다. +

+

+ 화병은 저희 것을 두고 씁니다. 자리와 조도를 먼저 보고 정하기 때문에 + 첫 회는 방문 상담으로 시작합니다. +

+ 정기 상담 신청 +
+
+ 꽃잎의 결이 보이는 접사 +
이번 주 · 라넌큘러스
+
+
+
+ + +
+
+

문의

+

무엇에 쓸 꽃인지부터 듣습니다

+

+ 받는 분과 자리, 예산을 알려주시면 그 주에 가능한 것을 먼저 말씀드립니다. + 가능하지 않은 주에는 그렇다고 말씀드립니다. +

+ +
+
+ + +
+
+
+

서촌, 목요일에만

+
+ 서울 종로구 자하문로 00길 00, 2층
+ 경복궁역 3번 출구에서 도보 8분 +
+
+

+ 목요일 외에는 상주하지 않습니다. 다른 요일 방문은 미리 연락 주세요. +

+

목요일의 화원

+
+
+
+ + + + diff --git a/apps/site/public/work/thursday-flowers/styles/main.css b/apps/site/public/work/thursday-flowers/styles/main.css new file mode 100644 index 0000000..cc9a178 --- /dev/null +++ b/apps/site/public/work/thursday-flowers/styles/main.css @@ -0,0 +1,602 @@ +@import url("./tokens.css"); + +/* ──────────────────────────────────────────────────────── + 리셋 — 필요한 것만 + ──────────────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; +} +* { + margin: 0; +} +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +body { + background: var(--paper); + color: var(--ink); + font-family: var(--font-body); + font-size: var(--step-body); + line-height: var(--leading-body); + font-weight: var(--weight-regular); + /* 종이 질감. 에디토리얼 프리셋의 기본 재질이고, 아주 약해야 한다. + 세게 걸면 화면이 지저분해지고 사진의 디테일과 싸운다. */ + background-image: url("#"); + background-attachment: fixed; +} + +img, +picture { + display: block; + max-width: 100%; + /* CLS 를 막으려고 HTML 에 width/height 속성을 적어두면, height 속성이 + 그대로 계산된 높이가 되어 **aspect-ratio 가 무시된다.** + 실측: aspect-ratio 는 "3 / 4" 로 적용돼 있는데 높이는 원본 1448px 이었다. + height:auto 를 줘야 비율이 높이를 정한다. */ + height: auto; +} + +/* 한글 조판. 서구 레퍼런스에는 이 정보가 없어서 직접 정한다. + keep-all 이 없으면 어절 한가운데서 줄이 바뀐다. */ +:where(p, li, h1, h2, h3, figcaption, dd, dt) { + word-break: keep-all; + overflow-wrap: anywhere; + text-wrap: pretty; +} + +a { + color: inherit; +} + +/* 키보드 사용자를 위한 것. 어떤 이펙트보다 우선한다 */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.skip { + position: absolute; + left: -9999px; + top: 0; + background: var(--ink); + color: var(--paper); + padding: var(--space-3) var(--space-4); + z-index: 100; +} +.skip:focus { + left: var(--gutter); + top: var(--space-3); +} + +/* ──────────────────────────────────────────────────────── + 타이포그래피 — 크기 4종, 웨이트 2종. 그 이상 늘리지 않는다 + ──────────────────────────────────────────────────────── */ +h1, +h2, +h3 { + font-family: var(--font-display); + font-weight: var(--weight-regular); /* 큰 글자일수록 가늘게 */ + line-height: var(--leading-tight); + letter-spacing: var(--track-display); + text-wrap: balance; +} +h1 { + font-size: var(--step-display); +} +h2 { + font-size: clamp(1.6rem, 1.2rem + 1.4vw, 2.3rem); + letter-spacing: var(--track-lead); +} +h3 { + font-size: 1.15rem; + letter-spacing: var(--track-lead); +} + +.lead { + font-size: var(--step-lead); + letter-spacing: var(--track-lead); + color: var(--ink-muted); + max-width: var(--measure); +} + +/* 작은 라벨만 굵고 자간이 넓다. 크기 위계의 반대쪽 끝이다 */ +.label { + font-size: var(--step-label); + font-weight: var(--weight-bold); + letter-spacing: var(--track-label); + color: var(--ink-muted); + text-transform: none; /* 한글에 uppercase 는 아무 효과가 없고 라틴만 튄다 */ +} + +.muted { + color: var(--ink-muted); +} + +/* 제목 다음에 오는 것은 반드시 떨어진다. + h2 는 margin: 0 이고 p 도 margin-top: 0 이라 그냥 두면 붙는다. */ +h1 + *, +h2 + *, +h3 + * { + margin-top: var(--space-4); +} + +/* ──────────────────────────────────────────────────────── + 레이아웃 + ──────────────────────────────────────────────────────── */ +.wrap { + max-width: var(--container); + margin-inline: auto; + padding-inline: var(--gutter); +} + +.section { + padding-block: var(--space-8); +} + +/* 세로로 세운 글자. R1 이 여백을 이렇게 쓴다 — + 가로 공간을 먹지 않으면서 빈 곳에 정보를 흘려 넣는다. */ +.vertical { + writing-mode: vertical-rl; + font-size: var(--step-label); + font-weight: var(--weight-bold); + /* 가로쓰기 라벨의 자간(0.14em)을 그대로 쓰면 안 된다. + 세로쓰기에서 자간은 **글자 사이 세로 간격**이 되어 낱글자가 흩어진다. + 한글은 자소가 모여 한 글자라 특히 심하다. */ + letter-spacing: 0.02em; + color: var(--ink-faint); + white-space: nowrap; +} + +/* ── 링크: 밑줄이 글자를 침범하지 않게 ── */ +.link { + color: var(--accent); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.25em; + transition: text-decoration-color 250ms var(--ease-out); +} +.link:hover { + text-decoration-color: transparent; +} + +/* ── 버튼: 하나뿐인 주 CTA ── */ +.btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: 3rem; + padding: 0 var(--space-5); + background: var(--ink); + color: var(--paper); + font-size: var(--step-body); + font-weight: var(--weight-regular); + text-decoration: none; + border: 1px solid var(--ink); + border-radius: 0; /* 이 페이지의 모서리는 전부 각지다. 하나만 둥글면 그게 튄다 */ + transition: + background 250ms var(--ease-out), + color 250ms var(--ease-out); +} +.btn:hover { + background: transparent; + color: var(--ink); +} +.btn-quiet { + background: transparent; + color: var(--ink); + border-color: var(--line); +} +.btn-quiet:hover { + border-color: var(--ink); + background: transparent; +} + +/* ──────────────────────────────────────────────────────── + 등장 — 페이드와 10px 상승뿐. + R1 의 스크롤 이동량이 0px 이었다. 하이엔드는 움직이지 않는다. + ──────────────────────────────────────────────────────── */ +@media (prefers-reduced-motion: no-preference) { + .reveal { + opacity: 0; + transform: translateY(var(--rise)); + transition: + opacity var(--dur-slow) var(--ease-out), + transform var(--dur-slow) var(--ease-out); + transition-delay: var(--delay, 0ms); + } + .reveal.is-in { + opacity: 1; + transform: none; + } +} + +/* ════════════════════════════════════════════════════════ + 섹션별 레이아웃 + ════════════════════════════════════════════════════════ */ + +/* ── 헤더 ───────────────────────────────────────────── + 워드마크 좌상단, 내비 우상단. R1 이 이렇게 놓는다. + 고정하지 않는다 — 에디토리얼에서 떠다니는 바는 읽기를 방해한다. */ +.site-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-5); + padding-top: var(--space-5); +} +/* 넓은 화면에서는 두 줄, 좁은 화면에서는 한 줄로 쓴다. +
을 display:none 으로 지우면 줄만 붙는 게 아니라 **공백까지 사라져** + "목요일의화원" 이 된다. span 두 개로 두고 display 를 바꾼다. */ +.wordmark span { + display: block; +} +.wordmark { + font-family: var(--font-display); + font-size: 1.35rem; + line-height: 1.25; + letter-spacing: 0.06em; + text-decoration: none; + color: var(--ink); +} +.nav { + display: flex; + gap: var(--space-4); + font-size: var(--step-label); + font-weight: var(--weight-bold); + letter-spacing: var(--track-label); + padding-top: 0.4rem; +} +.nav a { + text-decoration: none; + color: var(--ink-muted); + transition: color 250ms var(--ease-out); +} +.nav a:hover { + color: var(--ink); +} + +/* ── 히어로 ─────────────────────────────────────────── + 리스크를 감수한 자리다. 큰 사진 한 장 대신 셋을 흩뿌린다. + + 그리드를 12칸으로 두되 각 사진이 **서로 다른 칸에서 시작해 다른 칸에서 끝난다.** + R1 을 실측했더니 이미지 6개의 좌측 정렬선이 6개 전부 달랐다(117·173·188·433·588·948). + 정렬선이 하나로 모이는 순간 이 레이아웃은 그냥 어긋난 그리드가 된다. */ +.hero { + padding-block: var(--space-6) var(--space-8); +} + +/* 12칸 그리드에 grid-row 를 손으로 배치했더니 행 높이가 콘텐츠에 따라 + 285/195/88/266px 로 제각각 잡히고 사이에 빈 칸이 생겼다. + 글과 사진을 두 덩어리로 나누고, **사진 안에서만** 흩뿌린다. + 그래야 정렬선을 흩는 것과 빈 공간이 생기는 것이 분리된다. */ +.hero-inner { + display: grid; + /* 텍스트 칼럼이 좁으면 h1 이 3줄로 접힌다(카운트 규칙: 헤드라인 2줄 이하). + 세로 라벨이 왼쪽에서 자리를 먹으므로 그만큼 더 준다. */ + grid-template-columns: minmax(0, 1.06fr) minmax(0, 1fr); + gap: var(--space-5); + align-items: center; +} +.hero-text { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-5); + align-items: start; +} +.hero-body > * + * { + margin-top: var(--space-5); +} +.hero-role { + align-self: start; +} +.hero-cta { + justify-self: start; +} + +/* 사진 셋. 위치·크기·비율이 전부 다르고 서로 겹친다. + R1 실측에서 이미지 6개의 좌측 정렬선이 6개 전부 달랐다 — + 정렬선이 하나로 모이면 이 레이아웃은 그냥 어긋난 그리드가 된다. */ +.hero-photos { + position: relative; + /* 가장 아래 사진(.ph-c)이 63% 에서 시작해 폭 47%·4:3 이므로 약 0.99 에서 끝난다. + 컨테이너를 그보다 길게 잡으면 히어로 아래에 빈 띠가 남는다. */ + aspect-ratio: 1 / 0.995; +} +.ph { + position: absolute; + margin: 0; + overflow: hidden; +} +.ph img { + width: 100%; + height: 100%; + object-fit: cover; +} +.ph-a { + left: 16%; + top: 0; + width: 60%; + aspect-ratio: 3 / 4; +} +.ph-b { + left: 0; + top: 52%; + width: 27%; + aspect-ratio: 3 / 4; +} +.ph-c { + right: 0; + top: 63%; + width: 47%; + aspect-ratio: 4 / 3; +} +.hero-tags { + position: absolute; + right: -1.4rem; + top: 4%; +} + +/* ── 섹션 공통 ────────────────────────────────────── */ +.band { + background: var(--paper-deep); +} + +/* 이미지와 글의 2단. 같은 스플릿을 세 번 이상 쓰지 않는다 — + 두 번째는 좌우를 뒤집어 리듬을 준다 */ +.two { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr); + gap: var(--space-7); + align-items: start; +} +.two-reverse .two-text { + order: 2; +} +.two-text > * + * { + margin-top: var(--space-4); +} +.two-text p { + max-width: var(--measure); +} +.two-photo { + margin: 0; +} +.two-photo img { + width: 100%; + aspect-ratio: 3 / 4; + object-fit: cover; +} +.two-photo figcaption { + margin-top: var(--space-3); +} + +/* 숫자와 사실. 표가 아니라 조판으로 둔다 */ +.facts { + margin-top: var(--space-6); + display: grid; + gap: var(--space-3); +} +.facts > div { + display: grid; + grid-template-columns: 7.5rem 1fr; + gap: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--line); +} +.facts dt { + font-size: var(--step-label); + font-weight: var(--weight-bold); + letter-spacing: var(--track-label); + color: var(--ink-muted); + padding-top: 0.35rem; +} +.facts dd { + margin: 0; +} + +/* ── 작업 갤러리 ────────────────────────────────────── + 균일한 3열 그리드를 쓰지 않는다. 셋의 크기와 세로 위치가 전부 다르다. */ +.gallery { + margin-top: var(--space-7); + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: var(--space-5) var(--space-4); + align-items: start; +} +.g { + margin: 0; +} +.g img { + width: 100%; + object-fit: cover; +} +.g figcaption { + margin-top: var(--space-3); + font-size: var(--step-label); + font-weight: var(--weight-bold); + letter-spacing: var(--track-label); + color: var(--ink-muted); +} +.g-1 { + grid-column: 1 / 9; +} +.g-1 img { + aspect-ratio: 16 / 9; +} +.g-2 { + grid-column: 9 / 13; + margin-top: var(--space-7); +} +.g-2 img { + aspect-ratio: 3 / 4; +} +.g-3 { + grid-column: 3 / 10; + margin-top: var(--space-5); +} +.g-3 img { + aspect-ratio: 4 / 3; +} + +.work-note { + margin-top: var(--space-6); + max-width: var(--measure); +} + +/* ── 문의 ─────────────────────────────────────────── */ +.contact-inner { + max-width: 46rem; +} +.contact-list { + list-style: none; + padding: 0; + margin-top: var(--space-6); + display: grid; + gap: var(--space-3); +} +.contact-list li { + display: grid; + grid-template-columns: 7rem 1fr; + gap: var(--space-3); + align-items: baseline; + padding-top: var(--space-3); + border-top: 1px solid var(--line); +} + +/* ── 찾아오기 (푸터를 대신한다) ───────────────────── */ +.visit { + padding-bottom: var(--space-7); +} +.visit-inner { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.7fr) auto; + gap: var(--space-6); + align-items: end; +} +.visit-address { + margin-top: var(--space-4); + font-style: normal; + color: var(--ink-muted); +} +.visit-note { + max-width: 24ch; +} +.visit-mark { + color: var(--line); + font-family: var(--font-display); + font-size: 1rem; + letter-spacing: 0.2em; +} + +/* ════════════════════════════════════════════════════════ + 좁은 화면 — 흩뿌림은 넓은 화면의 사치다. + 320px 에서 정렬선 여섯 개를 유지하면 그냥 부서진 페이지가 된다. + ════════════════════════════════════════════════════════ */ +@media (max-width: 900px) { + /* 히어로를 한 칸으로 내린다. 흩뿌림은 넓은 화면의 사치다 — + 390px 에서 정렬선 셋을 유지하면 사진이 서로를 가린다. */ + .hero-inner { + grid-template-columns: 1fr; + gap: var(--space-6); + } + /* 여기가 실제 사고 지점이었다. .hero-text 가 `auto 1fr` 2칸인데 + 세로 라벨을 가로로 눕히자 그 긴 문장이 auto 칸을 통째로 먹어 + h1 칸이 짜부라졌다 — 390px 에서 헤드라인이 6줄, 320px 에서 11줄이 됐다. */ + .hero-text { + grid-template-columns: 1fr; + gap: var(--space-4); + } + .hero-role { + writing-mode: horizontal-tb; + } + /* 히어로의 텍스트 요소는 넷을 넘지 않는다(카운트 규칙) */ + .hero-tags { + display: none; + } + .hero-photos { + aspect-ratio: 1 / 0.9; + } + .visit-mark { + writing-mode: horizontal-tb; + } + + .two, + .visit-inner { + grid-template-columns: 1fr; + gap: var(--space-6); + } + .two-reverse .two-text { + order: 0; + } + + .gallery { + grid-template-columns: 1fr; + } + .g-1, + .g-2, + .g-3 { + grid-column: 1; + margin-top: 0; + } +} + +@media (max-width: 560px) { + .facts > div, + .contact-list li { + grid-template-columns: 1fr; + gap: var(--space-1); + } + /* 워드마크와 내비를 한 줄에 나란히 두면 320px 에서 둘 다 접힌다. + 실측: 내비 링크가 두 줄(top 46/77)에 걸리고 워드마크는 3줄이 됐다. + 좁은 화면에서는 위아래로 쌓는다 — 접히는 것보다 쌓는 것이 낫다. */ + .site-head { + flex-direction: column; + align-items: stretch; + gap: var(--space-3); + } + .wordmark { + font-size: 1.15rem; + } + /* 세로로 쌓았으니 폭에 여유가 있다. 워드마크를 한 줄로 편다 */ + .wordmark span { + display: inline; + } + .nav { + flex-wrap: nowrap; + justify-content: space-between; + gap: var(--space-2); + letter-spacing: 0.04em; + padding-top: 0; + } + /* 버튼 라벨이 2줄로 접히면 하드 게이트 6 위반이다. + 좌우 패딩을 줄이고 줄바꿈을 막는다. */ + .btn { + padding-inline: var(--space-4); + white-space: nowrap; + } + /* 히어로 사진 셋을 세로로 세우면 화면이 너무 길어진다. + 가장 작은 것 하나만 남기고 나머지는 갤러리에서 다시 보여준다. */ + .hero-photos { + aspect-ratio: 3 / 4; + } + .ph-b { + display: none; + } + .ph-a { + left: 0; + width: 78%; + } + .ph-c { + top: 58%; + width: 62%; + } +} diff --git a/apps/site/public/work/thursday-flowers/styles/tokens.css b/apps/site/public/work/thursday-flowers/styles/tokens.css new file mode 100644 index 0000000..f87b1ff --- /dev/null +++ b/apps/site/public/work/thursday-flowers/styles/tokens.css @@ -0,0 +1,84 @@ +/* + 목요일의 화원 — 디자인 토큰 + 전부 3단계에서 확정한 값이고, 근거는 design.md 에 있다. + + 값의 출처는 취향이 아니라 실측이다. + Aesop(#FFFEF2) · Sarah Winward(#F8F7F3) · Cereal(#F7F6EF) 세 곳이 + 전부 순백을 피하고 따뜻한 오프화이트를 쓴다. 글자도 순검정이 아니다. +*/ +:root { + /* ── 색 ────────────────────────────────────────────── + 강조색은 하나다. 그리고 **면이 아니라 점**으로만 쓴다. + 꽃 사진에는 이미 온갖 색이 들어 있어서, 화면에 색을 더 칠하면 + 사진과 싸운다. 링크·라벨·구분선 정도가 강조색이 쓰이는 전부다. */ + --paper: #f8f6f0; + --paper-deep: #f1ede4; /* 섹션 하나만 살짝 눕힌다. 리듬용 */ + --ink: #2a2723; + --ink-muted: #6b655c; + --ink-faint: #a09889; + --accent: #7c4a3f; /* 마른 장미. 꽃의 초록과 충돌하지 않는다 */ + --line: #ddd7ca; + + /* ── 타입 ──────────────────────────────────────────── + 크기를 4종으로 묶는다. Aesop 이 3종(31/14/12), Cereal 이 2종(12/11)이었다. + 종류를 늘리면 위계가 아니라 소음이 된다. + + 자간은 크기에 따라 **방향이 뒤집힌다**(Kinfolk 실측). + 50px → -0.5px(-1%) · 15px → +0.15px(+1%) + 큰 글자는 조여서 덩어리로, 작은 글자는 풀어서 낱글자로 읽히게 한다. */ + --font-display: "MaruBuri", "Nanum Myeongjo", Georgia, serif; + --font-body: "Pretendard", -apple-system, "Segoe UI", sans-serif; + + /* 가장 긴 줄이 "그 주에 좋은 꽃만"(한글 7 + 공백 2 ≒ 7.7em)이다. + 세로 라벨이 왼쪽에서 60px 을 먹으므로 텍스트 칼럼은 화면 폭의 절반보다 좁다. + 1024px 에서 55.7px 로 잡히자 딱 넘쳐 3줄이 됐다(카운트 규칙 위반). + 기울기를 낮춰 전 구간에서 2줄을 유지한다. */ + --step-display: clamp(2.2rem, 1.3rem + 2.9vw, 4.2rem); + --step-lead: clamp(1.05rem, 0.98rem + 0.3vw, 1.25rem); + --step-body: 1rem; + --step-label: 0.75rem; + + --track-display: -0.02em; + --track-lead: -0.005em; + --track-body: 0; + --track-label: 0.14em; + + /* 웨이트는 둘이면 충분하다. 굵기로 소리치지 않는다. + 큰 글자일수록 가늘게 — Aesop 의 31px 이 400 이었고 12px 라벨이 700 이었다. */ + --weight-regular: 400; + --weight-bold: 700; + + --leading-tight: 1.2; + --leading-body: 1.85; /* 한글은 라틴보다 넉넉해야 한다. 받침 때문에 줄이 붙어 보인다 */ + + /* 한글 본문은 25~35자가 읽기 폭이다. 라틴 기준 65자를 그대로 쓰면 너무 넓다 */ + --measure: 32ch; + + /* ── 간격 ──────────────────────────────────────────── + 여백이 콘텐츠다. 섹션 간격을 본문 간격의 5배 이상으로 벌린다. */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2.5rem; + --space-6: 4rem; + --space-7: 6.5rem; + --space-8: clamp(6rem, 4rem + 8vw, 11rem); /* 섹션 사이 */ + + --gutter: clamp(1.25rem, 0.6rem + 2.6vw, 3rem); + --container: 1240px; + + /* ── 모션 ──────────────────────────────────────────── + R1(sarahwinward.com)의 스크롤 이동량을 쟀더니 **0px** 이었다. + 하이엔드 플로럴은 움직이지 않는다. 등장만 조용히 처리한다. */ + --dur-slow: 900ms; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --rise: 10px; +} + +@media (prefers-contrast: more) { + :root { + --ink-muted: #4a453e; + --line: #b9b0a0; + } +} diff --git a/apps/site/src/assets/shots/work-thursday-flowers-2.png b/apps/site/src/assets/shots/work-thursday-flowers-2.png new file mode 100644 index 0000000..1d98778 Binary files /dev/null and b/apps/site/src/assets/shots/work-thursday-flowers-2.png differ diff --git a/apps/site/src/assets/shots/work-thursday-flowers.png b/apps/site/src/assets/shots/work-thursday-flowers.png new file mode 100644 index 0000000..a8a7d15 Binary files /dev/null and b/apps/site/src/assets/shots/work-thursday-flowers.png differ diff --git a/apps/site/src/data/work.ts b/apps/site/src/data/work.ts new file mode 100644 index 0000000..c3d5032 --- /dev/null +++ b/apps/site/src/data/work.ts @@ -0,0 +1,55 @@ +/** + * 쇼케이스 목록. + * + * 사례를 추가할 때 **여기 한 항목만 넣으면** 모자이크 그리드에 자동으로 들어간다. + * 페이지 코드는 건드리지 않는다. + * + * 순서가 곧 표시 순서다(최근 것을 위에). `size` 는 타일 크기이고, + * 전부 같은 크기로 두지 마라 — 균일한 격자는 이 스킬이 금지하는 형태다. + */ +import type { ImageMetadata } from "astro"; +import thursdayFlowers from "../assets/shots/work-thursday-flowers.png"; +import thursdayFlowers2 from "../assets/shots/work-thursday-flowers-2.png"; + +export interface WorkItem { + slug: string; + /** 사이트 이름. 실제 클라이언트가 아니라 데모면 그렇다고 적는다 */ + name: string; + /** 무엇을 만든 것인가 — 한 줄 */ + kind: string; + /** 2단계에서 정한 한 문장 컨셉 */ + concept: string; + /** 감수한 리스크 하나. 이 스킬의 결과물을 다른 것과 구별하는 지점이다 */ + risk: string; + preset: string; + /** YYYY-MM */ + date: string; + /** 자랑이 아니라 잰 값. 없으면 비워 둔다 */ + metrics: string[]; + /** 실제로 열리는 주소. 목업이 아니라 동작하는 사이트여야 한다 */ + href: string; + shot: ImageMetadata; + /** 호버할 때 바뀌는 두 번째 컷. 없어도 된다 */ + shotAlt?: ImageMetadata; + alt: string; + /** 모자이크 타일 크기 */ + size: "lg" | "md" | "sm"; +} + +export const WORK: WorkItem[] = [ + { + slug: "thursday-flowers", + name: "목요일의 화원", + kind: "플로럴 스튜디오 · 한 장", + concept: "꽃을 파는 가게가 아니라, 꽃으로 작업하는 사람의 기록", + risk: "첫 화면에 큰 사진을 깔지 않았습니다. 크기가 제각각인 사진 셋을 정렬선 없이 흩뿌립니다", + preset: "editorial", + date: "2026-08", + metrics: ["이미지 603KB", "JS 0.6KB", "본문 대비 13.75:1", "하드 게이트 12/12"], + href: "/work/thursday-flowers/", + shot: thursdayFlowers, + shotAlt: thursdayFlowers2, + alt: "목요일의 화원 첫 화면 — 크림색 배경에 꽃 사진 셋이 서로 다른 위치에 놓여 있습니다", + size: "lg", + }, +]; diff --git a/apps/site/src/i18n/content.ts b/apps/site/src/i18n/content.ts index a9908fc..740e325 100644 --- a/apps/site/src/i18n/content.ts +++ b/apps/site/src/i18n/content.ts @@ -108,34 +108,34 @@ export interface Content { const STAGES_KO: Stage[] = [ { name: "브리프 게이트", - does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축한다. 규모를 판정해 경로를 고른다.", - becomes: "누구에게: 홈카페 3년차 이상. 산미와 로스팅 프로파일을 구분한다", + does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축합니다. 규모를 판정해 경로를 고릅니다.", + becomes: "누구에게: 홈카페 3년차 이상. 산미와 로스팅 프로파일을 구분합니다", }, { name: "레퍼런스 조사", - does: "실제로 존재하는 사이트 3개를 서로 다른 층위에서 고른다. 구조, 톤, 디테일.", - becomes: "R2 = 독립 문학 출판사. 커피 업계 밖에서 톤을 가져온다", + does: "실제로 존재하는 사이트 3개를 서로 다른 층위에서 고릅니다. 구조, 톤, 디테일.", + becomes: "R2 = 독립 문학 출판사. 커피 업계 밖에서 톤을 가져옵니다", }, { name: "방향 결정", - does: "레퍼런스를 하나의 축으로 합성한다. 감수할 리스크를 하나 고른다.", + does: "레퍼런스를 하나의 축으로 합성합니다. 감수할 리스크를 하나 고릅니다.", becomes: "감상은 이름에만, 화면에는 수치만", }, { name: "디자인 토큰", - does: "코드를 쓰기 전에 숫자를 정한다. 성능 예산도 여기서 확정한다.", + does: "코드를 쓰기 전에 숫자를 정합니다. 성능 예산도 여기서 확정합니다.", becomes: "--measure: 33em / --leading-normal: 1.7 / JS 예산 0KB", mono: true, }, { name: "구현", - does: "레이아웃, 재질, 입체, 모션 순서로. 이펙트를 다 꺼도 완성돼 있어야 한다.", + does: "레이아웃, 재질, 입체, 모션 순서로. 이펙트를 다 꺼도 완성돼 있어야 합니다.", becomes: "

산미 8.5 · 바디 6.0 · 로스팅 라이트

", mono: true, }, { name: "프리플라이트", - does: "자기 결과물을 남의 것처럼 본다. 게이트 12개는 전부 '아니오'여야 한다.", + does: "자기 결과물을 남의 것처럼 봅니다. 게이트 12개는 전부 '아니오'여야 합니다.", becomes: "게이트 12/12 통과 · 슬롭 지문 0건 · 320px 오버플로 31px 수정", mono: true, }, @@ -181,17 +181,17 @@ export const ko: Content = { meta: { title: "designpaca · 웹 디자인 파이프라인 스킬", description: - "AI 코딩 에이전트에게 웹 디자인을 순서대로 하게 만드는 스킬. 레퍼런스 조사부터 셀프 감사까지 여섯 단계. Claude Code, Codex, Cursor, Windsurf 에 한 줄로 설치한다.", + "AI 코딩 에이전트에게 웹 디자인을 순서대로 하게 만드는 스킬. 레퍼런스 조사부터 셀프 감사까지 여섯 단계. Claude Code, Codex, Cursor, Windsurf 에 한 줄로 설치합니다.", }, hero: { h1: "결정하지 않으면 기본값이 나온다", - lead: "AI가 만든 티는 못 만들어서가 아니라 결정을 안 해서 난다. 그 결정을 여섯 단계로 강제하는 스킬.", + lead: "AI가 만든 티는 못 만들어서가 아니라 결정을 안 해서 납니다. 그 결정을 여섯 단계로 강제하는 스킬.", install: "npx designpaca", - installNote: "Node 20.11+ · 설치 대상을 감지해 골라준다", + installNote: "Node 20.11+ · 설치 대상을 감지해 골라줍니다", links: [{ label: "npm", href: "https://www.npmjs.com/package/designpaca" }], stage: { - alt: "유리판 여섯 장이 깊이 방향으로 나란히 서 있다. 스크롤하면 카메라가 판을 하나씩 통과하고, 통과한 판은 뒤로 물러난다.", - caption: "판 하나가 단계 하나다. 통과 조건을 채우지 못하면 다음 판으로 가지 않는다.", + alt: "유리판 여섯 장이 깊이 방향으로 나란히 서 있습니다. 스크롤하면 카메라가 판을 하나씩 통과하고, 통과한 판은 뒤로 물러납니다.", + caption: "판 하나가 단계 하나다. 통과 조건을 채우지 못하면 다음 판으로 가지 않습니다.", gate: "게이트", pass: "통과", }, @@ -200,7 +200,7 @@ export const ko: Content = { eyebrow: "여섯 단계", h2: "브리프 한 줄이 검증된 페이지가 될 때까지", lead: - "각 단계에는 통과 조건이 있다. 통과하지 못하면 다음으로 가지 않는다. 아래는 같은 브리프가 단계마다 무엇으로 변했는지다.", + "각 단계에는 통과 조건이 있습니다. 통과하지 못하면 다음으로 가지 않습니다. 아래는 같은 브리프가 단계마다 무엇으로 변했는지다.", briefLabel: "들어온 브리프", brief: "수제 커피 로스터리의 원두 정기구독 랜딩 만들어줘", stages: STAGES_KO, @@ -208,17 +208,17 @@ export const ko: Content = { showcase: { h2: "결과물은 서로 닮지 않는다", lead: - "같은 파이프라인을 두 브리프에 돌린 결과다. 1단계에서 톤을 다른 업종에서 가져오게 강제하기 때문에 같은 도구를 써도 같은 화면이 나오지 않는다. 둘 다 수치 자리가 비어 있는 것도 우연이 아니다.", + "같은 파이프라인을 두 브리프에 돌린 결과다. 1단계에서 톤을 다른 업종에서 가져오게 강제하기 때문에 같은 도구를 써도 같은 화면이 나오지 않습니다. 둘 다 수치 자리가 비어 있는 것도 우연이 아닙니다.", items: [ { - alt: "무월 원두 구독 랜딩. 오프화이트 배경에 굵은 한글 헤드라인, 오른쪽에 원두 3종의 산미와 바디 표가 있다.", + alt: "무월 원두 구독 랜딩. 오프화이트 배경에 굵은 한글 헤드라인, 오른쪽에 원두 3종의 산미와 바디 표가 있습니다.", caption: "수제 커피 로스터리 · 구독 전환", - note: "프리셋 quiet-commerce. 사진이 없다는 제약을 수치표로 뒤집었다. 표의 값은 아직 비어 있다. 로스터에게 받기 전까지 채우지 않는다.", + note: "프리셋 quiet-commerce. 사진이 없습니다는 제약을 수치표로 뒤집었습니다. 표의 값은 아직 비어 있습니다. 로스터에게 받기 전까지 채우지 않습니다.", }, { - alt: "Pulsegate 모니터링 SaaS 히어로. 밝은 배경에 큰 세리프 헤드라인과 무료 체험 버튼이 있다.", + alt: "Pulsegate 모니터링 SaaS 히어로. 밝은 배경에 큰 세리프 헤드라인과 무료 체험 버튼이 있습니다.", caption: "API 모니터링 SaaS · 무료 체험 가입", - note: "개발자 도구라고 자동으로 다크로 가지 않았다. 브리프에 근거가 없었기 때문이다. 리전 수는 아직 중괄호 자리로 남아 있다.", + note: "개발자 도구라고 자동으로 다크로 가지 않았습니다. 브리프에 근거가 없었기 때문입니다. 리전 수는 아직 중괄호 자리로 남아 있습니다.", }, ], }, @@ -226,43 +226,43 @@ export const ko: Content = { eyebrow: "SVG 필터", h2: "이미지 편집기를 열지 않고 고치는 네 가지", lead: - "질감을 만드는 필터 데모는 많다. 손이 실제로 줄어드는 자리는 따로 있다. 아래 넷은 왼쪽이 매번 손으로 맞추던 것, 오른쪽이 같은 소재에 선언 하나만 얹은 것이다. 아래 수치는 이 화면을 그대로 캡처해 잰 값이다.", + "질감을 만드는 필터 데모는 많다. 손이 실제로 줄어드는 자리는 따로 있습니다. 아래 넷은 왼쪽이 매번 손으로 맞추던 것, 오른쪽이 같은 소재에 선언 하나만 얹은 것입니다. 아래 수치는 이 화면을 그대로 캡처해 잰 값이다.", demos: [ { id: "scrim", name: "글자 모양대로 생기는 스크림", - problem: "사진 위 캡션이 대비를 못 넘긴다. 사진마다 밝기가 달라 반투명 박스를 손으로 맞추게 된다.", + problem: "사진 위 캡션이 대비를 못 넘깁니다. 사진마다 밝기가 달라 반투명 박스를 손으로 맞추게 됩니다.", beforeLabel: "그냥 얹음", afterLabel: "filter: url(#scrim)", - how: "글자 알파를 부풀려 그 모양대로만 색을 채운다. 박스가 아니라 글자를 따라가므로 사진을 덜 가린다.", + how: "글자 알파를 부풀려 그 모양대로만 색을 채웁니다. 박스가 아니라 글자를 따라가므로 사진을 덜 가립니다.", verdict: "흰 글자 대비 1.70:1 → 4.79:1", }, { id: "cvd", name: "색각 이상 시뮬레이션", - problem: "상태를 색으로만 구분하면 남성 12명 중 1명에게 같은 배지가 된다. 내 화면만 봐서는 못 잡는다.", + problem: "상태를 색으로만 구분하면 남성 12명 중 1명에게 같은 배지가 됩니다. 내 화면만 봐서는 못 잡는다.", beforeLabel: "내가 보는 화면", afterLabel: "적록색약(2형)", - how: "feColorMatrix 한 줄. 이것만은 linearRGB 로 둔다 — 색각 변환은 물리적인 빛의 혼합이다.", + how: "feColorMatrix 한 줄. 이것만은 linearRGB 로 둡니다 — 색각 변환은 물리적인 빛의 혼합이다.", verdict: "배지 셋의 최소 색거리 82 → 2 · 주의와 실패가 같은 색", }, { id: "goo", name: "떨어진 것이 하나로 뭉치기", - problem: "조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켠다. 로더와 메뉴 전환에서 반복된다.", + problem: "조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켜게 됩니다. 로더와 메뉴 전환에서 반복됩니다.", beforeLabel: "필터 없음 · 원 3개", afterLabel: "filter: url(#goo)", - how: "블러한 뒤 알파만 세워 잘라낸다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해진다.", + how: "블러한 뒤 알파만 세워 잘라냅니다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해진다.", verdict: "덩어리 3개 → 1개 · JS 0줄", }, { id: "glass", name: "배경을 휘게 하는 오버레이", - problem: "반투명 패널은 뒤를 흐리게만 만든다. 실제 유리는 빛을 휜다.", + problem: "반투명 패널은 뒤를 흐리게만 만듭니다. 실제 유리는 빛을 휜다.", beforeLabel: "backdrop-filter: blur", afterLabel: "blur + url(#lens)", - how: "변위 지도를 backdrop-filter 에 넘긴다. 노이즈로 만들면 값이 중립으로 수렴해 아무 일도 안 일어난다.", - verdict: "가장자리에서 격자가 눌린다 · Chromium 전용", + how: "변위 지도를 backdrop-filter 에 넘깁니다. 노이즈로 만들면 값이 중립으로 수렴해 아무 일도 안 일어납니다.", + verdict: "가장자리에서 격자가 눌립니다 · Chromium 전용", }, ], }, @@ -270,122 +270,123 @@ export const ko: Content = { eyebrow: "이 페이지의 수치", h2: "카피만 고쳐서는 슬롭이 없어지지 않는다", lead: - "문장을 다듬는 것으로 해결되는 것은 문장뿐이다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남는다. 그래서 이 페이지 자신의 형태 수치를 공개한다. 결과가 나쁘게 나와도 그대로 싣는다.", + "문장을 다듬는 것으로 해결되는 것은 문장뿐입니다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남습니다. 그래서 이 페이지 자신의 형태 수치를 공개합니다. 결과가 나쁘게 나와도 그대로 싣는다.", items: [ { label: "히어로까지 JS", value: "0.6%", note: "예산 50KB 중 실제로 쓴 비율", feature: true }, { label: "하드 게이트", value: "12/12", note: "정적 검사와 320px · 1440px 실렌더 양쪽", feature: true }, - { label: "three.js 추가분", value: "184KB", note: "검사대가 화면에 들어올 때만 받는다. 초기 번들에는 없다" }, + { label: "three.js 추가분", value: "184KB", note: "검사대가 화면에 들어올 때만 받습니다. 초기 번들에는 없습니다" }, { label: "슬롭 지문", value: "0건", note: "9개 패턴 grep" }, - { label: "radius 어휘", value: "2종", note: "3px 과 14px. 알약 모양을 없앴다" }, - { label: "반투명 패널", value: "2개", note: "29개까지 늘렸다가 걷어냈다. 유리는 무언가를 담는 액자에만 남겼다" }, + { label: "radius 어휘", value: "2종", note: "3px 과 14px. 알약 모양을 없앴습니다" }, + { label: "반투명 패널", value: "2개", note: "29개까지 늘렸다가 걷어냈습니다. 유리는 무언가를 담는 액자에만 남겼습니다" }, { label: "타입 단계", value: "6단계", note: "토큰에 정의된 수. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" }, - { label: "폰트 패밀리", value: "2개", note: "언어판마다 자기 것만 받는다" }, + { label: "폰트 패밀리", value: "2개", note: "언어판마다 자기 것만 받습니다" }, { label: "강조색", value: "1개", note: "라이트와 다크는 같은 색의 대응값" }, - { label: "첫 인터랙션", value: "미측정", note: "배포 후 Lighthouse 로 잰다" }, + { label: "첫 인터랙션", value: "미측정", note: "배포 후 Lighthouse 로 잽니다" }, ], caveat: - "값은 프로덕션 빌드에서 측정한다. 아직 측정하지 않은 항목은 비워둔다. 지어낸 숫자보다 구멍이 정직하다. 이 페이지는 제품 데모이므로 three.js 를 얹으며 JS 예산을 올렸다. 올렸다는 사실을 여기 적는 것이 규칙이다.", + "값은 프로덕션 빌드에서 측정합니다. 아직 측정하지 않은 항목은 비워둡니다. 지어낸 숫자보다 구멍이 정직하다. 이 페이지는 제품 데모이므로 three.js 를 얹으며 JS 예산을 올렸다. 올렸다는 사실을 여기 적는 것이 규칙이다.", }, preflight: { h2: "이 페이지를 만들며 실제로 걸린 것", - lead: "남을 검사한 결과가 아니라 자신을 검사한 결과다.", + lead: "남을 검사한 결과가 아니라 자신을 검사한 결과입니다.", items: [ { - caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었다", + caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었습니다", fixed: - "웹폰트가 오기 전에는 시스템 폰트로 그려진다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힌다. 폴백 폰트에 size-adjust 로 치수를 맞췄다. 실측 차이 0.0%. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했다.", + "웹폰트가 오기 전에는 시스템 폰트로 그려진다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힙니다. 폴백 폰트에 size-adjust 로 치수를 맞췄다. 실측 차이 0.0%. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했습니다.", }, { - caught: "유리 카드를 29개 뿌렸다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였다", + caught: "유리 카드를 29개 뿌렸습니다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였습니다", fixed: - "우리 규칙이 '카드는 elevation 이 진짜 위계를 나를 때만'이라고 정해두고 있었다. 수치는 조판으로, 규칙은 어긋난 목록으로, 감사 기록은 로그로 바꿨다. 유리는 5개만 남았다.", + "우리 규칙이 '카드는 elevation 이 진짜 위계를 나를 때만'이라고 정해두고 있었습니다. 수치는 조판으로, 규칙은 어긋난 목록으로, 감사 기록은 로그로 바꿨습니다. 유리는 5개만 남았습니다.", }, { - caught: "“backdrop-filter 에 SVG 를 얹으면 Chrome 이 버린다” 고 적었는데 사실이 아니었다", + caught: "“backdrop-filter 에 SVG 를 얹으면 Chrome 이 버린다” 고 적었는데 사실이 아니었습니다", fixed: - "한 번 실패하고 브라우저 탓으로 적어뒀다. 다시 재보니 Chrome 151 에서 computed 값이 none 이 아니라 url(#id) 로 살아 있고 배경도 실제로 굴절된다. 진짜 원인은 feImage 의 width/height 를 요소 크기와 맞추지 않은 것이었다 — backdrop-filter 는 필터 이미지를 자동으로 조정하지 않는다. 한 번 실패한 것을 ‘안 되는 것’ 으로 적으면 그 문장이 다음 사람의 상한선이 된다.", + "한 번 실패하고 브라우저 탓으로 적어뒀다. 다시 재보니 Chrome 151 에서 computed 값이 none 이 아니라 url(#id) 로 살아 있고 배경도 실제로 굴절됩니다. 진짜 원인은 feImage 의 width/height 를 요소 크기와 맞추지 않은 것이었습니다 — backdrop-filter 는 필터 이미지를 자동으로 조정하지 않습니다. 한 번 실패한 것을 ‘안 되는 것’ 으로 적으면 그 문장이 다음 사람의 상한선이 됩니다.", }, { - caught: "가로로 444px 넘쳤다. 레이아웃 전체가 어긋나 있었다", + caught: "가로로 444px 넘쳤습니다. 레이아웃 전체가 어긋나 있었습니다", fixed: - "main 에서 그리드 클래스가 빠져 있었다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났다.", + "main 에서 그리드 클래스가 빠져 있었습니다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였습니다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났다.", }, { - caught: "스크롤을 끝까지 내려도 파이프라인 6단계 중 뒤 3개가 안 보였다", + caught: "스크롤을 끝까지 내려도 파이프라인 6단계 중 뒤 3개가 안 보였습니다", fixed: - "scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못한다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다르다. 한 번 보이면 유지되는 방식으로 바꿨다.", + "scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못합니다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다르다. 한 번 보이면 유지되는 방식으로 바꿨습니다.", }, { caught: "다크 밴드 안 버튼의 대비가 2.88:1. 기준은 4.5:1", fixed: - "밴드가 강조색만 반전시키고 그 위 글자색은 흰색 그대로였다. 반전 대응 토큰을 하나 더 두고 함께 바꿨다.", + "밴드가 강조색만 반전시키고 그 위 글자색은 흰색 그대로였습니다. 반전 대응 토큰을 하나 더 두고 함께 바꿨습니다.", }, ], - note: "열세 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 셋이다. 카드가 전형적이라는 말, 폰트를 제대로 다루지 못한다는 말, 그리고 우리 스킬을 진짜로 쓴 게 맞냐는 말.", + note: "열세 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 셋이다. 카드가 전형적이라는 말, 폰트를 제대로 다루지 못합니다는 말, 그리고 우리 스킬을 진짜로 쓴 게 맞냐는 말.", }, typography: { eyebrow: "한글 조판", h2: "라틴 기본값은 한글에서 세 군데가 깨진다", lead: - "영어권 디자인 도구에는 한글 조판 규칙이 없다. 그래서 기본값을 그대로 쓰면 아래 셋이 동시에 무너진다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값이다. 글자도 폭도 폰트도 같다.", - sample: "스페셜티커피의 로스팅프로파일을 구분하려면 산미와 바디를 수치로 봐야 한다.", + "영어권 디자인 도구에는 한글 조판 규칙이 없습니다. 그래서 기본값을 그대로 쓰면 아래 셋이 동시에 무너집니다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값이다. 글자도 폭도 폰트도 같습니다.", + sample: "스페셜티커피의 로스팅프로파일을 구분하려면 산미와 바디를 수치로 봐야 합니다.", demos: [ { id: "break", title: "줄바꿈", badLabel: "기본값", goodLabel: "word-break: keep-all", - note: "기본값은 어절 한가운데를 끊는다. 왼쪽 마지막 줄을 보라 — 한 단어가 두 줄에 걸쳐 있다.", + note: "기본값은 어절 한가운데를 끊는다. 왼쪽 마지막 줄을 보라 — 한 단어가 두 줄에 걸쳐 있습니다.", }, { id: "leading", title: "행간", badLabel: "line-height 1.45", goodLabel: "line-height 1.75", - note: "라틴 표준 1.4~1.5 는 받침이 있는 글자에 좁다. 줄이 서로 붙어 보인다.", + note: "라틴 표준 1.4~1.5 는 받침이 있는 글자에 좁습니다. 줄이 서로 붙어 보입니다.", }, { id: "size", title: "크기", badLabel: "11px", goodLabel: "15px", - note: "라틴에서 11px 은 라벨로 쓴다. 한글은 획이 많아 같은 크기에서 뭉갠다.", + note: "라틴에서 11px 은 라벨로 씁니다. 한글은 획이 많아 같은 크기에서 뭉갠다.", }, ], rules: [ - "word-break: keep-all 없으면 단어가 아무 데서나 잘린다", + "word-break: keep-all 없으면 단어가 아무 데서나 잘립니다", "line-height 1.6에서 1.8 사이. 라틴보다 넉넉하게", - "음수 자간 금지. 한글은 이미 고정폭에 가까워 즉시 뭉개진다", - "한 줄 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 길다", - "한글 폰트를 지정하지 않는 것 자체가 완성도 미달 신호다", + "음수 자간 금지. 한글은 이미 고정폭에 가까워 즉시 뭉개집니다", + "한 줄 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 깁니다", + "한글 폰트를 지정하지 않는 것 자체가 완성도 미달 신호입니다", ], }, rules: { h2: "규칙마다 왜 그런지 적혀 있다", - lead: "근거 없는 금지는 지켜지지 않는다. 사용자가 명시적으로 요구하면 규칙보다 그쪽이 이긴다.", + lead: "근거 없는 금지는 지켜지지 않습니다. 사용자가 명시적으로 요구하면 규칙보다 그쪽이 이깁니다.", items: [ - { rule: "레퍼런스 없이 시작하지 않는다", why: "머릿속 기본값에서 출발하면 결과는 그 카테고리의 평균이 된다." }, - { rule: "강조색은 하나다", why: "두 개가 필요하다고 느끼면 위계 설계가 실패한 것이다." }, - { rule: "대담함은 한 곳에만", why: "두 곳에서 소리치면 둘 다 죽는다." }, - { rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10이다." }, - { rule: "지어낸 수치를 쓰지 않는다", why: "숫자 모양의 구멍은 정직하고, 지어낸 숫자는 신뢰를 깎는다." }, - { rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반이다. 무너지면 이펙트가 구조를 대신하고 있는 것이다." }, + { rule: "레퍼런스 없이 시작하지 않는다", why: "머릿속 기본값에서 출발하면 결과는 그 카테고리의 평균이 됩니다." }, + { rule: "강조색은 하나다", why: "두 개가 필요하다고 느끼면 위계 설계가 실패한 것입니다." }, + { rule: "대담함은 한 곳에만", why: "두 곳에서 소리치면 둘 다 죽습니다." }, + { rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10입니다." }, + { rule: "지어낸 수치를 쓰지 않는다", why: "숫자 모양의 구멍은 정직하고, 지어낸 숫자는 신뢰를 깎습니다." }, + { rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반이다. 무너지면 이펙트가 구조를 대신하고 있는 것입니다." }, ], }, install: { h2: "한 줄이면 된다", - lead: "설치 대상을 감지해 기본 선택해준다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않는다.", + lead: "설치 대상을 감지해 기본 선택해준다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않습니다.", command: "npx designpaca", targets: [ { name: "Claude Code", path: "~/.claude/skills/designpaca/" }, { name: "Codex CLI", path: "~/.codex/skills/designpaca/" }, + { name: "Antigravity", path: ".agents/skills/designpaca/" }, { name: "Cursor", path: ".cursor/rules/designpaca.mdc" }, { name: "Windsurf", path: ".windsurf/rules/designpaca.md" }, { name: "AGENTS.md", path: "블록 주입. 에이전트 무관" }, ], commands: [ - { cmd: "npx designpaca update", what: "최신 스킬로 갱신. 직접 고친 파일은 건너뛴다" }, + { cmd: "npx designpaca update", what: "최신 스킬로 갱신. 직접 고친 파일은 건너뜁니다" }, { cmd: "npx designpaca doctor", what: "설치 상태와 드리프트 진단" }, { cmd: "npx designpaca uninstall", what: "매니페스트 기반 정확한 제거" }, ], @@ -393,7 +394,7 @@ export const ko: Content = { nav: { items: [ { label: "파이프라인", href: "#pipeline" }, - { label: "사례", href: "#showcase" }, + { label: "사례", href: "/showcase" }, { label: "수치", href: "#metrics" }, { label: "설치", href: "#install" }, ], @@ -646,6 +647,7 @@ export const en: Content = { targets: [ { name: "Claude Code", path: "~/.claude/skills/designpaca/" }, { name: "Codex CLI", path: "~/.codex/skills/designpaca/" }, + { name: "Antigravity", path: ".agents/skills/designpaca/" }, { name: "Cursor", path: ".cursor/rules/designpaca.mdc" }, { name: "Windsurf", path: ".windsurf/rules/designpaca.md" }, { name: "AGENTS.md", path: "block injection, agent agnostic" }, @@ -659,7 +661,7 @@ export const en: Content = { nav: { items: [ { label: "Pipeline", href: "#pipeline" }, - { label: "Work", href: "#showcase" }, + { label: "Work", href: "/showcase" }, { label: "Numbers", href: "#metrics" }, { label: "Install", href: "#install" }, ], diff --git a/apps/site/src/pages/en/index.astro b/apps/site/src/pages/en/index.astro index ddd60a7..215c9a0 100644 --- a/apps/site/src/pages/en/index.astro +++ b/apps/site/src/pages/en/index.astro @@ -2,7 +2,6 @@ import Base from "../../layouts/Base.astro"; import Hero from "../../components/Hero.astro"; import Pipeline from "../../components/Pipeline.astro"; -import Showcase from "../../components/Showcase.astro"; import Materials from "../../components/Materials.astro"; import Metrics from "../../components/Metrics.astro"; import Preflight from "../../components/Preflight.astro"; @@ -20,7 +19,7 @@ import { en as c } from "../../i18n/content"; ① 무엇인가 히어로 · 파이프라인 ② 무엇을 아는가 SVG 필터 · 한글 조판 - ③ 정말 그런가 사례 · 수치 · 자기 감사 · 규칙 + ③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다) ④ 행동 설치 */} @@ -29,7 +28,6 @@ import { en as c } from "../../i18n/content"; - diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index bf9bc50..11e762f 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -2,7 +2,6 @@ import Base from "../layouts/Base.astro"; import Hero from "../components/Hero.astro"; import Pipeline from "../components/Pipeline.astro"; -import Showcase from "../components/Showcase.astro"; import Materials from "../components/Materials.astro"; import Metrics from "../components/Metrics.astro"; import Preflight from "../components/Preflight.astro"; @@ -20,7 +19,7 @@ import { ko as c } from "../i18n/content"; ① 무엇인가 히어로 · 파이프라인 ② 무엇을 아는가 SVG 필터 · 한글 조판 - ③ 정말 그런가 사례 · 수치 · 자기 감사 · 규칙 + ③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다) ④ 행동 설치 */} @@ -29,7 +28,6 @@ import { ko as c } from "../i18n/content"; - diff --git a/apps/site/src/pages/showcase.astro b/apps/site/src/pages/showcase.astro new file mode 100644 index 0000000..a0708cd --- /dev/null +++ b/apps/site/src/pages/showcase.astro @@ -0,0 +1,256 @@ +--- +import { Image } from "astro:assets"; +import Base from "../layouts/Base.astro"; +import BgPhoto from "../components/BgPhoto.astro"; +import { WORK } from "../data/work.ts"; + +/* + 쇼케이스. 소개 페이지 안의 섹션이 아니라 **별도 페이지**다. + + 홈에 있던 예전 섹션은 스크린샷 두 장을 카드에 넣고 각도를 살짝 준 것이었고, + 들어갈 곳이 없었다 — 그림만 보여주고 끝나는 자리는 증거가 아니라 장식이다. + 여기서는 타일을 누르면 **실제로 도는 사이트**로 들어간다. + + 타일 크기를 섞는다. 균일한 격자는 이 스킬이 금지하는 형태이고, + 사례마다 중요도가 다른데 같은 크기로 두면 그 차이가 사라진다. +*/ +const title = "사례 · designpaca"; +const description = + "designpaca 파이프라인으로 만든 사이트들. 각 사례의 컨셉과 감수한 리스크, 실측값을 함께 적었습니다. 타일을 누르면 실제 사이트가 열립니다."; +--- + + +
+ +

사례

+

파이프라인을 돌려서 나온 것들

+

+ 모두 0단계부터 6단계까지 그대로 돌려서 나온 결과물입니다. 각 사례마다 + 감수한 리스크를 함께 적었습니다 — 그것이 없으면 안전하지만 잊히는 디자인이 됩니다. +

+

+ 타일을 누르면 실제 사이트가 열립니다. 목업이 아니라 동작하는 페이지입니다. +

+
+ +
+ + +
+ + + diff --git a/apps/site/tools/polite.mjs b/apps/site/tools/polite.mjs new file mode 100644 index 0000000..03516e0 --- /dev/null +++ b/apps/site/tools/polite.mjs @@ -0,0 +1,186 @@ +/** + * 방문자에게 보이는 한국어 본문을 존댓말로 바꾼다. + * + * 제목(h1·h2·name·rule·caught)은 건드리지 않는다 — 단정문이 카피로서 힘을 갖는 자리다. + * 바꾸는 것은 **설명하는 문장**과 **명령형**이다. + * 방문자에게 "~해라" 라고 말하는 화면은 무례하다. + */ +import fs from "node:fs"; + +const FILE = "src/i18n/content.ts"; + +/** 이 키의 값만 바꾼다. 나머지는 그대로 둔다 */ +const TARGET_KEYS = new Set([ + "lead", "note", "how", "problem", "use", "what", "does", "becomes", "made", "alt", "sample", + "why", "fixed", "caught", "caveat", "installNote", "description", "verdict", "caption", +]); + +/** 순서가 중요하다. 긴 패턴을 먼저 잡지 않으면 짧은 것이 먼저 먹는다 */ +const RULES = [ + // ── 명령형 — 방문자에게 명령하지 않는다 ── + [/해라\./g, "하세요."], [/해라$/g, "하세요"], + [/봐라\./g, "보세요."], [/봐라$/g, "보세요"], + [/마라\./g, "마세요."], [/마라$/g, "마세요"], + [/써라\./g, "쓰세요."], [/써라$/g, "쓰세요"], + [/가라\./g, "가세요."], [/골라라/g, "고르세요"], + + // ── 서술 ── + [/하지 않는다/g, "하지 않습니다"], + [/되지 않는다/g, "되지 않습니다"], + [/지 않는다/g, "지 않습니다"], + [/않는다/g, "않습니다"], + [/것이 아니다/g, "것이 아닙니다"], + [/아니다/g, "아닙니다"], + [/것이다/g, "것입니다"], + [/뿐이다/g, "뿐입니다"], + [/때문이다/g, "때문입니다"], + [/없어진다/g, "없어집니다"], + [/사라진다/g, "사라집니다"], + [/달라진다/g, "달라집니다"], + [/남는다/g, "남습니다"], + [/걸린다/g, "걸립니다"], + [/나온다/g, "나옵니다"], + [/들어간다/g, "들어갑니다"], + [/올라간다/g, "올라갑니다"], + [/떨어진다/g, "떨어집니다"], + [/무너진다/g, "무너집니다"], + [/바뀐다/g, "바뀝니다"], + [/이어진다/g, "이어집니다"], + [/생긴다/g, "생깁니다"], + [/보인다/g, "보입니다"], + [/읽힌다/g, "읽힙니다"], + [/잡힌다/g, "잡힙니다"], + [/쓰인다/g, "쓰입니다"], + [/맡긴다/g, "맡깁니다"], + [/만든다/g, "만듭니다"], + [/막는다/g, "막습니다"], + [/줄인다/g, "줄입니다"], + [/센다/g, "셉니다"], + [/짠다/g, "짭니다"], + [/뺀다/g, "뺍니다"], + [/둔다/g, "둡니다"], + [/한다/g, "합니다"], + [/된다/g, "됩니다"], + [/있다/g, "있습니다"], + [/없다/g, "없습니다"], + [/같다/g, "같습니다"], + [/좁다/g, "좁습니다"], + [/넓다/g, "넓습니다"], + [/맞다/g, "맞습니다"], + [/낫다/g, "낫습니다"], + [/멈춘다/g, "멈춥니다"], + [/이긴다/g, "이깁니다"], + [/모른다/g, "모릅니다"], + [/받는다/g, "받습니다"], + [/걸었다/g, "걸었습니다"], + [/바꿨다/g, "바꿨습니다"], + [/했다/g, "했습니다"], + [/였다/g, "였습니다"], + [/었다/g, "었습니다"], + [/았다/g, "았습니다"], + [/린다/g, "립니다"], + [/난다/g, "납니다"], + [/쓴다/g, "씁니다"], + [/본다/g, "봅니다"], + [/간다/g, "갑니다"], + [/온다/g, "옵니다"], + [/골라준다/g, "골라줍니다"], + [/고른다/g, "고릅니다"], + [/넘긴다/g, "넘깁니다"], + [/채운다/g, "채웁니다"], + [/압축한다/g, "압축합니다"], + [/판정한다/g, "판정합니다"], + [/정한다/g, "정합니다"], + [/확정한다/g, "확정합니다"], + [/구분한다/g, "구분합니다"], + [/합성한다/g, "합성합니다"], + [/내린다/g, "내립니다"], + [/열린다/g, "열립니다"], + [/붙는다/g, "붙습니다"], + [/넣는다/g, "넣습니다"], + [/놓는다/g, "놓습니다"], + [/찾는다/g, "찾습니다"], + [/맞춘다/g, "맞춥니다"], + [/갖춘다/g, "갖춥니다"], + [/낮춘다/g, "낮춥니다"], + [/높인다/g, "높입니다"], + [/모은다/g, "모읍니다"], + [/버린다/g, "버립니다"], + [/살린다/g, "살립니다"], + [/시킨다/g, "시킵니다"], + [/지킨다/g, "지킵니다"], + [/적는다/g, "적습니다"], + [/삼는다/g, "삼습니다"], + [/남긴다/g, "남깁니다"], + [/옮긴다/g, "옮깁니다"], + [/뒤집는다/g, "뒤집습니다"], + [/따른다/g, "따릅니다"], + [/부른다/g, "부릅니다"], + [/누른다/g, "누릅니다"], + [/자른다/g, "자릅니다"], + [/오른다/g, "오릅니다"], + [/미룬다/g, "미룹니다"], + [/켠다/g, "켭니다"], + [/편다/g, "폅니다"], + [/센다/g, "셉니다"], + [/건다/g, "겁니다"], + [/푼다/g, "풉니다"], + [/꾼다/g, "꿉니다"], + [/띈다/g, "띕니다"], + [/진다/g, "집니다"], + [/킨다/g, "킵니다"], + [/힌다/g, "힙니다"], + [/빈다/g, "빕니다"], + [/친다/g, "칩니다"], + [/잘라낸다/g, "잘라냅니다"], + [/잘린다/g, "잘립니다"], + [/뭉개진다/g, "뭉개집니다"], + [/건너뛴다/g, "건너뜁니다"], + [/깎는다/g, "깎습니다"], + [/죽는다/g, "죽습니다"], + [/잰다/g, "잽니다"], + [/낸다/g, "냅니다"], + [/긴다/g, "깁니다"], + [/없앴다/g, "없앴습니다"], + [/걷어냈다/g, "걷어냈습니다"], + [/남겼다/g, "남겼습니다"], + [/뿌렸다/g, "뿌렸습니다"], + [/넘쳤다/g, "넘쳤습니다"], + [/늘렸다/g, "늘렸습니다"], + [/신호다/g, "신호입니다"], + [/결과다/g, "결과입니다"], + [/길다/g, "깁니다"], + [/그대로였다/g, "그대로였습니다"], + [/보였다/g, "보였습니다"], + [/튀고 있었다/g, "튀고 있었습니다"], + [/어긋나 있었다/g, "어긋나 있었습니다"], + [/적었는데/g, "적었는데"], +]; + +function polite(text) { + let out = text; + for (const [re, to] of RULES) out = out.replace(re, to); + return out; +} + +const src = fs.readFileSync(FILE, "utf8"); +// STAGES_KO 는 `export const ko` 밖에 따로 선언돼 있다. 범위를 거기서 시작하지 않으면 +// 파이프라인 여섯 단계의 설명이 통째로 빠진다 — 실측에서 37개가 반말로 남아 있었다. +const koStart = src.indexOf("const STAGES_KO"); +const koEnd = src.indexOf("const STAGES_EN"); +const koStart2 = src.indexOf("export const ko"); +const koEnd2 = src.indexOf("export const en"); +let ko = src.slice(koStart, koEnd); +let ko2 = src.slice(koStart2, koEnd2); + +let changed = 0; +// `key: "..."` 와 `key:\n "..."` 를 모두 잡는다 +ko = ko.replace(/(\b[a-zA-Z]+):(\s*)"((?:[^"\\]|\\.)*)"/g, (m, key, gap, val) => { + if (!TARGET_KEYS.has(key)) return m; + const next = polite(val); + if (next !== val) changed++; + return `${key}:${gap}"${next}"`; +}); + +fs.writeFileSync(FILE, src.slice(0, koStart) + ko + src.slice(koEnd), "utf8"); +console.log(`존댓말 변환: ${changed}개 문자열`); diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 0000000..c1774eb --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,8 @@ +# designpaca + +## 0.5.0 + +### Minor Changes + +- 8d02820: 어떤 하네스 에이전트든 스킬을 발견하게 만든다. 중립 경로 `agents` 타깃(`~/.agents/skills` · `.agents/skills`)을 새로 추가한다 — Cursor · Antigravity · ZCode 등 Agent Skills 규약을 읽는 하네스가 함께 잡는 자리다. `gemini-cli`(`.gemini/skills`)와 `copilot`(`.github/skills` · `~/.copilot/skills`) 타깃을 추가한다. Cursor 는 구식 `.cursor/rules/*.mdc` 변환을 버리고 네이티브 `.cursor/skills` 로 옮기며 전역 설치를 지원한다. Windsurf 도 규칙 파일(12,000자 상한 때문에 실제 스킬이 아예 설치되지 못했다)에서 `.windsurf/skills` · `~/.codeium/windsurf/skills` 네이티브 경로로 옮긴다. Antigravity 전역 경로를 공식 마이그레이션 문서대로 `~/.gemini/antigravity-cli/skills` 로 바로잡는다. +- 8d02820: ZCode 타깃 추가 — `~/.zcode/skills/designpaca/` (전역) 또는 `.zcode/skills/designpaca/` (프로젝트) 에 스킬을 설치한다. SKILL.md 규약이 Claude Code 와 같아 변환 없이 그대로 복사된다. diff --git a/packages/cli/README.md b/packages/cli/README.md index 19ea25d..18d8cba 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,8 @@ # designpaca -**웹 디자인 파이프라인 스킬을 Claude Code · Codex · Cursor 에 한 줄로 설치한다.** +**웹 디자인 파이프라인 스킬을 어떤 에이전트 하네스에든 한 줄로 설치한다.** + +Agent Skills 규약(SKILL.md)을 그대로 따르므로, 규약을 읽는 하네스라면 종류를 가리지 않는다. ```bash npx designpaca @@ -53,11 +55,19 @@ npx designpaca install -t claude-code,codex -s user -y npx designpaca install -t cursor -s project ``` +모든 타깃이 같은 SKILL.md 를 쓴다. 중립 경로(`agents`)는 이 규약을 읽는 하네스라면 무엇이든 잡는다. + | 대상 | 설치 위치 | |---|---| | `claude-code` | `~/.claude/skills/designpaca/` (또는 프로젝트 `.claude/`) | -| `codex` | `~/.codex/skills/designpaca/` | -| `cursor` | `.cursor/rules/designpaca.mdc` (프로젝트 단위) | +| `codex` | `~/.codex/skills/designpaca/` (또는 프로젝트 `.codex/`) | +| `cursor` | `~/.cursor/skills/designpaca/` (또는 프로젝트 `.cursor/`) | +| `zcode` | `~/.zcode/skills/designpaca/` (또는 프로젝트 `.zcode/`) | +| `agents` | `~/.agents/skills/designpaca/` (또는 프로젝트 `.agents/`) — **중립 경로** | +| `antigravity` | 전역 `~/.gemini/antigravity-cli/skills/` · 워크스페이스 `.agents/skills/` | +| `gemini-cli` | `~/.gemini/skills/designpaca/` (또는 프로젝트 `.gemini/`) | +| `windsurf` | `~/.codeium/windsurf/skills/designpaca/` (또는 프로젝트 `.windsurf/`) | +| `copilot` | `~/.copilot/skills/designpaca/` (또는 프로젝트 `.github/skills/`) | | `agents-md` | `AGENTS.md` 에 블록 주입 | ## 사용 @@ -95,7 +105,7 @@ npx designpaca update --force | 옵션 | 설명 | |---|---| -| `-t, --target ` | `claude-code` `codex` `cursor` `agents-md` | +| `-t, --target ` | `claude-code` `codex` `cursor` `zcode` `agents` `antigravity` `gemini-cli` `windsurf` `copilot` `agents-md` | | `-s, --scope <범위>` | `user` (전역) \| `project` | | `-y, --yes` | 확인 없이 진행 | | `-f, --force` | 직접 수정한 파일도 덮어쓴다 (`.orig` 백업) | diff --git a/packages/cli/package.json b/packages/cli/package.json index fd9903d..9817a4d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,16 +1,35 @@ { "name": "designpaca", - "version": "0.1.0", + "version": "0.5.0", "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", "author": "Yun Chan", "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", - "bin": { "designpaca": "./dist/index.js" }, - "files": ["dist", "README.md"], - "engines": { "node": ">=20.11" }, + "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", diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 66f1e0c..6a7b7fb 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -18,7 +18,7 @@ export async function runDoctor(): Promise { if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`)); if (manifest.installs.length === 0) { - console.log(warn("설치된 곳이 없다. `npx designpaca install` 로 설치해라.")); + console.log(warn("아직 설치된 곳이 없습니다. `npx designpaca install` 로 설치하실 수 있습니다.")); return 0; } @@ -40,7 +40,7 @@ export async function runDoctor(): Promise { if (rec.version !== version) problems++; if (missing.length > 0) { 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)}`)); } if (modified.length > 0) { diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 5d717e5..34ed3b4 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -60,7 +60,7 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void const verb = dryRun ? "쓸 파일" : "설치됨"; console.log(ok(`${o.target} — ${verb} ${o.written}개 ${dim(tildify(o.root))}`)); 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)}`)); } } @@ -70,16 +70,41 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void export function printNextSteps(targets: TargetId[]): void { console.log(heading("다음 단계")); if (targets.includes("claude-code")) { - console.log(info(`Claude Code 를 새로 열고 ${dim("/designpaca")} 를 실행해라`)); + console.log(info(`Claude Code 를 새로 연 뒤 ${dim("/designpaca")} 를 실행해 주세요`)); } if (targets.includes("codex")) { console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`)); } + if (targets.includes("zcode")) { + console.log(info(`ZCode 는 새 세션부터 designpaca 스킬이 자동으로 잡힌다`)); + } if (targets.includes("cursor")) { - console.log(info(`Cursor 는 ${dim(".cursor/rules/designpaca.mdc")} 를 프로젝트 열 때 읽는다`)); + console.log(info(`Cursor 는 새 창을 열 때 ${dim(".cursor/skills/designpaca")} 스킬을 읽는다`)); + } + if (targets.includes("agents")) { + console.log( + info( + `${dim(".agents/skills")} 는 중립 경로다 — Cursor · Antigravity · ZCode 등 규약을 읽는 하네스가 함께 잡는다`, + ), + ); + } + if (targets.includes("antigravity")) { + console.log( + info(`Antigravity 는 전역 ${dim("~/.gemini/antigravity-cli/skills")} 를 읽는다 (워크스페이스는 .agents/skills)`), + ); + } + if (targets.includes("gemini-cli")) { + console.log(info(`Gemini CLI 는 ${dim("~/.gemini/skills")} 에서 스킬을 읽는다`)); } if (targets.includes("windsurf")) { - console.log(info(`Windsurf 는 ${dim(".windsurf/rules/designpaca.md")} 를 읽는다`)); + console.log( + info(`Windsurf(Cascade) 는 ${dim(".windsurf/skills/designpaca")} 스킬을 읽는다`), + ); + } + if (targets.includes("copilot")) { + console.log( + info(`GitHub Copilot 은 ${dim(".github/skills")} (~/.copilot/skills) 의 SKILL.md 를 읽는다`), + ); } if (targets.includes("agents-md")) { console.log( diff --git a/packages/cli/src/commands/tools.ts b/packages/cli/src/commands/tools.ts new file mode 100644 index 0000000..d71e996 --- /dev/null +++ b/packages/cli/src/commands/tools.ts @@ -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 { + 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; +} diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts index 26aba00..fbe20c8 100644 --- a/packages/cli/src/commands/uninstall.ts +++ b/packages/cli/src/commands/uninstall.ts @@ -11,7 +11,7 @@ export async function runUninstall(opts: { ); if (targets.length === 0) { - console.log(warn("제거할 설치 기록이 없다")); + console.log(warn("제거할 설치 기록이 없습니다")); return 0; } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index aeb3014..7f5980b 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -12,7 +12,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise const manifest = await readManifest(); if (manifest.installs.length === 0) { - console.log(warn("설치 기록이 없다. `npx designpaca install` 을 먼저 실행해라.")); + console.log(warn("설치 기록이 없습니다. `npx designpaca install` 을 먼저 실행해 주세요.")); return 1; } @@ -20,7 +20,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise const stale = manifest.installs.filter((i) => i.version !== version); if (stale.length === 0) { - console.log(ok("모든 설치가 이미 최신이다")); + console.log(ok("설치된 스킬이 모두 최신 버전입니다")); // 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다. let broken = 0; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bd0d5a6..7855ce1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ 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 { runTools } from "./commands/tools.ts"; import { runUpdate } from "./commands/update.ts"; import { runUninstall } from "./commands/uninstall.ts"; import { runList } from "./commands/list.ts"; @@ -70,14 +71,15 @@ ${bold("사용법")} npx designpaca uninstall 제거 npx designpaca doctor 설치 상태 진단 npx designpaca list 설치 가능 대상과 현재 설치 목록 + npx designpaca tools 브라우저 도구 상태 확인 ${dim("(--yes 로 설치)")} ${bold("옵션")} -t, --target 설치 대상: ${VALID_TARGETS.join(", ")} -s, --scope <범위> user | project ${dim("(기본: user)")} - -y, --yes 확인 없이 진행 - -f, --force 직접 수정한 파일도 덮어쓴다 ${dim("(.orig 로 백업)")} - --dry-run 쓰지 않고 계획만 출력 - --no-update-check 새 버전 확인을 건너뛴다 + -y, --yes 확인 없이 진행합니다 + -f, --force 직접 수정한 파일도 덮어씁니다 ${dim("(.orig 로 백업)")} + --dry-run 쓰지 않고 계획만 보여줍니다 + --no-update-check 새 버전 확인을 건너뜁니다 -h, --help 이 도움말 -v, --version 버전 @@ -85,6 +87,7 @@ ${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")} + ${dim("npx designpaca tools --yes")} `.trimStart(); } @@ -146,12 +149,16 @@ async function main(): Promise { case "doctor": code = await runDoctor(); break; + case "tools": + // 수백 MB 를 받는 일이라 --yes 없이는 상태만 보여준다 + code = await runTools({ install: args.yes }); + break; case "list": case "ls": code = await runList(); break; default: - console.error(bad(`알 수 없는 명령: ${args.command}`)); + console.error(bad(`알 수 없는 명령입니다: ${args.command}`)); console.log(usage(version)); return 2; } @@ -160,7 +167,7 @@ async function main(): Promise { const latest = await checkForUpdate(version); if (latest) { console.log( - "\n" + warn(`새 버전 ${bold(`v${latest}`)} 이 있다 — ${dim("npx designpaca@latest update")}`), + "\n" + warn(`새 버전 v${latest} 이 나왔습니다 — ${dim("npx designpaca@latest update")}`), ); } return code; diff --git a/packages/cli/src/tui/onboard.ts b/packages/cli/src/tui/onboard.ts index 4a621be..a9e3013 100644 --- a/packages/cli/src/tui/onboard.ts +++ b/packages/cli/src/tui/onboard.ts @@ -2,6 +2,7 @@ import * as p from "@clack/prompts"; import path from "node:path"; import { ADAPTERS, + checkTools, detectTargets, getAdapter, planInstall, @@ -27,19 +28,21 @@ export async function onboard(): Promise { p.note( [ - "브리프에서 시작해 레퍼런스 조사 · 방향 결정 · 디자인 토큰 ·", - "구현 · 셀프 감사까지 끌고 가는 웹 디자인 파이프라인 스킬.", + "웹 디자인을 순서대로 진행하게 만드는 스킬입니다.", + "브리프 정리 → 레퍼런스 조사 → 방향 결정 → 디자인 토큰 →", + "구현 → 셀프 감사까지, 단계마다 통과 조건을 두고 진행합니다.", "", - `${dim("SVG 필터 · three.js · 인터랙티브 모션을 기본 재료로 쓴다.")}`, + dim("설치하면 에이전트에서 /designpaca 로 부를 수 있습니다."), + dim("스킬 파일만 복사하며, 기존 설정은 건드리지 않습니다."), ].join("\n"), - "무엇을 설치하나", + "designpaca 는 무엇인가요", ); // 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다 const detected = await detectTargets(skill); const targets = await p.multiselect({ - message: "어디에 설치할까?", + message: "어디에 설치할까요?", options: ADAPTERS.map((a) => ({ value: a.id, label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""), @@ -51,10 +54,18 @@ export async function onboard(): Promise { if (p.isCancel(targets)) return cancel(); const wanted = await p.select({ - message: "설치 범위", + message: "어느 범위에 설치할까요?", 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", }); @@ -67,7 +78,7 @@ export async function onboard(): Promise { downgraded .map((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"), ); @@ -91,35 +102,126 @@ export async function onboard(): Promise { ].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(); const s = p.spinner(); - s.start("설치 중"); + s.start("설치하고 있습니다"); const outcomes = []; try { for (const t of targets) { const scope = effectiveScope(t, wanted); - s.message(`설치 중 — ${getAdapter(t).label}`); + s.message(`${getAdapter(t).label} 에 설치하고 있습니다`); outcomes.push(...(await runInstall({ targets: [t], scope }))); } - s.stop("설치 완료"); + s.stop("설치가 끝났습니다"); } catch (err) { - s.stop("설치 실패", 1); + s.stop("설치하지 못했습니다", 1); p.log.error(err instanceof Error ? err.message : String(err)); return 1; } printOutcomes(outcomes); + + await offerTools(); + printNextSteps(targets); - p.outro(`${accent("designpaca")} 준비됨 — 이제 브리프를 던져라`); + p.outro(`${accent("designpaca")} 준비됐습니다 — 만들고 싶은 것을 말씀해 주세요`); return 0; } +/** + * 선택 도구를 제안한다. 스킬 설치가 끝난 뒤에 묻는다 — + * 본 작업을 이것 때문에 붙잡지 않기 위해서다. 건너뛰어도 스킬은 돈다. + */ +export async function offerTools(): Promise { + 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({ + 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 { - p.cancel("설치를 취소했다. 아무것도 바꾸지 않았다."); + p.cancel("설치를 취소했습니다. 아무것도 변경하지 않았습니다."); return 130; } diff --git a/packages/cli/test/tools.test.ts b/packages/cli/test/tools.test.ts new file mode 100644 index 0000000..5f3f461 --- /dev/null +++ b/packages/cli/test/tools.test.ts @@ -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, "도움말에 명령조 표현이 남아 있다"); +}); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..54b7b59 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,8 @@ +# @designpaca/core + +## 0.5.0 + +### Minor Changes + +- 8d02820: 어떤 하네스 에이전트든 스킬을 발견하게 만든다. 중립 경로 `agents` 타깃(`~/.agents/skills` · `.agents/skills`)을 새로 추가한다 — Cursor · Antigravity · ZCode 등 Agent Skills 규약을 읽는 하네스가 함께 잡는 자리다. `gemini-cli`(`.gemini/skills`)와 `copilot`(`.github/skills` · `~/.copilot/skills`) 타깃을 추가한다. Cursor 는 구식 `.cursor/rules/*.mdc` 변환을 버리고 네이티브 `.cursor/skills` 로 옮기며 전역 설치를 지원한다. Windsurf 도 규칙 파일(12,000자 상한 때문에 실제 스킬이 아예 설치되지 못했다)에서 `.windsurf/skills` · `~/.codeium/windsurf/skills` 네이티브 경로로 옮긴다. Antigravity 전역 경로를 공식 마이그레이션 문서대로 `~/.gemini/antigravity-cli/skills` 로 바로잡는다. +- 8d02820: ZCode 타깃 추가 — `~/.zcode/skills/designpaca/` (전역) 또는 `.zcode/skills/designpaca/` (프로젝트) 에 스킬을 설치한다. SKILL.md 규약이 Claude Code 와 같아 변환 없이 그대로 복사된다. diff --git a/packages/core/package.json b/packages/core/package.json index 28b40d3..09ddd50 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@designpaca/core", - "version": "0.1.0", + "version": "0.5.0", "private": true, "description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지", "type": "module", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1fe9b33..98f7958 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,4 +5,5 @@ export * from "./marker.ts"; export * from "./manifest.ts"; export * from "./skill-source.ts"; export * from "./installer.ts"; +export * from "./tools.ts"; export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts"; diff --git a/packages/core/src/targets/agents.ts b/packages/core/src/targets/agents.ts new file mode 100644 index 0000000..e964e76 --- /dev/null +++ b/packages/core/src/targets/agents.ts @@ -0,0 +1,41 @@ +import path from "node:path"; +import { exists } from "../fsx.ts"; +import type { TargetAdapter, TargetContext } from "../types.ts"; +import { anyExists, scopeRoot, skillDirActions } from "./common.ts"; + +/** + * 중립 경로 `.agents/skills` — 특정 제품의 경로가 아니라 Agent Skills 규약 자체의 홈이다. + * + * "스펙은 통일됐는데 경로가 갈렸다"가 현재 생태계의 문제라면, 이 디렉터리가 그 답이다. + * Cursor · Antigravity · ZCode 가 프로젝트와 홈에서 이 경로를 읽고, 같은 규약을 채택하는 + * 하네스가 계속 늘고 있다. 어떤 도구를 쓰는지와 무관하게 **한 번 깔면 다 같이 잡는 자리**다. + * + * 경로: + * 워크스페이스 /.agents/skills/designpaca/ + * 전역 ~/.agents/skills/designpaca/ + */ +export const agents: TargetAdapter = { + id: "agents", + label: "중립 경로 (.agents)", + hint: "~/.agents/skills/designpaca — 이 규약을 읽는 하네스라면 무엇이든 잡는다", + scopes: ["user", "project"], + + async detect(ctx: TargetContext) { + return anyExists([path.join(ctx.home, ".agents"), path.join(ctx.cwd, ".agents")]); + }, + + async plan(ctx: TargetContext) { + const root = scopeRoot( + ctx, + [".agents", "skills", "designpaca"], + [".agents", "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/antigravity.ts b/packages/core/src/targets/antigravity.ts new file mode 100644 index 0000000..494e46f --- /dev/null +++ b/packages/core/src/targets/antigravity.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { exists } from "../fsx.ts"; +import type { TargetAdapter, TargetContext } from "../types.ts"; +import { anyExists, scopeRoot, skillDirActions } from "./common.ts"; + +/** + * Antigravity 와 `.agents/skills` 를 읽는 도구들. + * + * `.agents/skills/` 는 특정 제품의 경로가 아니라 **중립 경로**다. + * Antigravity 가 여기서 스킬을 읽고, 같은 규약을 쓰는 다른 하네스도 늘고 있다 + * (중립 경로 단독 설치는 `agents` 타깃이 담당한다). + * SKILL.md 구조(프론트매터 + references/)가 Claude Code 와 같아서 + * 파일을 그대로 복사하면 된다 — 변환이 필요 없다. + * + * 경로(공식 문서 기준): + * 워크스페이스 /.agents/skills/<이름>/ + * 전역 ~/.gemini/antigravity-cli/skills/<이름>/ + * + * 전역 경로는 Gemini CLI → Antigravity 마이그레이션 문서가 정리한 현재 위치다. + * 예전 문서의 ~/.gemini/config/skills · ~/.gemini/skills 는 레거시다. + * + * 프론트매터 요구사항도 확인했다: `description` 필수, `name` 선택(없으면 폴더명). + * 우리 SKILL.md 는 둘 다 갖고 있다. + */ +export const antigravity: TargetAdapter = { + id: "antigravity", + label: "Antigravity", + hint: "전역 ~/.gemini/antigravity-cli/skills — 워크스페이스는 중립 경로 타깃과 같다", + scopes: ["user", "project"], + + async detect(ctx: TargetContext) { + return anyExists([ + path.join(ctx.home, ".gemini"), + path.join(ctx.home, ".gemini", "antigravity-cli"), + path.join(ctx.cwd, ".agents"), + ]); + }, + + async plan(ctx: TargetContext) { + const root = scopeRoot( + ctx, + [".gemini", "antigravity-cli", "skills", "designpaca"], + [".agents", "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/copilot.ts b/packages/core/src/targets/copilot.ts new file mode 100644 index 0000000..b8057ab --- /dev/null +++ b/packages/core/src/targets/copilot.ts @@ -0,0 +1,42 @@ +import path from "node:path"; +import { exists } from "../fsx.ts"; +import type { TargetAdapter, TargetContext } from "../types.ts"; +import { anyExists, scopeRoot, skillDirActions } from "./common.ts"; + +/** + * GitHub Copilot — ~/.copilot/skills/designpaca/ (전역) 또는 .github/skills/designpaca/ (프로젝트). + * Copilot 코딩 에이전트는 2025-12 부터 Agent Skills 규약을 지원한다. + * + * detect 는 보수적으로 잡는다 — `.github/` 은 Copilot 을 안 쓰는 저장소에도 흔하니까. + * Copilot 사용 흔적(~/.copilot, 기존 .github/skills, copilot-instructions.md)이 있을 때만 + * 기본 선택에 올린다. 명시적으로 `-t copilot` 을 주면 언제든 설치할 수 있다. + */ +export const copilot: TargetAdapter = { + id: "copilot", + label: "GitHub Copilot", + hint: "~/.copilot/skills/designpaca — SKILL.md 그대로", + scopes: ["user", "project"], + + async detect(ctx: TargetContext) { + return anyExists([ + path.join(ctx.home, ".copilot"), + path.join(ctx.cwd, ".github", "skills"), + path.join(ctx.cwd, ".github", "copilot-instructions.md"), + ]); + }, + + async plan(ctx: TargetContext) { + const root = scopeRoot( + ctx, + [".copilot", "skills", "designpaca"], + [".github", "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/cursor.ts b/packages/core/src/targets/cursor.ts index f320638..6a4d20d 100644 --- a/packages/core/src/targets/cursor.ts +++ b/packages/core/src/targets/cursor.ts @@ -1,57 +1,41 @@ 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"; +import type { TargetAdapter, TargetContext } from "../types.ts"; +import { anyExists, scopeRoot, skillDirActions } from "./common.ts"; /** - * Cursor — .cursor/rules/designpaca.mdc. - * .mdc 프론트매터는 SKILL.md 와 필드가 다르다(description/globs/alwaysApply)므로 변환한다. + * Cursor — ~/.cursor/skills/designpaca/ (전역) 또는 .cursor/skills/designpaca/ (프로젝트). * - * Windsurf 는 이 파일을 읽지 않는다(.windsurf/rules/*.md 를 쓴다) — 별도 어댑터로 분리했다. + * Cursor 는 Agent Skills 규약을 네이티브로 읽는다. 시작 시 자기 경로(.cursor/skills)와 + * 중립 경로(.agents/skills)를 살피고, 호환 경로(.claude/skills · .codex/skills)까지 함께 + * 읽는다 — 그래서 SKILL.md 를 변환 없이 그대로 복사하면 끝난다. + * + * 예전엔 .cursor/rules/designpaca.mdc 로 변환했지만 그 접근은 버렸다. 규칙 파일은 + * 프론트매터가 다르고 본문이 통째로 프롬프트에 들어가는 규약이라 설명만 띄우고 + * 본문·references 는 필요할 때 여는 스킬 방식이 맞다. 이제 전역 설치도 된다. */ export const cursor: TargetAdapter = { id: "cursor", label: "Cursor", - hint: ".cursor/rules/designpaca.mdc — 프로젝트 단위", - // Cursor 의 전역 규칙은 파일이 아니라 앱 설정(User Rules)이라 프로젝트 범위만 지원한다 - scopes: ["project"], + hint: "~/.cursor/skills/designpaca — SKILL.md 그대로 (전역 지원)", + scopes: ["user", "project"], async detect(ctx: TargetContext) { - return anyExists([path.join(ctx.cwd, ".cursor"), path.join(ctx.home, ".cursor")]); + return anyExists([path.join(ctx.home, ".cursor"), path.join(ctx.cwd, ".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), - ]; - + const root = scopeRoot( + ctx, + [".cursor", "skills", "designpaca"], + [".cursor", "skills", "designpaca"], + ); return { target: this.id, scope: ctx.scope, - root: rulesDir, - actions, - alreadyInstalled: await exists(mdc), + root, + actions: skillDirActions(root, ctx.skill), + alreadyInstalled: await exists(path.join(root, "SKILL.md")), }; }, }; diff --git a/packages/core/src/targets/gemini-cli.ts b/packages/core/src/targets/gemini-cli.ts new file mode 100644 index 0000000..9abaf26 --- /dev/null +++ b/packages/core/src/targets/gemini-cli.ts @@ -0,0 +1,41 @@ +import path from "node:path"; +import { exists } from "../fsx.ts"; +import type { TargetAdapter, TargetContext } from "../types.ts"; +import { anyExists, scopeRoot, skillDirActions } from "./common.ts"; + +/** + * Gemini CLI — ~/.gemini/skills/designpaca/ (전역) 또는 .gemini/skills/designpaca/ (프로젝트). + * Agent Skills 규약(SKILL.md)을 Claude 와 같은 형태로 그대로 읽는다. + * + * Google 을 Gemini CLI 에서 Antigravity 로 옮기고 있지만 마이그레이션이 끝난 사용자만 + * Antigravity 경로(~/.gemini/antigravity-cli/skills)를 쓴다 — 아직 Gemini CLI 를 쓰는 + * 사람을 위해 이 타깃을 따로 둔다. 둘 다 쓰면 스킬이 두 번 잡히니 하나만 고른다. + */ +export const geminiCli: TargetAdapter = { + id: "gemini-cli", + label: "Gemini CLI", + hint: "~/.gemini/skills/designpaca — SKILL.md 그대로 (Antigravity 전환 전)", + scopes: ["user", "project"], + + async detect(ctx: TargetContext) { + return anyExists([ + path.join(ctx.home, ".gemini"), + path.join(ctx.cwd, ".gemini"), + ]); + }, + + async plan(ctx: TargetContext) { + const root = scopeRoot( + ctx, + [".gemini", "skills", "designpaca"], + [".gemini", "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/index.ts b/packages/core/src/targets/index.ts index a70adfd..b41fb7a 100644 --- a/packages/core/src/targets/index.ts +++ b/packages/core/src/targets/index.ts @@ -2,10 +2,26 @@ import type { TargetAdapter, TargetId } from "../types.ts"; import { claudeCode } from "./claude-code.ts"; import { codex } from "./codex.ts"; import { cursor } from "./cursor.ts"; +import { zcode } from "./zcode.ts"; +import { agents } from "./agents.ts"; +import { antigravity } from "./antigravity.ts"; +import { geminiCli } from "./gemini-cli.ts"; import { windsurf } from "./windsurf.ts"; +import { copilot } from "./copilot.ts"; import { agentsMd } from "./agents-md.ts"; -export const ADAPTERS: TargetAdapter[] = [claudeCode, codex, cursor, windsurf, agentsMd]; +export const ADAPTERS: TargetAdapter[] = [ + claudeCode, + codex, + cursor, + zcode, + agents, + antigravity, + geminiCli, + windsurf, + copilot, + agentsMd, +]; export function getAdapter(id: TargetId): TargetAdapter { const a = ADAPTERS.find((x) => x.id === id); @@ -14,4 +30,15 @@ export function getAdapter(id: TargetId): TargetAdapter { } export { MARKER, rewriteRefPaths } from "./common.ts"; -export { claudeCode, codex, cursor, windsurf, agentsMd }; +export { + claudeCode, + codex, + cursor, + zcode, + agents, + antigravity, + geminiCli, + windsurf, + copilot, + agentsMd, +}; diff --git a/packages/core/src/targets/windsurf.ts b/packages/core/src/targets/windsurf.ts index a4ca7e6..e92d7ca 100644 --- a/packages/core/src/targets/windsurf.ts +++ b/packages/core/src/targets/windsurf.ts @@ -1,66 +1,42 @@ 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; +import type { TargetAdapter, TargetContext } from "../types.ts"; +import { anyExists, scopeRoot, skillDirActions } from "./common.ts"; /** - * Windsurf — .windsurf/rules/designpaca.md. - * Cursor 와 경로·프론트매터가 모두 다르다(trigger/globs, .mdc 아님). + * Windsurf — ~/.codeium/windsurf/skills/designpaca/ (전역) 또는 .windsurf/skills/designpaca/ (프로젝트). + * Cascade 가 Agent Skills 규약(SKILL.md)을 네이티브로 읽는다. * - * trigger 는 model_decision 을 쓴다. always_on 으로 두면 디자인 스킬이 모든 메시지의 - * 시스템 프롬프트에 상주한다. + * 예전 .windsurf/rules/designpaca.md 방식은 규칙 파일에 12,000자 상한이 있어서 + * 25KB 짜리 실제 스킬이 아예 설치되지 못했다 — 네이티브 스킬 경로로 옮기며 풀렸다. + * 스킬은 name/description 만 띄우고 본문은 필요할 때 열기 때문에 크기 제약이 없다. */ export const windsurf: TargetAdapter = { id: "windsurf", label: "Windsurf", - hint: ".windsurf/rules/designpaca.md — 프로젝트 단위", - scopes: ["project"], + hint: "~/.codeium/windsurf/skills/designpaca — SKILL.md 그대로 (전역 지원)", + scopes: ["user", "project"], async detect(ctx: TargetContext) { return anyExists([ path.join(ctx.cwd, ".windsurf"), - path.join(ctx.cwd, ".windsurfrules"), path.join(ctx.home, ".windsurf"), + path.join(ctx.home, ".codeium"), ]); }, 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), - ]; - + const root = scopeRoot( + ctx, + [".codeium", "windsurf", "skills", "designpaca"], + [".windsurf", "skills", "designpaca"], + ); 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 를 줄여야 한다.`, - } - : {}), + root, + actions: skillDirActions(root, ctx.skill), + alreadyInstalled: await exists(path.join(root, "SKILL.md")), }; }, }; diff --git a/packages/core/src/targets/zcode.ts b/packages/core/src/targets/zcode.ts new file mode 100644 index 0000000..d37c89d --- /dev/null +++ b/packages/core/src/targets/zcode.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import { exists } from "../fsx.ts"; +import type { TargetAdapter, TargetContext } from "../types.ts"; +import { anyExists, scopeRoot, skillDirActions } from "./common.ts"; + +/** + * ZCode — ~/.zcode/skills/designpaca/ (전역) 또는 .zcode/skills/designpaca/ (프로젝트). + * ZCode 도 Claude Code 와 같은 SKILL.md 규약을 그대로 읽으므로 변환이 없다. + * + * ZCode 는 중립 경로인 .agents/skills 도 함께 읽지만 그쪽은 agents 타깃이 쓴다. + * 같은 스킬을 두 곳에 깔면 하나가 다른 하나를 섀도잉할 뿐이므로 + * ZCode 전용 경로에만 쓴다. + */ +export const zcode: TargetAdapter = { + id: "zcode", + label: "ZCode", + hint: "~/.zcode/skills/designpaca — SKILL.md 그대로", + scopes: ["user", "project"], + + async detect(ctx: TargetContext) { + return anyExists([ + path.join(ctx.home, ".zcode"), + path.join(ctx.cwd, ".zcode"), + ]); + }, + + async plan(ctx: TargetContext) { + const root = scopeRoot(ctx, [".zcode", "skills", "designpaca"], [".zcode", "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/tools.ts b/packages/core/src/tools.ts new file mode 100644 index 0000000..5c30fd6 --- /dev/null +++ b/packages/core/src/tools.ts @@ -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; + install(): Promise; + /** 설치 후 사람이 직접 해야 하는 것이 있으면 여기에 */ + followUp?: string; +} + +/** npm 전역 루트. Windows 경로에 공백이 있어도 안전하게 얻는다 */ +async function npmRoot(): Promise { + try { + const { stdout } = await exec("npm", ["root", "-g"], { shell: true }); + return stdout.trim() || null; + } catch { + return null; + } +} + +async function exists(p: string): Promise { + 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 { + 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); +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f11c1c1..38af630 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,15 @@ /** 설치 대상 식별자 */ -export type TargetId = "claude-code" | "codex" | "cursor" | "windsurf" | "agents-md"; +export type TargetId = + | "claude-code" + | "codex" + | "cursor" + | "zcode" + | "agents" + | "antigravity" + | "gemini-cli" + | "windsurf" + | "copilot" + | "agents-md"; /** 설치 범위 — user: 홈 디렉터리 전역, project: 현재 프로젝트 */ export type Scope = "user" | "project"; diff --git a/packages/core/test/installer.test.ts b/packages/core/test/installer.test.ts index e070f6e..7ed59e1 100644 --- a/packages/core/test/installer.test.ts +++ b/packages/core/test/installer.test.ts @@ -118,46 +118,118 @@ test("agents-md: 포인터만 주입하고 본문은 별도 디렉터리에 푼 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: 네이티브 스킬 경로에 SKILL.md 그대로 싣는다 (전역·프로젝트)", async () => { + const user = await planInstall("cursor", "user", skill, env); + assert.equal(user.root, path.join(home, ".cursor", "skills", "designpaca")); + const proj = await planInstall("cursor", "project", skill, env); + assert.equal(proj.root, path.join(cwd, ".cursor", "skills", "designpaca")); -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")); + const res = await applyPlan(proj, skill.version, { env }); + const md = await fs.readFile(path.join(proj.root, "SKILL.md"), "utf8"); + assert.match(md, /^---\n/, "프론트매터가 그대로 있어야 한다"); + assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); + await fs.access(path.join(proj.root, "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 }); +test("windsurf: 네이티브 스킬 경로에 싣는다 — 본문 크기 제한이 없다", async () => { + const proj = await planInstall("windsurf", "project", skill, env); + assert.equal(proj.root, path.join(cwd, ".windsurf", "skills", "designpaca")); + const user = await planInstall("windsurf", "user", skill, env); + assert.equal(user.root, path.join(home, ".codeium", "windsurf", "skills", "designpaca")); - 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 () => { + // 예전 규칙 파일 방식은 12,000자 상한 때문에 실제 스킬(본문 25KB)이 막혔다. + // 네이티브 스킬은 본문을 통째로 프롬프트에 넣지 않으므로 큰 본문도 설치돼야 한다. const huge: SkillSource = { ...skill, body: "가".repeat(13_000) }; - const plan = await planInstall("windsurf", "project", huge, env); - assert.ok(plan.blocked, "상한 초과인데 막지 않았다"); - assert.match(plan.blocked, /상한/); + const hugePlan = await planInstall("windsurf", "project", huge, env); + assert.ok(!hugePlan.blocked, "본문이 크다고 막히면 안 된다"); + + const res = await applyPlan(proj, skill.version, { env }); + const md = await fs.readFile(path.join(proj.root, "SKILL.md"), "utf8"); + assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); + await fs.access(path.join(proj.root, "references", "tokens.md")); + + await removeInstall(res.record, { env }); +}); + +test("antigravity: 프로젝트는 .agents/skills, 전역은 ~/.gemini/antigravity-cli/skills", async () => { + // 두 경로가 다른 제품 규약이라 하나만 맞으면 다른 쪽에서 스킬이 안 잡힌다. + const proj = await planInstall("antigravity", "project", skill, env); + assert.equal(proj.root, path.join(cwd, ".agents", "skills", "designpaca")); + + // 전역은 공식 마이그레이션 문서가 정한 현재 경로다 (~/.gemini/config/skills 는 레거시). + const user = await planInstall("antigravity", "user", skill, env); + assert.equal(user.root, path.join(home, ".gemini", "antigravity-cli", "skills", "designpaca")); +}); + +test("antigravity: SKILL.md 를 변환 없이 그대로 싣는다", async () => { + // Claude Code 와 같은 규약이라 프론트매터도 참조 경로도 손대지 않아야 한다. + // 여기서 경로를 재작성하면 references 가 존재하지 않는 곳을 가리킨다. + const plan = await planInstall("antigravity", "project", skill, env); + const res = await applyPlan(plan, skill.version, { env }); + + const md = await fs.readFile(path.join(plan.root, "SKILL.md"), "utf8"); + assert.match(md, /^---\n/, "프론트매터가 있어야 한다"); + assert.match(md, /description:/, "Antigravity 는 description 을 필수로 요구한다"); + assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); + + const ref = await fs.readFile(path.join(plan.root, "references", "tokens.md"), "utf8"); + assert.match(ref, /# 토큰/); + + await removeInstall(res.record, { env }); +}); + +test("agents: 중립 경로 — 전역 ~/.agents/skills, 프로젝트 .agents/skills", async () => { + const user = await planInstall("agents", "user", skill, env); + assert.equal(user.root, path.join(home, ".agents", "skills", "designpaca")); + const proj = await planInstall("agents", "project", skill, env); + assert.equal(proj.root, path.join(cwd, ".agents", "skills", "designpaca")); + + const res = await applyPlan(proj, skill.version, { env }); + const md = await fs.readFile(path.join(proj.root, "SKILL.md"), "utf8"); + assert.match(md, /^---\n/, "프론트매터가 있어야 한다"); + assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); + await fs.access(path.join(proj.root, "references", "tokens.md")); + + await removeInstall(res.record, { env }); + await assert.rejects(() => fs.access(proj.root)); +}); + +test("gemini-cli: 전역 ~/.gemini/skills, 프로젝트 .gemini/skills", async () => { + const user = await planInstall("gemini-cli", "user", skill, env); + assert.equal(user.root, path.join(home, ".gemini", "skills", "designpaca")); + const proj = await planInstall("gemini-cli", "project", skill, env); + assert.equal(proj.root, path.join(cwd, ".gemini", "skills", "designpaca")); +}); + +test("copilot: 전역 ~/.copilot/skills, 프로젝트 .github/skills", async () => { + const user = await planInstall("copilot", "user", skill, env); + assert.equal(user.root, path.join(home, ".copilot", "skills", "designpaca")); + const proj = await planInstall("copilot", "project", skill, env); + assert.equal(proj.root, path.join(cwd, ".github", "skills", "designpaca")); +}); + +test("zcode: 전역은 ~/.zcode/skills, 프로젝트는 .zcode/skills", async () => { + // ZCode 는 .zcode/skills 와 .agents/skills 를 모두 읽지만 중립 경로 쪽은 + // agents 타깃이 쓰는 영역이라 zcode 타깃은 건드리지 않는다. + const user = await planInstall("zcode", "user", skill, env); + assert.equal(user.root, path.join(home, ".zcode", "skills", "designpaca")); + + const proj = await planInstall("zcode", "project", skill, env); + assert.equal(proj.root, path.join(cwd, ".zcode", "skills", "designpaca")); +}); + +test("zcode: SKILL.md 를 변환 없이 그대로 싣는다", async () => { + const plan = await planInstall("zcode", "user", skill, env); + const res = await applyPlan(plan, skill.version, { env }); + + const md = await fs.readFile(path.join(plan.root, "SKILL.md"), "utf8"); + assert.match(md, /^---\n/, "프론트매터가 있어야 한다"); + assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); + await fs.access(path.join(plan.root, "references", "tokens.md")); + await fs.access(path.join(plan.root, ".designpaca_version")); + + await removeInstall(res.record, { env }); + await assert.rejects(() => fs.access(plan.root)); }); diff --git a/packages/skill/CHANGELOG.md b/packages/skill/CHANGELOG.md new file mode 100644 index 0000000..f8a1c6b --- /dev/null +++ b/packages/skill/CHANGELOG.md @@ -0,0 +1,7 @@ +# @designpaca/skill + +## 0.5.0 + +### Minor Changes + +- 8d02820: 어떤 하네스 에이전트든 스킬을 발견하게 만든다. 중립 경로 `agents` 타깃(`~/.agents/skills` · `.agents/skills`)을 새로 추가한다 — Cursor · Antigravity · ZCode 등 Agent Skills 규약을 읽는 하네스가 함께 잡는 자리다. `gemini-cli`(`.gemini/skills`)와 `copilot`(`.github/skills` · `~/.copilot/skills`) 타깃을 추가한다. Cursor 는 구식 `.cursor/rules/*.mdc` 변환을 버리고 네이티브 `.cursor/skills` 로 옮기며 전역 설치를 지원한다. Windsurf 도 규칙 파일(12,000자 상한 때문에 실제 스킬이 아예 설치되지 못했다)에서 `.windsurf/skills` · `~/.codeium/windsurf/skills` 네이티브 경로로 옮긴다. Antigravity 전역 경로를 공식 마이그레이션 문서대로 `~/.gemini/antigravity-cli/skills` 로 바로잡는다. diff --git a/packages/skill/SKILL.md b/packages/skill/SKILL.md index 0c2fec3..65dc7ce 100644 --- a/packages/skill/SKILL.md +++ b/packages/skill/SKILL.md @@ -88,7 +88,77 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬. **애매하면 긴 쪽으로.** 단 국소 조건에 해당하면 국소로 가라 — 버튼 하나에 갤러리 3곳을 여는 것은 사용자가 이 스킬을 끄게 만든다. **국소로 시작했다가 조건이 깨지면 멈추고 올린다.** 토큰을 새로 정의하게 됐거나, 손댄 섹션이 3개를 넘었거나, 방향을 바꿔야 하면. **올렸다고 말해라. 조용히 국소에 머무는 것이 이 스킬의 최대 실패다.** -**막혔을 때**: 브리프가 비어 있으면 추측하지 말고 물어라. 단 **한 번에 다 묻지 마라.** 결과를 가장 크게 바꾸는 것 하나만 묻고, 나머지는 가정을 명시하고 진행한다. +**브리프가 비어 있으면 인터뷰해라. 가정으로 채우지 마라.** + +이 단계에서 추측한 것은 전부 기본값이고, 기본값의 총합이 슬롭이다. +실측 사례 — "꽃집 홍보 사이트 하나 만들어보자"를 받고 업종 성격·목표 행동·톤·이름을 +전부 혼자 정했다. 실제로 물어보니 **넷 중 넷이 달랐다.** + +| 내가 가정한 것 | 사용자의 실제 답 | +|---|---| +| 일상 꽃 · 정기구독 | **하이엔드 플로럴 스튜디오** | +| 문의 유도 하나 | 문의 · 구독 · 방문 · 인스타 **넷 다** | +| (묻지 않음) | **에디토리얼 · 잡지** | +| (내가 지어냄) | **목요일의 화원** | + +이 상태로 1단계에 들어갔으면 **레퍼런스 세 개를 전부 틀린 방향에서 골랐을 것이다.** + +**질문 도구(AskUserQuestion)로 한 번에 묻는다.** 하나씩 캐물으면 사용자가 지친다 — +결과를 크게 바꾸는 축을 골라 **선택지와 함께** 한 화면에 낸다. 각 선택지에는 +"이걸 고르면 무엇이 달라지는지"를 적어라. 고르는 사람이 결과를 예상할 수 있어야 한다. + +거의 모든 브리프에서 다음이 결과를 가장 크게 바꾼다. **위 넷은 거의 항상 묻는다.** + +| 축 | 무엇이 달라지는가 | 안 물으면 | +|---|---|---| +| **업종·성격** | 정보 구조 전체. 같은 "꽃집"도 구독형과 하이엔드 스튜디오는 다른 사이트다 | 카테고리 평균이 나온다 | +| **목표 행동** | CTA 의 수와 위치, 어떤 섹션이 필요하고 어떤 게 군더더기인지 | CTA 가 넷이 되고 페이지가 무너진다 | +| **톤** | 2단계 프리셋과 감수할 리스크가 여기서 결정된다 | 내 기본 미학이 나온다 | +| **고유명사·실제 값** | 이름·지역·가격·연락처 | 지어내면 하드 게이트 11 | +| **기존 브랜드 색·로고** | 있으면 3단계 팔레트가 **거기서 시작한다** | 있는 자산을 무시하고 새로 만든다 | +| **좁은 화면의 내비** | 항목 5개 이상이면 구조가 달라진다 | 넓은 화면 메뉴를 그대로 접어 두 줄이 된다 | + +### 색은 반드시 확인한다 — 없다는 답도 답이다 + +**"브랜드 색이 있나요"** 는 결과를 되돌릴 수 없게 바꾸는 질문이다. +이미 로고·간판·패키지에 색이 있는데 사이트만 다른 색이면, 잘 만들어도 **틀린 것**이다. + +- **있다** → 3단계에서 그 색이 강조색이고, 레퍼런스는 그 색과 어울리는 곳으로 고른다 +- **없다·상관없다** → 레퍼런스 실측에서 뽑는다. 이때도 **어떤 색을 피해야 하는지**는 물어라 + (경쟁사가 쓰는 색, 대표가 싫어하는 색은 사용자만 안다) + +> 실측 사례: 꽃집 작업에서 색을 묻지 않고 레퍼런스 세 곳의 배경 평균(`#F8F6F0`)으로 정했다. +> 결과는 좋았지만 **운이 좋았던 것**이다. 브랜드 색이 있었다면 그걸 무시한 작업이 된다. + +### 좁은 화면의 내비를 미리 정한다 + +내비 항목 수는 0단계에서 이미 정해진다(= 섹션 수). **구현 단계에서 발견하면 늦다.** + +| 항목 수 | 좁은 화면 처리 | 비용 | +|---|---|---| +| 2~4개 | 그대로 한 줄. 자간을 줄이고 `justify-content: space-between` | 0 | +| 5~6개 | 가로 스크롤 줄 또는 하단 고정 바 | 낮음 | +| 7개 이상 | **여는 메뉴**(햄버거) | `
` 로 JS 0바이트 | + +**햄버거를 기본값으로 삼지 마라.** 항목이 넷인데 햄버거를 쓰면 한 번의 탭을 공짜로 뺏는 것이다. +반대로 여섯 개를 한 줄에 욱여넣으면 접힌다 — 실측에서 링크가 두 줄(top 46/77)에 걸렸다. + +여는 메뉴가 필요하다고 판단되면 `references/layout.md` 의 **모바일 내비** 절을 본다. + +넷을 `multiSelect` 로 낼지 단일 선택으로 낼지 구분해라 — **목표 행동은 대개 복수**고, +톤과 성격은 하나여야 한다. 둘 다 고르면 방향이 서지 않는다. + +**물어도 되는 것과 물으면 안 되는 것** + +- 묻는다: 결과를 바꾸는 결정(위 표), 사용자만 아는 사실(실제 수치·이름·재고) +- 묻지 않는다: 검색하면 나오는 것, 코드를 읽으면 아는 것, 관례가 명확한 것 + +**답이 모순되면 그 자리에서 정리해라.** 위 사례에서 목표 행동 넷이 다 선택됐는데, +하이엔드 스튜디오에서 "정기구독"과 "문의 상담"은 성격이 다르다. +**우선순위를 제안하고 확인받는다** — 넷을 같은 무게로 놓으면 CTA 가 넷이 되고 페이지가 무너진다. + +예외: 사용자가 "알아서 해줘"라고 명시했거나, 되돌리기 쉬운 습작이면 가정하고 진행해도 된다. +**단 가정한 항목을 목록으로 말하고, 6단계 `design.md` 의 미확정 목록에 올린다.** **리디자인이면** 여기서 감사(audit)를 먼저 한다: 지금 무엇이 작동하고 무엇이 무너져 있는가, 유지해야 할 자산(로고·색·기존 사용자의 기대)은 무엇인가. 감사 없는 리디자인은 파괴다. @@ -115,6 +185,11 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된 **갤러리 목록 페이지가 아니라 원본 사이트를 열어라.** 이미지를 볼 수 없어도 구조는 읽을 수 있다. +**403·404·타임아웃을 두 번 만나면 텍스트 페처를 버리고 브라우저를 띄워라.** +좋은 레퍼런스일수록 봇을 막는다. 대체 갤러리를 찾아 헤매는 것은 시간 낭비이고, +찾아낸 대체 소스는 애초에 보려던 것보다 나쁘다. `designpaca tools` 로 설치된 +headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다 — 방법은 `galleries.md` §5. + > 통과 조건: R1/R2/R3 각각의 URL과, 그것에서 **무엇을 가져올지** 한 줄씩. 형식은 `reference-method.md` 참조. --- @@ -164,6 +239,12 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된 순서가 있다. **레이아웃 → 재질 → 모션.** 거꾸로 가면 화려한데 읽을 수 없는 페이지가 나온다. +**4-0. 이미지 조달** → `references/images.md` +자리를 만들기 전에 **무엇을 실을지** 정한다. 나중에 채우면 비율이 안 맞아 레이아웃을 다시 짠다. +사용자가 준 사진이 있으면 무조건 그것이고, 없으면 **codex 가 설치돼 있는지 확인해 생성**한다. +생성했으면 `design.md` 에 생성물이라고 적는다 — 나중에 실제 사진으로 바꿀 사람이 알아야 한다. +싣기 전에 반드시 줄인다(실측: 10.1MB → 603KB). + **4-1. 레이아웃과 타이포그래피** → `references/layout.md` (+ 폰트 적용은 `references/typography.md` §5, §6) 그리드, 여백 리듬, 시선 흐름. 3단계의 토큰을 그대로 쓴다. 이 단계가 끝나면 **아무 이펙트 없이도 완성된 페이지**여야 한다. 이것이 모든 폴백의 기반이다. @@ -261,6 +342,7 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는 | `references/presets/README.md` | 2단계 — 미학 방향을 고를 때 (고른 프리셋 하나만 추가로 읽는다) | | `references/tokens.md` | 3단계 — 토큰을 정할 때 | | `references/typography.md` | 3단계 — 폰트를 고를 때 / 4-1 — 적용할 때 | +| `references/images.md` | **4-0 — 사진을 구하고 최적화할 때** | | `references/layout.md` | 4-1 — 그리드와 타이포 | | `references/svg-filters.md` | 4-2 — 재질을 만들 때 | | `references/three.md` | 4-3 — 입체가 필요할 때 | diff --git a/packages/skill/package.json b/packages/skill/package.json index 5b4fab1..9767eda 100644 --- a/packages/skill/package.json +++ b/packages/skill/package.json @@ -1,6 +1,6 @@ { "name": "@designpaca/skill", - "version": "0.1.0", + "version": "0.5.0", "private": true, "description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서", "scripts": { diff --git a/packages/skill/references/antipatterns.md b/packages/skill/references/antipatterns.md index 27a7e26..ed1bfab 100644 --- a/packages/skill/references/antipatterns.md +++ b/packages/skill/references/antipatterns.md @@ -328,7 +328,29 @@ const s = await sharp(buf).stats(); // 잘라낸 버퍼를 다시 물려야 > 지나가는 프레임은 방문자도 놓치고 측정도 매번 다른 값을 준다. > 정지된 before/after 두 장이 더 정확하게 말한다. -**④ 눈으로 의심한 것이 실측에서 뒤집힐 수 있다.** +**④ 개별 요소의 높이로는 "여러 줄에 걸친 것" 을 못 잡는다.** +"컨트롤이 2줄로 접히는가"(하드 게이트 6)를 이렇게 쟀다. + +```js +// 틀렸다 — 이 검사는 "접힘 0" 이라고 답한다 +[...document.querySelectorAll('.nav a')] + .filter(e => e.getBoundingClientRect().height > lineHeight * 1.8) +``` + +각 링크는 한 줄짜리라 높이가 정상이었다. **줄바꿈된 것은 링크가 아니라 컨테이너였다** — +링크들이 두 행(top 46 / 77)에 나뉘어 놓여 있었다. 게이트를 통과했다고 보고했고, +사용자가 화면을 보고 잡아냈다. + +```js +// 맞다 — 행이 몇 개인지는 top 값의 종류로 센다 +new Set([...document.querySelectorAll('.nav a')] + .map(e => Math.round(e.getBoundingClientRect().top))).size +``` + +같은 함정이 갤러리·태그 목록·버튼 그룹 어디에나 있다. +**"몇 줄인가" 를 물을 때는 높이가 아니라 위치를 세라.** + +**⑤ 눈으로 의심한 것이 실측에서 뒤집힐 수 있다.** WebGL 판이 본문 뒤를 지나가는 화면을 보고 "대비가 죽었다" 고 판단해 캔버스를 어둡게 만들려 했다. 스크린샷에서 글자 사이 빈 띠의 배경 휘도를 재보니 최악 지점이 **9.83:1** — AAA(7:1)를 넘었다. 고칠 필요가 없었다. @@ -442,6 +464,8 @@ sticky 캔버스가 끝까지 돌려면 목록 뒤에 스크롤 구간이 필요 - [ ] (한국어) 한글 폰트가 명시되어 있고 `word-break: keep-all`이 있다 - [ ] 모든 섹션에서 제목→본문 간격이 **같은 값**이다 (0px 인 섹션이 하나도 없다) - [ ] 화면에 적은 수치는 **전부 그 화면을 캡처해 잰 값**이다 (추정치·기대치가 섞여 있지 않다) +- [ ] 이미지 높이를 실제로 쟀다 — 원본 픽셀 높이가 그대로 나오면 `aspect-ratio` 가 죽은 것이다 +- [ ] 내비·버튼 그룹의 **행 수를 `top` 값으로** 셌다 (개별 높이로는 못 잡는다) **판정** diff --git a/packages/skill/references/galleries.md b/packages/skill/references/galleries.md index 831af26..f6b103f 100644 --- a/packages/skill/references/galleries.md +++ b/packages/skill/references/galleries.md @@ -170,4 +170,51 @@ Awwwards는 **타이포 완성도와 야심의 상한선**으로만 쓰고, 섹 | adfolio.design | 429 | Love The Work More | | **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) diff --git a/packages/skill/references/images.md b/packages/skill/references/images.md new file mode 100644 index 0000000..41dad76 --- /dev/null +++ b/packages/skill/references/images.md @@ -0,0 +1,142 @@ +# images.md — 사진을 어디서 구하고 어떻게 싣나 + +이미지는 대부분의 디자인에서 **가장 무거운 자산이고 가장 큰 인상**이다. +레이아웃이 좋아도 사진이 약하면 페이지가 약하다. 반대도 마찬가지다. + +4단계(구현)에서 **레이아웃을 잡기 전에** 무엇을 실을지 정해라. +자리를 먼저 만들고 나중에 채우면 비율이 안 맞아 다시 짠다. + +--- + +## 1. 조달 경로 — 위에서부터 확인한다 + +| 순위 | 경로 | 언제 | +|---|---|---| +| 1 | **사용자가 준 사진** | 있으면 무조건 이것. 실제 작업물을 이길 생성물은 없다 | +| 2 | **생성**(codex `image_gen`) | 콘셉트 확인용, 톤이 정확히 지정된 추상·분위기 컷 | +| 3 | **스톡** | 라이선스가 명확한 곳만. 출처를 `design.md` 에 적는다 | +| 4 | 없이 간다 | 타이포·색·여백만으로 만든다. 약한 사진보다 낫다 | + +**0단계에서 물어라**: "쓸 수 있는 사진이 있나요?" +없다고 하면 2번으로 가되, **생성물이라는 사실을 `design.md` 에 적는다.** +나중에 실제 사진으로 바꿀 때 레이아웃을 다시 짜지 않도록 비율을 미리 고정해 둔다. + +--- + +## 2. codex 가 있으면 생성한다 + +설치 여부를 먼저 확인한다. 없으면 조용히 3번으로 내려간다. + +```bash +codex --version # 있나 +node -p "JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.codex/auth.json','utf8')).auth_mode" +# → "chatgpt" 여야 구독 경로다. "api_key" 면 사용자 과금이라 먼저 물어라. +``` + +### 래퍼가 실패해도 이미지는 만들어져 있다 — 반드시 확인해라 + +`~/.codex/imagegen-headless/codex_imagegen.sh` 는 세션 rollout 에서 +`image_generation_call` 의 **base64** 를 찾는다. **codex-cli 0.147.0 은 base64 를 돌려주지 않는다.** +`exec` 도구를 거쳐 **파일로 저장**하고, 래퍼는 "image_gen 이 호출되지 않았다" 며 실패한다. + +실측에서 이 메시지를 보고 두 번 다시 만들었는데, **두 번 다 파일은 멀쩡히 있었다.** + +``` +~/.codex/generated_images//exec-.png +``` + +rollout jsonl 의 `custom_tool_call_output` 에 경로가 그대로 적혀 있다. + +``` +Generated images are saved to <디렉터리> as <디렉터리>\exec-.png by default. +``` + +**한 줄에 경로가 둘**이므로 마지막 것을 쓴다. 공백을 허용하는 정규식은 +앞 디렉터리부터 `" as "` 까지 통째로 삼킨다. + +```js +// 세션 파일에서 실제 저장 경로를 읽는다 +const all = line.match(/[A-Za-z]:\\[^\s"]*generated_images[^\s"]*\.png/g); +if (all) hit = all[all.length - 1]; +``` + +호출은 이렇게 한다. **`$imagegen` 이 프롬프트 맨 앞에 와야** 내장 도구를 쓴다 — +뒤에 붙이면 codex 가 스크립트를 짜려고 헤맨다. + +```bash +printf '%s' "\$imagegen $PROMPT" \ + | codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox - +``` + +> 실패로 판정하기 전에 `~/.codex/generated_images/` 를 봐라. +> "실패했다" 고 보고했는데 파일이 있으면 그건 도구가 아니라 판정이 틀린 것이다. + +### 프롬프트 — 톤을 공통 문자열로 고정한다 + +여러 장을 만들 때 각 프롬프트에 톤을 다시 쓰면 장마다 색이 달라진다. +**공통 접두사 하나**를 만들고 장면만 바꿔라. + +``` +[공통] Editorial magazine photograph. Warm off-white setting (#F8F6F0), + soft natural window light, low saturation, muted restrained palette. + Photographic realism, shallow depth of field, fine film grain. + No text, no logo, no watermark. +[개별] Overhead flat-lay on a worn wooden workbench: cut stems, florist shears, twine. +``` + +- **3단계에서 정한 배경색을 프롬프트에 그대로 넣어라.** 사진이 페이지 배경과 이어진다 +- 크기는 양변 16의 배수: `1152x1536`(3:4) · `1536x1152`(4:3) · `2048x1152`(16:9) +- 사람 얼굴은 피하는 쪽이 안전하다. 손·뒷모습·부분은 잘 나오고 얼굴은 어색해지기 쉽다 +- `No text` 를 넣어라. 넣지 않으면 간판·라벨에 뭉개진 글자가 생긴다 + +### 생성물은 반드시 눈으로 본다 + +`Read` 로 열어 확인한 뒤에만 쓴다. 특히 **공간 사진**(작업실·매장)이 어색해지기 쉽다. +주제가 안 맞으면 프롬프트를 **한 번에 한 가지만** 바꿔 다시 만든다. + +--- + +## 3. 싣기 전에 반드시 줄인다 + +생성물은 1~3MB PNG 로 나온다. 그대로 실으면 페이지가 10MB 가 된다. + +```js +const w = Math.min(meta.width, meta.width > meta.height ? 1600 : 1100); +await sharp(src).resize({ width: w }).webp({ quality: 82 }).toFile(out); +``` + +실측: **10.1MB → 603KB (94% 감소).** 화면에서 차이가 보이지 않는다. + +| 쓰임 | 최대 폭 | +|---|---| +| 전체폭 배경·히어로 | 1600px | +| 본문 폭 이미지 | 1100px | +| 카드·썸네일 | 640px | + +## 4. `` 에 `width`/`height` 를 적었다면 `height: auto` 를 반드시 줘라 + +CLS 를 막으려고 속성을 적는 것은 맞다. 그런데 CSS 에서 높이를 정하지 않으면 +**속성 height 가 그대로 계산된 높이가 되어 `aspect-ratio` 가 무시된다.** + +```css +img { max-width: 100%; height: auto; } /* 이 한 줄이 없으면 */ +``` + +실측 사고: `.two-photo img { aspect-ratio: 3/4 }` 를 줬는데 computed 값은 +`"3 / 4"` 로 멀쩡히 보이고 **실제 높이는 원본 1448px** 이었다. +`aspect-ratio` 는 폭·높이 중 하나가 `auto` 일 때만 높이를 정한다. + +증상이 조용하다 — 오류도 경고도 없고 **페이지만 길어진다.** +이 한 줄을 넣자 전체 높이가 10.9 화면에서 7 화면이 됐다. + +> 진단: 이미지 높이를 실제로 재라. 원본 픽셀 높이(예: 1448)가 그대로 나오면 이것이다. +> ```js +> [...document.querySelectorAll('img')] +> .filter(i => i.getBoundingClientRect().height > 1000) +> ``` + +## 5. `alt` 는 장식이 아니면 반드시 쓴다 + +- 내용을 나르는 이미지 → **무엇이 보이는지** 적는다. "이미지", "사진" 은 alt 가 아니다 +- 순수 장식(배경 텍스처·그레인) → `alt=""` + `aria-hidden="true"` +- 캡션이 이미 설명하고 있으면 alt 는 짧게. 같은 문장을 두 번 읽히지 않는다 diff --git a/packages/skill/references/layout.md b/packages/skill/references/layout.md index 379ff7f..42b821c 100644 --- a/packages/skill/references/layout.md +++ b/packages/skill/references/layout.md @@ -105,6 +105,79 @@ --- +## 4-b. 모바일 내비 — 햄버거를 기본값으로 삼지 마라 + +내비 항목 수는 0단계에서 이미 정해져 있다(= 섹션 수). 구현하다 발견하면 늦다. + +| 항목 수 | 처리 | 비용 | +|---|---|---| +| **2~4개** | 한 줄 유지. 자간을 줄이고 `space-between` 으로 편다 | 0 | +| **5~6개** | 가로 스크롤 줄, 또는 하단 고정 바 | 낮음 | +| **7개 이상** | 여는 메뉴 | `
` 로 JS 0바이트 | + +**넷인데 햄버거를 쓰면 탭 한 번을 공짜로 뺏는 것이다.** 반대로 여섯을 한 줄에 +욱여넣으면 접힌다 — 실측에서 링크가 두 줄(top 46/77)에 걸렸고, 워드마크까지 3줄이 됐다. + +### 2~4개 — 쌓는 것이 접히는 것보다 낫다 + +워드마크와 내비를 한 줄에 두면 둘 다 접힌다. 좁은 화면에서는 **위아래로 쌓아라.** + +```css +@media (max-width: 560px) { + .site-head { flex-direction: column; align-items: stretch; gap: var(--space-3); } + .nav { flex-wrap: nowrap; justify-content: space-between; letter-spacing: 0.04em; } +} +``` + +라벨 자간(`0.14em`)이 좁은 화면에서 폭을 크게 먹는다. **자간부터 줄여라** — +폰트 크기를 줄이는 것보다 읽기에 덜 해롭다. + +### 7개 이상 — `
` 면 JS 가 필요 없다 + +키보드 조작·`Esc`·포커스 이동이 브라우저 기본으로 온다. +직접 만든 토글은 그걸 전부 다시 구현해야 하고, 대개 빠뜨린다. + +```html + +``` + +```css +.nav-mobile > summary { list-style: none; cursor: pointer; } +.nav-mobile > summary::-webkit-details-marker { display: none; } +.nav-sheet { position: absolute; right: 0; } +``` + +**아이콘이 세 줄(≡)일 필요는 없다.** 에디토리얼이면 "메뉴" 라고 적는 편이 낫다 — +글자는 뜻이 분명하고, 세 줄은 학습된 관례일 뿐이다. + +### 어느 쪽이든 검사는 같다 + +**항목의 `top` 값이 한 종류여야 한다.** 높이만 봐서는 못 잡는다(→ `antipatterns.md`). + +```js +new Set([...document.querySelectorAll('.nav a')] + .map(e => Math.round(e.getBoundingClientRect().top))).size === 1 +``` + +--- + +## 4-c. 구조를 바꾸면 미디어쿼리도 같이 다시 써라 + +실측 사고. 히어로를 12칸 그리드에서 "글 + 사진 컨테이너" 2단으로 바꾸면서 +좁은 화면 규칙을 그대로 뒀다. `.hero-text` 가 `auto 1fr` 2칸인데 +세로 라벨을 가로로 눕히자 **그 긴 문장이 auto 칸을 통째로 먹어** h1 칸이 짜부라졌고, +헤드라인이 390px 에서 6줄, 320px 에서 11줄이 됐다. + +넓은 화면에서는 멀쩡했다. **구조 변경의 대가는 항상 좁은 화면에서 먼저 청구된다.** + +> 규칙: 그리드 구조를 바꿨으면 **그 자리에서** 320·390·768 을 다시 재라. +> 나중에 하면 원인이 어느 변경이었는지 못 찾는다. + +--- + ## 5. 이 단계의 통과 조건 - [ ] CSS/HTML만으로 페이지가 완성됐다. JS를 꺼도 읽힌다 diff --git a/packages/skill/references/typography.md b/packages/skill/references/typography.md index fe31702..dcc71c3 100644 --- a/packages/skill/references/typography.md +++ b/packages/skill/references/typography.md @@ -270,6 +270,29 @@ font-family: "Switzer", "Pretendard Variable", system-ui, sans-serif; --- +### 세로쓰기에 가로쓰기 자간을 그대로 쓰지 마라 + +`writing-mode: vertical-rl` 로 세운 글자에 라벨용 자간(`letter-spacing: 0.14em`)을 +그대로 물리면 **글자 사이 세로 간격**이 되어 낱글자가 흩어진다. + +한글은 자소가 모여 한 글자를 이루기 때문에 라틴보다 심하다 — +"플로럴 스튜디오" 가 낱글자 일곱 개로 읽히기 시작한다. + +```css +.vertical { + writing-mode: vertical-rl; + letter-spacing: 0.02em; /* 가로쓰기 라벨의 0.14em 을 그대로 쓰면 안 된다 */ +} +``` + +세로쓰기는 **자간이 아니라 `line-height` 로 숨을 만든다.** 세로쓰기에서 `line-height` 는 +글자 사이가 아니라 **줄(세로 기둥) 사이**를 벌린다. + +> 좁은 화면에서는 세로 글자가 자리를 못 얻는다. `writing-mode: horizontal-tb` 로 눕히되, +> **그 자리의 그리드 칼럼도 같이 손봐라** — 가로로 누운 긴 문장이 `auto` 칸을 통째로 먹는다. + +--- + ## 7. 라이선스 **배포 전에 확인한다.** 폰트 라이선스 위반은 조용히 있다가 청구서로 온다.