Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

49 changed files with 238 additions and 2806 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View file

@ -1,301 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>목요일의 화원 · 플로럴 스튜디오</title>
<meta
name="description"
content="한 주에 한 번, 목요일에만 문을 여는 플로럴 스튜디오. 그 주에 좋은 꽃만 들여 주문받은 만큼만 만듭니다. 서울 서촌."
/>
<meta property="og:title" content="목요일의 화원 · 플로럴 스튜디오" />
<meta
property="og:description"
content="한 주에 한 번, 목요일에만 문을 여는 플로럴 스튜디오. 서울 서촌."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="./assets/flower-01.webp" />
<meta name="theme-color" content="#f8f6f0" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<!-- 한글 세리프. 라틴 세리프만 지정하면 한글이 시스템 기본으로 떨어져 전부 무너진다 -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link rel="stylesheet" href="./styles/main.css" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E🌿%3C/text%3E%3C/svg%3E" />
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head wrap">
<a class="wordmark" href="#main">
<span>목요일의</span> <span>화원</span>
</a>
<nav class="nav" aria-label="섹션">
<a href="#work">작업</a>
<a href="#thursday">목요일</a>
<a href="#subscribe">정기</a>
<a href="#visit">찾아오기</a>
</nav>
</header>
<main id="main">
<!--
히어로. 큰 사진 한 장을 깔지 않는다 — 그게 이 카테고리의 기본값이다.
크기도 비율도 다른 사진 넷을 공통 정렬선 없이 흩뿌리고,
그 사이 여백에 글자를 세로로 세운다. (R1: sarahwinward.com)
-->
<section class="hero" aria-labelledby="hero-h">
<div class="hero-inner wrap">
<div class="hero-text">
<p class="vertical hero-role reveal">플로럴 스튜디오 / 서울 서촌 / 2019</p>
<div class="hero-body">
<h1 id="hero-h" class="hero-h reveal">
그 주에 좋은 꽃만<br />들입니다
</h1>
<p class="lead hero-lead reveal" style="--delay: 120ms">
목요일 아침에 시장에서 그 주의 꽃을 고르고, 주문받은 만큼만 만듭니다.
남는 꽃이 없어서 시들 일도 없습니다.
</p>
<a class="btn hero-cta reveal" href="#contact" style="--delay: 200ms">
상담 신청하기
</a>
</div>
</div>
<div class="hero-photos">
<figure class="ph ph-a reveal" style="--delay: 80ms">
<img
src="./assets/flower-01.webp"
alt="흰 라넌큘러스와 작약, 유칼립투스로 묶은 꽃다발"
width="1086"
height="1448"
loading="eager"
fetchpriority="high"
/>
</figure>
<figure class="ph ph-b reveal" style="--delay: 260ms">
<img
src="./assets/flower-03.webp"
alt="꽃 한 송이의 꽃잎 결이 보이는 접사"
width="1086"
height="1448"
loading="lazy"
/>
</figure>
<figure class="ph ph-c reveal" style="--delay: 340ms">
<img
src="./assets/flower-02.webp"
alt="작업대 위에 놓인 꽃 줄기와 가위, 노끈"
width="1448"
height="1086"
loading="lazy"
/>
</figure>
<p class="vertical hero-tags reveal" style="--delay: 420ms">
주문 제작 / 정기 구독 / 공간 장식
</p>
</div>
</div>
</section>
<!-- 왜 목요일인가 — 이름이 곧 운영 방식이라는 것을 여기서 푼다 -->
<section id="thursday" class="section band" aria-labelledby="thursday-h">
<div class="wrap two">
<div class="two-text">
<h2 id="thursday-h">
일주일에 하루만 문을 엽니다
</h2>
<p>
수요일 밤에 경매가 끝나고, 목요일 새벽 시장에 그 주의 꽃이 나옵니다.
그날 좋은 것만 골라 그날 만듭니다.
</p>
<p>
나머지 엿새는 만들지 않습니다. 미리 사다 두면 파는 날까지 꽃이 기다려야
하고, 기다린 꽃은 이미 한 번 시든 꽃입니다.
</p>
<dl class="facts">
<div>
<dt>여는 날</dt>
<dd>매주 목요일 10:0019:00</dd>
</div>
<div>
<dt>주문 마감</dt>
<dd>수요일 18:00</dd>
</div>
<div>
<dt>그 주 제작 수</dt>
<dd>최대 20건</dd>
</div>
</dl>
</div>
<figure class="two-photo">
<img
src="./assets/flower-05.webp"
alt="꽃 줄기를 가위로 다듬는 손"
width="1086"
height="1448"
loading="lazy"
/>
<figcaption class="label">목요일 아침, 손질</figcaption>
</figure>
</div>
</section>
<!-- 작업 — 사진이 설득의 주체다. 크기 차이가 리듬이다 -->
<section id="work" class="section" aria-labelledby="work-h">
<div class="wrap">
<p class="label">작업</p>
<h2 id="work-h">같은 꽃을 두 번 쓰지 않습니다</h2>
<p class="lead">
주에 따라 들어오는 꽃이 다르니 결과도 매번 다릅니다. 지난 작업을 그대로
다시 만들어 달라는 요청은 받지 않습니다.
</p>
</div>
<div class="wrap gallery">
<figure class="g g-1">
<img
src="./assets/flower-04.webp"
alt="창가 자연광이 드는 작업실, 양철 양동이에 담긴 꽃들과 나무 선반"
width="1600"
height="900"
loading="lazy"
/>
<figcaption>작업실 · 서촌</figcaption>
</figure>
<figure class="g g-2">
<img
src="./assets/flower-01.webp"
alt="흰색과 크림색 위주로 묶은 꽃다발"
width="1086"
height="1448"
loading="lazy"
/>
<figcaption>화이트 온 화이트 · 6월</figcaption>
</figure>
<figure class="g g-3">
<img
src="./assets/flower-02.webp"
alt="꽃 줄기와 가위, 노끈이 놓인 작업대를 위에서 본 모습"
width="1448"
height="1086"
loading="lazy"
/>
<figcaption>손질 전 · 목요일 오전</figcaption>
</figure>
</div>
<div class="wrap">
<p class="muted work-note">
더 많은 작업은
<a class="link" href="https://instagram.com" rel="noopener">인스타그램</a>
주 단위로 올립니다.
</p>
</div>
</section>
<!-- 정기구독. 주 CTA(상담)와 경쟁하지 않게 아래쪽에 조용히 둔다 -->
<section id="subscribe" class="section band" aria-labelledby="sub-h">
<div class="wrap two two-reverse">
<div class="two-text">
<h2 id="sub-h">격주 목요일, 같은 자리에</h2>
<p>
사무실 응접실이나 매장 카운터처럼 꽃이 늘 있어야 하는 자리를 위한
것입니다. 격주로 그 주의 꽃을 가져다 놓고, 지난 것을 가져옵니다.
</p>
<p class="muted">
화병은 저희 것을 두고 씁니다. 자리와 조도를 먼저 보고 정하기 때문에
첫 회는 방문 상담으로 시작합니다.
</p>
<a class="btn btn-quiet" href="#contact">정기 상담 신청</a>
</div>
<figure class="two-photo">
<img
src="./assets/flower-03.webp"
alt="꽃잎의 결이 보이는 접사"
width="1086"
height="1448"
loading="lazy"
/>
<figcaption class="label">이번 주 · 라넌큘러스</figcaption>
</figure>
</div>
</section>
<!-- 문의 — 이 페이지의 유일한 주 CTA가 도착하는 곳 -->
<section id="contact" class="section contact" aria-labelledby="contact-h">
<div class="wrap contact-inner">
<p class="label">문의</p>
<h2 id="contact-h">무엇에 쓸 꽃인지부터 듣습니다</h2>
<p class="lead">
받는 분과 자리, 예산을 알려주시면 그 주에 가능한 것을 먼저 말씀드립니다.
가능하지 않은 주에는 그렇다고 말씀드립니다.
</p>
<ul class="contact-list">
<li>
<span class="label">전화</span>
<a class="link" href="tel:0212345678">02-1234-5678</a>
</li>
<li>
<span class="label">메일</span>
<a class="link" href="mailto:hello@thursday.flowers"
>hello@thursday.flowers</a
>
</li>
<li>
<span class="label">인스타그램</span>
<a class="link" href="https://instagram.com" rel="noopener"
>@thursday.flowers</a
>
</li>
</ul>
</div>
</section>
<!-- 찾아오기. 푸터를 따로 두지 않고 마지막 섹션이 그 역할을 한다 -->
<section id="visit" class="section visit" aria-labelledby="visit-h">
<div class="wrap visit-inner">
<div>
<h2 id="visit-h">서촌, 목요일에만</h2>
<address class="visit-address">
서울 종로구 자하문로 00길 00, 2층<br />
경복궁역 3번 출구에서 도보 8분
</address>
</div>
<p class="visit-note muted">
목요일 외에는 상주하지 않습니다. 다른 요일 방문은 미리 연락 주세요.
</p>
<p class="vertical visit-mark">목요일의 화원</p>
</div>
</section>
</main>
<script>
/*
등장 처리만 한다. 스크롤 이벤트는 쓰지 않는다 —
매 프레임 레이아웃을 강제하고 모바일에서 프레임이 무너진다.
한 번 보이면 유지한다: 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 아니다.
*/
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -1,602 +0,0 @@
@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);
}
/* 넓은 화면에서는 , 좁은 화면에서는 줄로 쓴다.
<br> 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%;
}
}

View file

@ -1,84 +0,0 @@
/*
목요일의 화원 디자인 토큰
전부 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;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

View file

@ -1,55 +0,0 @@
/**
* .
*
* ** ** .
* .
*
* ( ). `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",
},
];

View file

@ -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: "<h1>산미 8.5 · 바디 6.0 · 로스팅 라이트</h1>",
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,123 +270,122 @@ 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: "매니페스트 기반 정확한 제거" },
],
@ -394,7 +393,7 @@ export const ko: Content = {
nav: {
items: [
{ label: "파이프라인", href: "#pipeline" },
{ label: "사례", href: "/showcase" },
{ label: "사례", href: "#showcase" },
{ label: "수치", href: "#metrics" },
{ label: "설치", href: "#install" },
],
@ -647,7 +646,6 @@ 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" },
@ -661,7 +659,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" },
],

View file

@ -2,6 +2,7 @@
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";
@ -19,7 +20,7 @@ import { en as c } from "../../i18n/content";
① 무엇인가 히어로 · 파이프라인
② 무엇을 아는가 SVG 필터 · 한글 조판
③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다)
③ 정말 그런가 사례 · 수치 · 자기 감사 · 규칙
④ 행동 설치
*/}
<Hero c={c.hero} ui={c.ui} />
@ -28,6 +29,7 @@ import { en as c } from "../../i18n/content";
<Materials c={c.materials} />
<Typography c={c.typography} />
<Showcase c={c.showcase} />
<Metrics c={c.metrics} />
<Preflight c={c.preflight} />
<Rules c={c.rules} />

View file

@ -2,6 +2,7 @@
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";
@ -19,7 +20,7 @@ import { ko as c } from "../i18n/content";
① 무엇인가 히어로 · 파이프라인
② 무엇을 아는가 SVG 필터 · 한글 조판
③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다)
③ 정말 그런가 사례 · 수치 · 자기 감사 · 규칙
④ 행동 설치
*/}
<Hero c={c.hero} ui={c.ui} />
@ -28,6 +29,7 @@ import { ko as c } from "../i18n/content";
<Materials c={c.materials} />
<Typography c={c.typography} />
<Showcase c={c.showcase} />
<Metrics c={c.metrics} />
<Preflight c={c.preflight} />
<Rules c={c.rules} />

View file

@ -1,256 +0,0 @@
---
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 파이프라인으로 만든 사이트들. 각 사례의 컨셉과 감수한 리스크, 실측값을 함께 적었습니다. 타일을 누르면 실제 사이트가 열립니다.";
---
<Base lang="ko" title={title} description={description}>
<section class="section intro has-bg" aria-labelledby="sc-h">
<BgPhoto variant="align" mode="fixed" opacity={0.85} />
<p class="eyebrow reveal">사례</p>
<h1 id="sc-h" class="reveal">파이프라인을 돌려서 나온 것들</h1>
<p class="lead intro-lead reveal">
모두 0단계부터 6단계까지 그대로 돌려서 나온 결과물입니다. 각 사례마다
<strong>감수한 리스크</strong>를 함께 적었습니다 — 그것이 없으면 안전하지만 잊히는 디자인이 됩니다.
</p>
<p class="muted intro-note reveal">
타일을 누르면 실제 사이트가 열립니다. 목업이 아니라 동작하는 페이지입니다.
</p>
</section>
<section class="section grid-section" aria-label="사례 목록">
<ul class="full mosaic">
{
WORK.map((w, i) => (
<li class:list={["tile", `tile-${w.size}`, "reveal"]} style={`--reveal-delay:${i * 80}ms`}>
<a class="tile-link" href={w.href}>
<span class="tile-shot">
<Image
src={w.shot}
alt={w.alt}
widths={[520, 900, 1440]}
sizes="(max-width: 820px) 92vw, 46vw"
loading={i === 0 ? "eager" : "lazy"}
/>
{w.shotAlt && (
<Image
class="tile-shot-alt"
src={w.shotAlt}
alt=""
widths={[520, 900, 1440]}
sizes="(max-width: 820px) 92vw, 46vw"
loading="lazy"
aria-hidden="true"
/>
)}
</span>
<span class="tile-body">
<span class="tile-head">
<span class="tile-name">{w.name}</span>
<span class="tile-kind muted">{w.kind}</span>
</span>
<span class="tile-concept">{w.concept}</span>
<span class="tile-risk">
<span class="risk-label">감수한 리스크</span>
{w.risk}
</span>
<span class="tile-metrics mono">
{w.metrics.map((m) => (
<span class="metric">{m}</span>
))}
</span>
</span>
</a>
</li>
))
}
</ul>
</section>
</Base>
<style>
/* .page 그리드는 base.css 가 준다. 여기서 다시 정의하면
main 칼럼이 본문 폭(--measure)에서 벗어나 전 페이지가 어긋난다. */
.intro {
padding-block: calc(var(--header-h) + var(--space-7)) var(--space-7);
}
/* 목록 페이지의 제목이라 홈 히어로만큼 클 이유가 없다.
h1 기본값(--display)은 여기서 세 줄로 접힌다. */
#sc-h {
font-size: clamp(2rem, 1.3rem + 2.4vw, 3.4rem);
max-width: 18ch;
}
.intro-lead {
margin-bottom: var(--space-4);
max-width: var(--measure);
}
.intro-note {
max-width: var(--measure);
}
.grid-section {
padding-block: 0 var(--space-8);
}
/* ── 모자이크 ────────────────────────────────────────
타일 크기가 섞인다. 사례마다 무게가 다른데 같은 크기로 두면
그 차이가 사라지고, 그때부터는 격자가 내용을 지운다. */
.mosaic {
list-style: none;
margin: 0;
max-width: 1180px;
margin-inline: auto;
padding: 0 var(--gutter);
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--space-6) var(--space-5);
}
.tile {
margin: 0;
min-width: 0;
}
.tile-lg {
grid-column: span 6;
}
.tile-md {
grid-column: span 3;
}
.tile-sm {
grid-column: span 2;
}
.tile-link {
display: grid;
gap: var(--space-4);
color: inherit;
text-decoration: none;
}
/* 큰 타일은 그림과 글을 나란히 둔다. 세로로 쌓으면 화면을 통째로 먹는다 */
.tile-lg .tile-link {
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: var(--space-6);
align-items: center;
}
.tile-shot {
position: relative;
display: block;
overflow: hidden;
border-radius: var(--radius-md);
border: 1px solid var(--line);
background: var(--surface-2);
aspect-ratio: 16 / 10;
}
.tile-shot :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
/* 두 번째 컷은 위에 겹쳐 두고 호버에서 바꾼다.
스크롤이나 자동 전환으로 바꾸지 않는다 — 읽는 사람의 속도를 뺏지 않는다 */
.tile-shot-alt {
position: absolute;
inset: 0;
opacity: 0;
}
.tile-body {
display: grid;
gap: var(--space-3);
align-content: start;
}
.tile-head {
display: grid;
gap: var(--space-1);
}
.tile-name {
font-size: var(--step-1);
font-weight: var(--weight-medium);
}
.tile-kind {
font-size: var(--step-0);
}
.tile-concept {
color: var(--ink);
max-width: 34em;
}
.tile-risk {
display: grid;
gap: var(--space-1);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
color: var(--ink-muted);
max-width: 38em;
}
.risk-label {
font-size: var(--step-0);
letter-spacing: var(--tracking-label);
color: var(--accent);
}
.tile-metrics {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
font-size: var(--mono-size);
color: var(--ink-muted);
}
.metric + .metric::before {
content: "·";
margin-right: var(--space-4);
color: var(--line-strong);
}
/* ── 호버: 그림이 바뀌고 테두리가 선다 ─────────────── */
@media (prefers-reduced-motion: no-preference) {
.tile-shot :global(img) {
transition: opacity var(--dur-slow) var(--ease-soft);
}
.tile-shot {
transition: border-color var(--dur-quick) var(--ease-out);
}
.tile-link:hover .tile-shot,
.tile-link:focus-visible .tile-shot {
border-color: var(--accent);
}
.tile-link:hover .tile-shot-alt,
.tile-link:focus-visible .tile-shot-alt {
opacity: 1;
}
}
@media (max-width: 900px) {
.mosaic {
grid-template-columns: 1fr;
}
.tile-lg,
.tile-md,
.tile-sm {
grid-column: span 1;
}
.tile-lg .tile-link {
grid-template-columns: 1fr;
gap: var(--space-4);
}
}
</style>

View file

@ -1,186 +0,0 @@
/**
* 방문자에게 보이는 한국어 본문을 존댓말로 바꾼다.
*
* 제목(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}개 문자열`);

View file

@ -1,8 +0,0 @@
# 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 와 같아 변환 없이 그대로 복사된다.

View file

@ -1,8 +1,6 @@
# designpaca
**웹 디자인 파이프라인 스킬을 어떤 에이전트 하네스에든 한 줄로 설치한다.**
Agent Skills 규약(SKILL.md)을 그대로 따르므로, 규약을 읽는 하네스라면 종류를 가리지 않는다.
**웹 디자인 파이프라인 스킬을 Claude Code · Codex · Cursor 에 한 줄로 설치한다.**
```bash
npx designpaca
@ -55,19 +53,11 @@ 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/` (또는 프로젝트 `.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/`) |
| `codex` | `~/.codex/skills/designpaca/` |
| `cursor` | `.cursor/rules/designpaca.mdc` (프로젝트 단위) |
| `agents-md` | `AGENTS.md` 에 블록 주입 |
## 사용
@ -105,7 +95,7 @@ npx designpaca update --force
| 옵션 | 설명 |
|---|---|
| `-t, --target <id[,id]>` | `claude-code` `codex` `cursor` `zcode` `agents` `antigravity` `gemini-cli` `windsurf` `copilot` `agents-md` |
| `-t, --target <id[,id]>` | `claude-code` `codex` `cursor` `agents-md` |
| `-s, --scope <범위>` | `user` (전역) \| `project` |
| `-y, --yes` | 확인 없이 진행 |
| `-f, --force` | 직접 수정한 파일도 덮어쓴다 (`.orig` 백업) |

View file

@ -1,35 +1,16 @@
{
"name": "designpaca",
"version": "0.5.0",
"version": "0.1.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",

View file

@ -18,7 +18,7 @@ export async function runDoctor(): Promise<number> {
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<number> {
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) {

View file

@ -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,41 +70,16 @@ 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/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")} 에서 스킬을 읽는다`));
console.log(info(`Cursor 는 ${dim(".cursor/rules/designpaca.mdc")} 를 프로젝트 열 때 읽는다`));
}
if (targets.includes("windsurf")) {
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 를 읽는다`),
);
console.log(info(`Windsurf 는 ${dim(".windsurf/rules/designpaca.md")} 를 읽는다`));
}
if (targets.includes("agents-md")) {
console.log(

View file

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

View file

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

View file

@ -12,7 +12,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise<number>
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<number>
const stale = manifest.installs.filter((i) => i.version !== version);
if (stale.length === 0) {
console.log(ok("설치된 스킬이 모두 최신 버전입니다"));
console.log(ok("모든 설치가 이미 최신이다"));
// 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다.
let broken = 0;

View file

@ -3,7 +3,6 @@ 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";
@ -71,15 +70,14 @@ ${bold("사용법")}
npx designpaca uninstall
npx designpaca doctor
npx designpaca list
npx designpaca tools ${dim("(--yes 로 설치)")}
${bold("옵션")}
-t, --target <id[,id]> 대상: ${VALID_TARGETS.join(", ")}
-s, --scope <범위> user | project ${dim("(기본: user)")}
-y, --yes
-f, --force ${dim("(.orig 로 백업)")}
--dry-run
--no-update-check
-y, --yes
-f, --force ${dim("(.orig 로 백업)")}
--dry-run
--no-update-check
-h, --help
-v, --version
@ -87,7 +85,6 @@ ${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();
}
@ -149,16 +146,12 @@ async function main(): Promise<number> {
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;
}
@ -167,7 +160,7 @@ async function main(): Promise<number> {
const latest = await checkForUpdate(version);
if (latest) {
console.log(
"\n" + warn(`새 버전 v${latest} 이 나왔습니다 — ${dim("npx designpaca@latest update")}`),
"\n" + warn(`새 버전 ${bold(`v${latest}`)} 이 있다 — ${dim("npx designpaca@latest update")}`),
);
}
return code;

View file

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

View file

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

View file

@ -1,8 +0,0 @@
# @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 와 같아 변환 없이 그대로 복사된다.

View file

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

View file

@ -5,5 +5,4 @@ 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";

View file

@ -1,41 +0,0 @@
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 ,
* . ** **.
*
* :
* <root>/.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")),
};
},
};

View file

@ -1,53 +0,0 @@
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
* .
*
* ( ):
* <root>/.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")),
};
},
};

View file

@ -1,42 +0,0 @@
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")),
};
},
};

View file

@ -1,41 +1,57 @@
import path from "node:path";
import { exists } from "../fsx.ts";
import type { TargetAdapter, TargetContext } from "../types.ts";
import { anyExists, scopeRoot, skillDirActions } from "./common.ts";
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
import { anyExists, referenceActions, rewriteRefPaths } from "./common.ts";
/** 본문과 references 가 함께 놓이는 루트 (본문 경로 재작성 기준) */
const REF_PREFIX = ".cursor/rules/designpaca";
/**
* Cursor ~/.cursor/skills/designpaca/ () .cursor/skills/designpaca/ ().
* Cursor .cursor/rules/designpaca.mdc.
* .mdc SKILL.md (description/globs/alwaysApply) .
*
* Cursor Agent Skills . (.cursor/skills)
* (.agents/skills) , (.claude/skills · .codex/skills)
* SKILL.md .
*
* .cursor/rules/designpaca.mdc .
*
* ·references . .
* Windsurf (.windsurf/rules/*.md ) .
*/
export const cursor: TargetAdapter = {
id: "cursor",
label: "Cursor",
hint: "~/.cursor/skills/designpaca — SKILL.md 그대로 (전역 지원)",
scopes: ["user", "project"],
hint: ".cursor/rules/designpaca.mdc — 프로젝트 단위",
// Cursor 의 전역 규칙은 파일이 아니라 앱 설정(User Rules)이라 프로젝트 범위만 지원한다
scopes: ["project"],
async detect(ctx: TargetContext) {
return anyExists([path.join(ctx.home, ".cursor"), path.join(ctx.cwd, ".cursor")]);
return anyExists([path.join(ctx.cwd, ".cursor"), path.join(ctx.home, ".cursor")]);
},
async plan(ctx: TargetContext) {
const root = scopeRoot(
ctx,
[".cursor", "skills", "designpaca"],
[".cursor", "skills", "designpaca"],
);
const rulesDir = path.join(ctx.cwd, ".cursor", "rules");
const mdc = path.join(rulesDir, "designpaca.mdc");
const refRoot = path.join(rulesDir, "designpaca");
const header = [
"---",
`description: ${ctx.skill.description}`,
"globs:",
"alwaysApply: false",
"---",
"",
].join("\n");
const actions: FileAction[] = [
{
kind: "write",
path: mdc,
content: header + rewriteRefPaths(ctx.skill.body, REF_PREFIX),
},
...referenceActions(refRoot, ctx.skill),
];
return {
target: this.id,
scope: ctx.scope,
root,
actions: skillDirActions(root, ctx.skill),
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
root: rulesDir,
actions,
alreadyInstalled: await exists(mdc),
};
},
};

View file

@ -1,41 +0,0 @@
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")),
};
},
};

View file

@ -2,26 +2,10 @@ 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,
zcode,
agents,
antigravity,
geminiCli,
windsurf,
copilot,
agentsMd,
];
export const ADAPTERS: TargetAdapter[] = [claudeCode, codex, cursor, windsurf, agentsMd];
export function getAdapter(id: TargetId): TargetAdapter {
const a = ADAPTERS.find((x) => x.id === id);
@ -30,15 +14,4 @@ export function getAdapter(id: TargetId): TargetAdapter {
}
export { MARKER, rewriteRefPaths } from "./common.ts";
export {
claudeCode,
codex,
cursor,
zcode,
agents,
antigravity,
geminiCli,
windsurf,
copilot,
agentsMd,
};
export { claudeCode, codex, cursor, windsurf, agentsMd };

View file

@ -1,42 +1,66 @@
import path from "node:path";
import { exists } from "../fsx.ts";
import type { TargetAdapter, TargetContext } from "../types.ts";
import { anyExists, scopeRoot, skillDirActions } from "./common.ts";
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
import { anyExists, referenceActions, rewriteRefPaths } from "./common.ts";
const REF_PREFIX = ".windsurf/rules/designpaca";
/** Windsurf 규칙 파일의 하드 상한. 넘으면 잘려서 조용히 망가진다. */
const WINDSURF_CHAR_LIMIT = 12_000;
/**
* Windsurf ~/.codeium/windsurf/skills/designpaca/ () .windsurf/skills/designpaca/ ().
* Cascade Agent Skills (SKILL.md) .
* Windsurf .windsurf/rules/designpaca.md.
* Cursor · (trigger/globs, .mdc ).
*
* .windsurf/rules/designpaca.md 12,000
* 25KB .
* name/description .
* trigger model_decision . always_on
* .
*/
export const windsurf: TargetAdapter = {
id: "windsurf",
label: "Windsurf",
hint: "~/.codeium/windsurf/skills/designpaca — SKILL.md 그대로 (전역 지원)",
scopes: ["user", "project"],
hint: ".windsurf/rules/designpaca.md — 프로젝트 단위",
scopes: ["project"],
async detect(ctx: TargetContext) {
return anyExists([
path.join(ctx.cwd, ".windsurf"),
path.join(ctx.cwd, ".windsurfrules"),
path.join(ctx.home, ".windsurf"),
path.join(ctx.home, ".codeium"),
]);
},
async plan(ctx: TargetContext) {
const root = scopeRoot(
ctx,
[".codeium", "windsurf", "skills", "designpaca"],
[".windsurf", "skills", "designpaca"],
);
const rulesDir = path.join(ctx.cwd, ".windsurf", "rules");
const rule = path.join(rulesDir, "designpaca.md");
const refRoot = path.join(rulesDir, "designpaca");
const header = [
"---",
"trigger: model_decision",
`description: ${ctx.skill.description}`,
"---",
"",
].join("\n");
const body = header + rewriteRefPaths(ctx.skill.body, REF_PREFIX);
const actions: FileAction[] = [
{ kind: "write", path: rule, content: body },
...referenceActions(refRoot, ctx.skill),
];
return {
target: this.id,
scope: ctx.scope,
root,
actions: skillDirActions(root, ctx.skill),
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
root: rulesDir,
actions,
alreadyInstalled: await exists(rule),
// 상한을 넘으면 설치는 되지만 Windsurf 가 뒷부분을 버린다. 조용히 깨지느니 막는다.
...(body.length > WINDSURF_CHAR_LIMIT
? {
blocked: `규칙 본문이 ${body.length}자로 Windsurf 상한(${WINDSURF_CHAR_LIMIT}자)을 넘는다. SKILL.md 를 줄여야 한다.`,
}
: {}),
};
},
};

View file

@ -1,37 +0,0 @@
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")),
};
},
};

View file

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

View file

@ -1,15 +1,5 @@
/** 설치 대상 식별자 */
export type TargetId =
| "claude-code"
| "codex"
| "cursor"
| "zcode"
| "agents"
| "antigravity"
| "gemini-cli"
| "windsurf"
| "copilot"
| "agents-md";
export type TargetId = "claude-code" | "codex" | "cursor" | "windsurf" | "agents-md";
/** 설치 범위 — user: 홈 디렉터리 전역, project: 현재 프로젝트 */
export type Scope = "user" | "project";

View file

@ -118,118 +118,46 @@ test("agents-md: 포인터만 주입하고 본문은 별도 디렉터리에 푼
assert.ok(!after2.includes("designpaca:start"));
});
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 는 user 범위를 거부한다", async () => {
const plan = await planInstall("cursor", "user", skill, env);
assert.ok(plan.blocked);
await assert.rejects(() => applyPlan(plan, skill.version, { env }));
});
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"));
test("cursor: .mdc 프론트매터로 변환하고 참조 경로를 재작성한다", async () => {
const plan = await planInstall("cursor", "project", skill, env);
const res = await applyPlan(plan, skill.version, { env });
const mdc = await fs.readFile(path.join(cwd, ".cursor", "rules", "designpaca.mdc"), "utf8");
assert.ok(mdc.startsWith("---\ndescription: 테스트용"));
assert.ok(mdc.includes("alwaysApply: false"));
assert.ok(mdc.includes("# 본문"));
// 본문과 references 가 다른 디렉터리에 놓이므로 경로가 재작성돼야 한다
assert.ok(
mdc.includes(".cursor/rules/designpaca/references/tokens.md"),
"참조 경로가 재작성되지 않았다",
);
await fs.access(path.join(cwd, ".cursor", "rules", "designpaca", "references", "tokens.md"));
await removeInstall(res.record, { env });
});
test("windsurf: 네이티브 스킬 경로에 싣는다 — 본문 크기 제한이 없다", 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"));
test("windsurf: Cursor 와 다른 경로·프론트매터를 쓴다", async () => {
const plan = await planInstall("windsurf", "project", skill, env);
const res = await applyPlan(plan, skill.version, { env });
// 예전 규칙 파일 방식은 12,000자 상한 때문에 실제 스킬(본문 25KB)이 막혔다.
// 네이티브 스킬은 본문을 통째로 프롬프트에 넣지 않으므로 큰 본문도 설치돼야 한다.
const rule = await fs.readFile(path.join(cwd, ".windsurf", "rules", "designpaca.md"), "utf8");
// always_on 이면 디자인 스킬이 모든 메시지의 시스템 프롬프트에 상주한다
assert.ok(rule.includes("trigger: model_decision"));
assert.ok(!rule.includes("alwaysApply"));
assert.ok(rule.includes(".windsurf/rules/designpaca/references/tokens.md"));
await removeInstall(res.record, { env });
});
test("windsurf: 12,000자 상한을 넘으면 설치를 막는다", async () => {
const huge: SkillSource = { ...skill, body: "가".repeat(13_000) };
const 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));
const plan = await planInstall("windsurf", "project", huge, env);
assert.ok(plan.blocked, "상한 초과인데 막지 않았다");
assert.match(plan.blocked, /상한/);
});

View file

@ -1,7 +0,0 @@
# @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` 로 바로잡는다.

View file

@ -88,77 +88,7 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬.
**애매하면 긴 쪽으로.** 단 국소 조건에 해당하면 국소로 가라 — 버튼 하나에 갤러리 3곳을 여는 것은 사용자가 이 스킬을 끄게 만든다.
**국소로 시작했다가 조건이 깨지면 멈추고 올린다.** 토큰을 새로 정의하게 됐거나, 손댄 섹션이 3개를 넘었거나, 방향을 바꿔야 하면. **올렸다고 말해라. 조용히 국소에 머무는 것이 이 스킬의 최대 실패다.**
**브리프가 비어 있으면 인터뷰해라. 가정으로 채우지 마라.**
이 단계에서 추측한 것은 전부 기본값이고, 기본값의 총합이 슬롭이다.
실측 사례 — "꽃집 홍보 사이트 하나 만들어보자"를 받고 업종 성격·목표 행동·톤·이름을
전부 혼자 정했다. 실제로 물어보니 **넷 중 넷이 달랐다.**
| 내가 가정한 것 | 사용자의 실제 답 |
|---|---|
| 일상 꽃 · 정기구독 | **하이엔드 플로럴 스튜디오** |
| 문의 유도 하나 | 문의 · 구독 · 방문 · 인스타 **넷 다** |
| (묻지 않음) | **에디토리얼 · 잡지** |
| (내가 지어냄) | **목요일의 화원** |
이 상태로 1단계에 들어갔으면 **레퍼런스 세 개를 전부 틀린 방향에서 골랐을 것이다.**
**질문 도구(AskUserQuestion)로 한 번에 묻는다.** 하나씩 캐물으면 사용자가 지친다 —
결과를 크게 바꾸는 축을 골라 **선택지와 함께** 한 화면에 낸다. 각 선택지에는
"이걸 고르면 무엇이 달라지는지"를 적어라. 고르는 사람이 결과를 예상할 수 있어야 한다.
거의 모든 브리프에서 다음이 결과를 가장 크게 바꾼다. **위 넷은 거의 항상 묻는다.**
| 축 | 무엇이 달라지는가 | 안 물으면 |
|---|---|---|
| **업종·성격** | 정보 구조 전체. 같은 "꽃집"도 구독형과 하이엔드 스튜디오는 다른 사이트다 | 카테고리 평균이 나온다 |
| **목표 행동** | CTA 의 수와 위치, 어떤 섹션이 필요하고 어떤 게 군더더기인지 | CTA 가 넷이 되고 페이지가 무너진다 |
| **톤** | 2단계 프리셋과 감수할 리스크가 여기서 결정된다 | 내 기본 미학이 나온다 |
| **고유명사·실제 값** | 이름·지역·가격·연락처 | 지어내면 하드 게이트 11 |
| **기존 브랜드 색·로고** | 있으면 3단계 팔레트가 **거기서 시작한다** | 있는 자산을 무시하고 새로 만든다 |
| **좁은 화면의 내비** | 항목 5개 이상이면 구조가 달라진다 | 넓은 화면 메뉴를 그대로 접어 두 줄이 된다 |
### 색은 반드시 확인한다 — 없다는 답도 답이다
**"브랜드 색이 있나요"** 는 결과를 되돌릴 수 없게 바꾸는 질문이다.
이미 로고·간판·패키지에 색이 있는데 사이트만 다른 색이면, 잘 만들어도 **틀린 것**이다.
- **있다** → 3단계에서 그 색이 강조색이고, 레퍼런스는 그 색과 어울리는 곳으로 고른다
- **없다·상관없다** → 레퍼런스 실측에서 뽑는다. 이때도 **어떤 색을 피해야 하는지**는 물어라
(경쟁사가 쓰는 색, 대표가 싫어하는 색은 사용자만 안다)
> 실측 사례: 꽃집 작업에서 색을 묻지 않고 레퍼런스 세 곳의 배경 평균(`#F8F6F0`)으로 정했다.
> 결과는 좋았지만 **운이 좋았던 것**이다. 브랜드 색이 있었다면 그걸 무시한 작업이 된다.
### 좁은 화면의 내비를 미리 정한다
내비 항목 수는 0단계에서 이미 정해진다(= 섹션 수). **구현 단계에서 발견하면 늦다.**
| 항목 수 | 좁은 화면 처리 | 비용 |
|---|---|---|
| 2~4개 | 그대로 한 줄. 자간을 줄이고 `justify-content: space-between` | 0 |
| 5~6개 | 가로 스크롤 줄 또는 하단 고정 바 | 낮음 |
| 7개 이상 | **여는 메뉴**(햄버거) | `<details>` 로 JS 0바이트 |
**햄버거를 기본값으로 삼지 마라.** 항목이 넷인데 햄버거를 쓰면 한 번의 탭을 공짜로 뺏는 것이다.
반대로 여섯 개를 한 줄에 욱여넣으면 접힌다 — 실측에서 링크가 두 줄(top 46/77)에 걸렸다.
여는 메뉴가 필요하다고 판단되면 `references/layout.md`**모바일 내비** 절을 본다.
넷을 `multiSelect` 로 낼지 단일 선택으로 낼지 구분해라 — **목표 행동은 대개 복수**고,
톤과 성격은 하나여야 한다. 둘 다 고르면 방향이 서지 않는다.
**물어도 되는 것과 물으면 안 되는 것**
- 묻는다: 결과를 바꾸는 결정(위 표), 사용자만 아는 사실(실제 수치·이름·재고)
- 묻지 않는다: 검색하면 나오는 것, 코드를 읽으면 아는 것, 관례가 명확한 것
**답이 모순되면 그 자리에서 정리해라.** 위 사례에서 목표 행동 넷이 다 선택됐는데,
하이엔드 스튜디오에서 "정기구독"과 "문의 상담"은 성격이 다르다.
**우선순위를 제안하고 확인받는다** — 넷을 같은 무게로 놓으면 CTA 가 넷이 되고 페이지가 무너진다.
예외: 사용자가 "알아서 해줘"라고 명시했거나, 되돌리기 쉬운 습작이면 가정하고 진행해도 된다.
**단 가정한 항목을 목록으로 말하고, 6단계 `design.md` 의 미확정 목록에 올린다.**
**막혔을 때**: 브리프가 비어 있으면 추측하지 말고 물어라. 단 **한 번에 다 묻지 마라.** 결과를 가장 크게 바꾸는 것 하나만 묻고, 나머지는 가정을 명시하고 진행한다.
**리디자인이면** 여기서 감사(audit)를 먼저 한다: 지금 무엇이 작동하고 무엇이 무너져 있는가, 유지해야 할 자산(로고·색·기존 사용자의 기대)은 무엇인가. 감사 없는 리디자인은 파괴다.
@ -185,11 +115,6 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된
**갤러리 목록 페이지가 아니라 원본 사이트를 열어라.** 이미지를 볼 수 없어도 구조는 읽을 수 있다.
**403·404·타임아웃을 두 번 만나면 텍스트 페처를 버리고 브라우저를 띄워라.**
좋은 레퍼런스일수록 봇을 막는다. 대체 갤러리를 찾아 헤매는 것은 시간 낭비이고,
찾아낸 대체 소스는 애초에 보려던 것보다 나쁘다. `designpaca tools` 로 설치된
headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다 — 방법은 `galleries.md` §5.
> 통과 조건: R1/R2/R3 각각의 URL과, 그것에서 **무엇을 가져올지** 한 줄씩. 형식은 `reference-method.md` 참조.
---
@ -239,12 +164,6 @@ headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다
순서가 있다. **레이아웃 → 재질 → 모션.** 거꾸로 가면 화려한데 읽을 수 없는 페이지가 나온다.
**4-0. 이미지 조달** → `references/images.md`
자리를 만들기 전에 **무엇을 실을지** 정한다. 나중에 채우면 비율이 안 맞아 레이아웃을 다시 짠다.
사용자가 준 사진이 있으면 무조건 그것이고, 없으면 **codex 가 설치돼 있는지 확인해 생성**한다.
생성했으면 `design.md` 에 생성물이라고 적는다 — 나중에 실제 사진으로 바꿀 사람이 알아야 한다.
싣기 전에 반드시 줄인다(실측: 10.1MB → 603KB).
**4-1. 레이아웃과 타이포그래피** → `references/layout.md` (+ 폰트 적용은 `references/typography.md` §5, §6)
그리드, 여백 리듬, 시선 흐름. 3단계의 토큰을 그대로 쓴다. 이 단계가 끝나면 **아무 이펙트 없이도 완성된 페이지**여야 한다. 이것이 모든 폴백의 기반이다.
@ -342,7 +261,6 @@ 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 — 입체가 필요할 때 |

View file

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

View file

@ -328,29 +328,7 @@ 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)를 넘었다. 고칠 필요가 없었다.
@ -464,8 +442,6 @@ sticky 캔버스가 끝까지 돌려면 목록 뒤에 스크롤 구간이 필요
- [ ] (한국어) 한글 폰트가 명시되어 있고 `word-break: keep-all`이 있다
- [ ] 모든 섹션에서 제목→본문 간격이 **같은 값**이다 (0px 인 섹션이 하나도 없다)
- [ ] 화면에 적은 수치는 **전부 그 화면을 캡처해 잰 값**이다 (추정치·기대치가 섞여 있지 않다)
- [ ] 이미지 높이를 실제로 쟀다 — 원본 픽셀 높이가 그대로 나오면 `aspect-ratio` 가 죽은 것이다
- [ ] 내비·버튼 그룹의 **행 수를 `top` 값으로** 셌다 (개별 높이로는 못 잡는다)
**판정**

View file

@ -170,51 +170,4 @@ 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)

View file

@ -1,142 +0,0 @@
# 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/<session-id>/exec-<call-id>.png
```
rollout jsonl 의 `custom_tool_call_output` 에 경로가 그대로 적혀 있다.
```
Generated images are saved to <디렉터리> as <디렉터리>\exec-<id>.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. `<img>``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 는 짧게. 같은 문장을 두 번 읽히지 않는다

View file

@ -105,79 +105,6 @@
---
## 4-b. 모바일 내비 — 햄버거를 기본값으로 삼지 마라
내비 항목 수는 0단계에서 이미 정해져 있다(= 섹션 수). 구현하다 발견하면 늦다.
| 항목 수 | 처리 | 비용 |
|---|---|---|
| **2~4개** | 한 줄 유지. 자간을 줄이고 `space-between` 으로 편다 | 0 |
| **5~6개** | 가로 스크롤 줄, 또는 하단 고정 바 | 낮음 |
| **7개 이상** | 여는 메뉴 | `<details>` 로 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개 이상 — `<details>` 면 JS 가 필요 없다
키보드 조작·`Esc`·포커스 이동이 브라우저 기본으로 온다.
직접 만든 토글은 그걸 전부 다시 구현해야 하고, 대개 빠뜨린다.
```html
<details class="nav-mobile">
<summary aria-label="메뉴"></summary>
<div class="nav-sheet"><a href="#a"></a></div>
</details>
```
```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를 꺼도 읽힌다

View file

@ -270,29 +270,6 @@ 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. 라이선스
**배포 전에 확인한다.** 폰트 라이선스 위반은 조용히 있다가 청구서로 온다.