Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
|
@ -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:00–19: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>
|
|
||||||
|
|
@ -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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 684 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
|
@ -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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -108,34 +108,34 @@ export interface Content {
|
||||||
const STAGES_KO: Stage[] = [
|
const STAGES_KO: Stage[] = [
|
||||||
{
|
{
|
||||||
name: "브리프 게이트",
|
name: "브리프 게이트",
|
||||||
does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축합니다. 규모를 판정해 경로를 고릅니다.",
|
does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축한다. 규모를 판정해 경로를 고른다.",
|
||||||
becomes: "누구에게: 홈카페 3년차 이상. 산미와 로스팅 프로파일을 구분합니다",
|
becomes: "누구에게: 홈카페 3년차 이상. 산미와 로스팅 프로파일을 구분한다",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "레퍼런스 조사",
|
name: "레퍼런스 조사",
|
||||||
does: "실제로 존재하는 사이트 3개를 서로 다른 층위에서 고릅니다. 구조, 톤, 디테일.",
|
does: "실제로 존재하는 사이트 3개를 서로 다른 층위에서 고른다. 구조, 톤, 디테일.",
|
||||||
becomes: "R2 = 독립 문학 출판사. 커피 업계 밖에서 톤을 가져옵니다",
|
becomes: "R2 = 독립 문학 출판사. 커피 업계 밖에서 톤을 가져온다",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "방향 결정",
|
name: "방향 결정",
|
||||||
does: "레퍼런스를 하나의 축으로 합성합니다. 감수할 리스크를 하나 고릅니다.",
|
does: "레퍼런스를 하나의 축으로 합성한다. 감수할 리스크를 하나 고른다.",
|
||||||
becomes: "감상은 이름에만, 화면에는 수치만",
|
becomes: "감상은 이름에만, 화면에는 수치만",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "디자인 토큰",
|
name: "디자인 토큰",
|
||||||
does: "코드를 쓰기 전에 숫자를 정합니다. 성능 예산도 여기서 확정합니다.",
|
does: "코드를 쓰기 전에 숫자를 정한다. 성능 예산도 여기서 확정한다.",
|
||||||
becomes: "--measure: 33em / --leading-normal: 1.7 / JS 예산 0KB",
|
becomes: "--measure: 33em / --leading-normal: 1.7 / JS 예산 0KB",
|
||||||
mono: true,
|
mono: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "구현",
|
name: "구현",
|
||||||
does: "레이아웃, 재질, 입체, 모션 순서로. 이펙트를 다 꺼도 완성돼 있어야 합니다.",
|
does: "레이아웃, 재질, 입체, 모션 순서로. 이펙트를 다 꺼도 완성돼 있어야 한다.",
|
||||||
becomes: "<h1>산미 8.5 · 바디 6.0 · 로스팅 라이트</h1>",
|
becomes: "<h1>산미 8.5 · 바디 6.0 · 로스팅 라이트</h1>",
|
||||||
mono: true,
|
mono: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "프리플라이트",
|
name: "프리플라이트",
|
||||||
does: "자기 결과물을 남의 것처럼 봅니다. 게이트 12개는 전부 '아니오'여야 합니다.",
|
does: "자기 결과물을 남의 것처럼 본다. 게이트 12개는 전부 '아니오'여야 한다.",
|
||||||
becomes: "게이트 12/12 통과 · 슬롭 지문 0건 · 320px 오버플로 31px 수정",
|
becomes: "게이트 12/12 통과 · 슬롭 지문 0건 · 320px 오버플로 31px 수정",
|
||||||
mono: true,
|
mono: true,
|
||||||
},
|
},
|
||||||
|
|
@ -181,17 +181,17 @@ export const ko: Content = {
|
||||||
meta: {
|
meta: {
|
||||||
title: "designpaca · 웹 디자인 파이프라인 스킬",
|
title: "designpaca · 웹 디자인 파이프라인 스킬",
|
||||||
description:
|
description:
|
||||||
"AI 코딩 에이전트에게 웹 디자인을 순서대로 하게 만드는 스킬. 레퍼런스 조사부터 셀프 감사까지 여섯 단계. Claude Code, Codex, Cursor, Windsurf 에 한 줄로 설치합니다.",
|
"AI 코딩 에이전트에게 웹 디자인을 순서대로 하게 만드는 스킬. 레퍼런스 조사부터 셀프 감사까지 여섯 단계. Claude Code, Codex, Cursor, Windsurf 에 한 줄로 설치한다.",
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
h1: "결정하지 않으면 기본값이 나온다",
|
h1: "결정하지 않으면 기본값이 나온다",
|
||||||
lead: "AI가 만든 티는 못 만들어서가 아니라 결정을 안 해서 납니다. 그 결정을 여섯 단계로 강제하는 스킬.",
|
lead: "AI가 만든 티는 못 만들어서가 아니라 결정을 안 해서 난다. 그 결정을 여섯 단계로 강제하는 스킬.",
|
||||||
install: "npx designpaca",
|
install: "npx designpaca",
|
||||||
installNote: "Node 20.11+ · 설치 대상을 감지해 골라줍니다",
|
installNote: "Node 20.11+ · 설치 대상을 감지해 골라준다",
|
||||||
links: [{ label: "npm", href: "https://www.npmjs.com/package/designpaca" }],
|
links: [{ label: "npm", href: "https://www.npmjs.com/package/designpaca" }],
|
||||||
stage: {
|
stage: {
|
||||||
alt: "유리판 여섯 장이 깊이 방향으로 나란히 서 있습니다. 스크롤하면 카메라가 판을 하나씩 통과하고, 통과한 판은 뒤로 물러납니다.",
|
alt: "유리판 여섯 장이 깊이 방향으로 나란히 서 있다. 스크롤하면 카메라가 판을 하나씩 통과하고, 통과한 판은 뒤로 물러난다.",
|
||||||
caption: "판 하나가 단계 하나다. 통과 조건을 채우지 못하면 다음 판으로 가지 않습니다.",
|
caption: "판 하나가 단계 하나다. 통과 조건을 채우지 못하면 다음 판으로 가지 않는다.",
|
||||||
gate: "게이트",
|
gate: "게이트",
|
||||||
pass: "통과",
|
pass: "통과",
|
||||||
},
|
},
|
||||||
|
|
@ -200,7 +200,7 @@ export const ko: Content = {
|
||||||
eyebrow: "여섯 단계",
|
eyebrow: "여섯 단계",
|
||||||
h2: "브리프 한 줄이 검증된 페이지가 될 때까지",
|
h2: "브리프 한 줄이 검증된 페이지가 될 때까지",
|
||||||
lead:
|
lead:
|
||||||
"각 단계에는 통과 조건이 있습니다. 통과하지 못하면 다음으로 가지 않습니다. 아래는 같은 브리프가 단계마다 무엇으로 변했는지다.",
|
"각 단계에는 통과 조건이 있다. 통과하지 못하면 다음으로 가지 않는다. 아래는 같은 브리프가 단계마다 무엇으로 변했는지다.",
|
||||||
briefLabel: "들어온 브리프",
|
briefLabel: "들어온 브리프",
|
||||||
brief: "수제 커피 로스터리의 원두 정기구독 랜딩 만들어줘",
|
brief: "수제 커피 로스터리의 원두 정기구독 랜딩 만들어줘",
|
||||||
stages: STAGES_KO,
|
stages: STAGES_KO,
|
||||||
|
|
@ -208,17 +208,17 @@ export const ko: Content = {
|
||||||
showcase: {
|
showcase: {
|
||||||
h2: "결과물은 서로 닮지 않는다",
|
h2: "결과물은 서로 닮지 않는다",
|
||||||
lead:
|
lead:
|
||||||
"같은 파이프라인을 두 브리프에 돌린 결과다. 1단계에서 톤을 다른 업종에서 가져오게 강제하기 때문에 같은 도구를 써도 같은 화면이 나오지 않습니다. 둘 다 수치 자리가 비어 있는 것도 우연이 아닙니다.",
|
"같은 파이프라인을 두 브리프에 돌린 결과다. 1단계에서 톤을 다른 업종에서 가져오게 강제하기 때문에 같은 도구를 써도 같은 화면이 나오지 않는다. 둘 다 수치 자리가 비어 있는 것도 우연이 아니다.",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
alt: "무월 원두 구독 랜딩. 오프화이트 배경에 굵은 한글 헤드라인, 오른쪽에 원두 3종의 산미와 바디 표가 있습니다.",
|
alt: "무월 원두 구독 랜딩. 오프화이트 배경에 굵은 한글 헤드라인, 오른쪽에 원두 3종의 산미와 바디 표가 있다.",
|
||||||
caption: "수제 커피 로스터리 · 구독 전환",
|
caption: "수제 커피 로스터리 · 구독 전환",
|
||||||
note: "프리셋 quiet-commerce. 사진이 없습니다는 제약을 수치표로 뒤집었습니다. 표의 값은 아직 비어 있습니다. 로스터에게 받기 전까지 채우지 않습니다.",
|
note: "프리셋 quiet-commerce. 사진이 없다는 제약을 수치표로 뒤집었다. 표의 값은 아직 비어 있다. 로스터에게 받기 전까지 채우지 않는다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alt: "Pulsegate 모니터링 SaaS 히어로. 밝은 배경에 큰 세리프 헤드라인과 무료 체험 버튼이 있습니다.",
|
alt: "Pulsegate 모니터링 SaaS 히어로. 밝은 배경에 큰 세리프 헤드라인과 무료 체험 버튼이 있다.",
|
||||||
caption: "API 모니터링 SaaS · 무료 체험 가입",
|
caption: "API 모니터링 SaaS · 무료 체험 가입",
|
||||||
note: "개발자 도구라고 자동으로 다크로 가지 않았습니다. 브리프에 근거가 없었기 때문입니다. 리전 수는 아직 중괄호 자리로 남아 있습니다.",
|
note: "개발자 도구라고 자동으로 다크로 가지 않았다. 브리프에 근거가 없었기 때문이다. 리전 수는 아직 중괄호 자리로 남아 있다.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -226,43 +226,43 @@ export const ko: Content = {
|
||||||
eyebrow: "SVG 필터",
|
eyebrow: "SVG 필터",
|
||||||
h2: "이미지 편집기를 열지 않고 고치는 네 가지",
|
h2: "이미지 편집기를 열지 않고 고치는 네 가지",
|
||||||
lead:
|
lead:
|
||||||
"질감을 만드는 필터 데모는 많다. 손이 실제로 줄어드는 자리는 따로 있습니다. 아래 넷은 왼쪽이 매번 손으로 맞추던 것, 오른쪽이 같은 소재에 선언 하나만 얹은 것입니다. 아래 수치는 이 화면을 그대로 캡처해 잰 값이다.",
|
"질감을 만드는 필터 데모는 많다. 손이 실제로 줄어드는 자리는 따로 있다. 아래 넷은 왼쪽이 매번 손으로 맞추던 것, 오른쪽이 같은 소재에 선언 하나만 얹은 것이다. 아래 수치는 이 화면을 그대로 캡처해 잰 값이다.",
|
||||||
demos: [
|
demos: [
|
||||||
{
|
{
|
||||||
id: "scrim",
|
id: "scrim",
|
||||||
name: "글자 모양대로 생기는 스크림",
|
name: "글자 모양대로 생기는 스크림",
|
||||||
problem: "사진 위 캡션이 대비를 못 넘깁니다. 사진마다 밝기가 달라 반투명 박스를 손으로 맞추게 됩니다.",
|
problem: "사진 위 캡션이 대비를 못 넘긴다. 사진마다 밝기가 달라 반투명 박스를 손으로 맞추게 된다.",
|
||||||
beforeLabel: "그냥 얹음",
|
beforeLabel: "그냥 얹음",
|
||||||
afterLabel: "filter: url(#scrim)",
|
afterLabel: "filter: url(#scrim)",
|
||||||
how: "글자 알파를 부풀려 그 모양대로만 색을 채웁니다. 박스가 아니라 글자를 따라가므로 사진을 덜 가립니다.",
|
how: "글자 알파를 부풀려 그 모양대로만 색을 채운다. 박스가 아니라 글자를 따라가므로 사진을 덜 가린다.",
|
||||||
verdict: "흰 글자 대비 1.70:1 → 4.79:1",
|
verdict: "흰 글자 대비 1.70:1 → 4.79:1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cvd",
|
id: "cvd",
|
||||||
name: "색각 이상 시뮬레이션",
|
name: "색각 이상 시뮬레이션",
|
||||||
problem: "상태를 색으로만 구분하면 남성 12명 중 1명에게 같은 배지가 됩니다. 내 화면만 봐서는 못 잡는다.",
|
problem: "상태를 색으로만 구분하면 남성 12명 중 1명에게 같은 배지가 된다. 내 화면만 봐서는 못 잡는다.",
|
||||||
beforeLabel: "내가 보는 화면",
|
beforeLabel: "내가 보는 화면",
|
||||||
afterLabel: "적록색약(2형)",
|
afterLabel: "적록색약(2형)",
|
||||||
how: "feColorMatrix 한 줄. 이것만은 linearRGB 로 둡니다 — 색각 변환은 물리적인 빛의 혼합이다.",
|
how: "feColorMatrix 한 줄. 이것만은 linearRGB 로 둔다 — 색각 변환은 물리적인 빛의 혼합이다.",
|
||||||
verdict: "배지 셋의 최소 색거리 82 → 2 · 주의와 실패가 같은 색",
|
verdict: "배지 셋의 최소 색거리 82 → 2 · 주의와 실패가 같은 색",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "goo",
|
id: "goo",
|
||||||
name: "떨어진 것이 하나로 뭉치기",
|
name: "떨어진 것이 하나로 뭉치기",
|
||||||
problem: "조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켜게 됩니다. 로더와 메뉴 전환에서 반복됩니다.",
|
problem: "조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켠다. 로더와 메뉴 전환에서 반복된다.",
|
||||||
beforeLabel: "필터 없음 · 원 3개",
|
beforeLabel: "필터 없음 · 원 3개",
|
||||||
afterLabel: "filter: url(#goo)",
|
afterLabel: "filter: url(#goo)",
|
||||||
how: "블러한 뒤 알파만 세워 잘라냅니다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해진다.",
|
how: "블러한 뒤 알파만 세워 잘라낸다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해진다.",
|
||||||
verdict: "덩어리 3개 → 1개 · JS 0줄",
|
verdict: "덩어리 3개 → 1개 · JS 0줄",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "glass",
|
id: "glass",
|
||||||
name: "배경을 휘게 하는 오버레이",
|
name: "배경을 휘게 하는 오버레이",
|
||||||
problem: "반투명 패널은 뒤를 흐리게만 만듭니다. 실제 유리는 빛을 휜다.",
|
problem: "반투명 패널은 뒤를 흐리게만 만든다. 실제 유리는 빛을 휜다.",
|
||||||
beforeLabel: "backdrop-filter: blur",
|
beforeLabel: "backdrop-filter: blur",
|
||||||
afterLabel: "blur + url(#lens)",
|
afterLabel: "blur + url(#lens)",
|
||||||
how: "변위 지도를 backdrop-filter 에 넘깁니다. 노이즈로 만들면 값이 중립으로 수렴해 아무 일도 안 일어납니다.",
|
how: "변위 지도를 backdrop-filter 에 넘긴다. 노이즈로 만들면 값이 중립으로 수렴해 아무 일도 안 일어난다.",
|
||||||
verdict: "가장자리에서 격자가 눌립니다 · Chromium 전용",
|
verdict: "가장자리에서 격자가 눌린다 · Chromium 전용",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -270,123 +270,122 @@ export const ko: Content = {
|
||||||
eyebrow: "이 페이지의 수치",
|
eyebrow: "이 페이지의 수치",
|
||||||
h2: "카피만 고쳐서는 슬롭이 없어지지 않는다",
|
h2: "카피만 고쳐서는 슬롭이 없어지지 않는다",
|
||||||
lead:
|
lead:
|
||||||
"문장을 다듬는 것으로 해결되는 것은 문장뿐입니다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남습니다. 그래서 이 페이지 자신의 형태 수치를 공개합니다. 결과가 나쁘게 나와도 그대로 싣는다.",
|
"문장을 다듬는 것으로 해결되는 것은 문장뿐이다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남는다. 그래서 이 페이지 자신의 형태 수치를 공개한다. 결과가 나쁘게 나와도 그대로 싣는다.",
|
||||||
items: [
|
items: [
|
||||||
{ label: "히어로까지 JS", value: "0.6%", note: "예산 50KB 중 실제로 쓴 비율", feature: true },
|
{ label: "히어로까지 JS", value: "0.6%", note: "예산 50KB 중 실제로 쓴 비율", feature: true },
|
||||||
{ label: "하드 게이트", value: "12/12", note: "정적 검사와 320px · 1440px 실렌더 양쪽", 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: "슬롭 지문", value: "0건", note: "9개 패턴 grep" },
|
||||||
{ label: "radius 어휘", value: "2종", note: "3px 과 14px. 알약 모양을 없앴습니다" },
|
{ label: "radius 어휘", value: "2종", note: "3px 과 14px. 알약 모양을 없앴다" },
|
||||||
{ label: "반투명 패널", value: "2개", note: "29개까지 늘렸다가 걷어냈습니다. 유리는 무언가를 담는 액자에만 남겼습니다" },
|
{ label: "반투명 패널", value: "2개", note: "29개까지 늘렸다가 걷어냈다. 유리는 무언가를 담는 액자에만 남겼다" },
|
||||||
{ label: "타입 단계", value: "6단계", note: "토큰에 정의된 수. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" },
|
{ label: "타입 단계", value: "6단계", note: "토큰에 정의된 수. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" },
|
||||||
{ label: "폰트 패밀리", value: "2개", note: "언어판마다 자기 것만 받습니다" },
|
{ label: "폰트 패밀리", value: "2개", note: "언어판마다 자기 것만 받는다" },
|
||||||
{ label: "강조색", value: "1개", note: "라이트와 다크는 같은 색의 대응값" },
|
{ label: "강조색", value: "1개", note: "라이트와 다크는 같은 색의 대응값" },
|
||||||
{ label: "첫 인터랙션", value: "미측정", note: "배포 후 Lighthouse 로 잽니다" },
|
{ label: "첫 인터랙션", value: "미측정", note: "배포 후 Lighthouse 로 잰다" },
|
||||||
],
|
],
|
||||||
caveat:
|
caveat:
|
||||||
"값은 프로덕션 빌드에서 측정합니다. 아직 측정하지 않은 항목은 비워둡니다. 지어낸 숫자보다 구멍이 정직하다. 이 페이지는 제품 데모이므로 three.js 를 얹으며 JS 예산을 올렸다. 올렸다는 사실을 여기 적는 것이 규칙이다.",
|
"값은 프로덕션 빌드에서 측정한다. 아직 측정하지 않은 항목은 비워둔다. 지어낸 숫자보다 구멍이 정직하다. 이 페이지는 제품 데모이므로 three.js 를 얹으며 JS 예산을 올렸다. 올렸다는 사실을 여기 적는 것이 규칙이다.",
|
||||||
},
|
},
|
||||||
preflight: {
|
preflight: {
|
||||||
h2: "이 페이지를 만들며 실제로 걸린 것",
|
h2: "이 페이지를 만들며 실제로 걸린 것",
|
||||||
lead: "남을 검사한 결과가 아니라 자신을 검사한 결과입니다.",
|
lead: "남을 검사한 결과가 아니라 자신을 검사한 결과다.",
|
||||||
items: [
|
items: [
|
||||||
|
|
||||||
{
|
{
|
||||||
caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었습니다",
|
caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었다",
|
||||||
fixed:
|
fixed:
|
||||||
"웹폰트가 오기 전에는 시스템 폰트로 그려진다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힙니다. 폴백 폰트에 size-adjust 로 치수를 맞췄다. 실측 차이 0.0%. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했습니다.",
|
"웹폰트가 오기 전에는 시스템 폰트로 그려진다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힌다. 폴백 폰트에 size-adjust 로 치수를 맞췄다. 실측 차이 0.0%. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
caught: "유리 카드를 29개 뿌렸습니다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였습니다",
|
caught: "유리 카드를 29개 뿌렸다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였다",
|
||||||
fixed:
|
fixed:
|
||||||
"우리 규칙이 '카드는 elevation 이 진짜 위계를 나를 때만'이라고 정해두고 있었습니다. 수치는 조판으로, 규칙은 어긋난 목록으로, 감사 기록은 로그로 바꿨습니다. 유리는 5개만 남았습니다.",
|
"우리 규칙이 '카드는 elevation 이 진짜 위계를 나를 때만'이라고 정해두고 있었다. 수치는 조판으로, 규칙은 어긋난 목록으로, 감사 기록은 로그로 바꿨다. 유리는 5개만 남았다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
caught: "“backdrop-filter 에 SVG 를 얹으면 Chrome 이 버린다” 고 적었는데 사실이 아니었습니다",
|
caught: "“backdrop-filter 에 SVG 를 얹으면 Chrome 이 버린다” 고 적었는데 사실이 아니었다",
|
||||||
fixed:
|
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:
|
fixed:
|
||||||
"main 에서 그리드 클래스가 빠져 있었습니다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였습니다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났다.",
|
"main 에서 그리드 클래스가 빠져 있었다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
caught: "스크롤을 끝까지 내려도 파이프라인 6단계 중 뒤 3개가 안 보였습니다",
|
caught: "스크롤을 끝까지 내려도 파이프라인 6단계 중 뒤 3개가 안 보였다",
|
||||||
fixed:
|
fixed:
|
||||||
"scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못합니다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다르다. 한 번 보이면 유지되는 방식으로 바꿨습니다.",
|
"scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못한다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다르다. 한 번 보이면 유지되는 방식으로 바꿨다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
caught: "다크 밴드 안 버튼의 대비가 2.88:1. 기준은 4.5:1",
|
caught: "다크 밴드 안 버튼의 대비가 2.88:1. 기준은 4.5:1",
|
||||||
fixed:
|
fixed:
|
||||||
"밴드가 강조색만 반전시키고 그 위 글자색은 흰색 그대로였습니다. 반전 대응 토큰을 하나 더 두고 함께 바꿨습니다.",
|
"밴드가 강조색만 반전시키고 그 위 글자색은 흰색 그대로였다. 반전 대응 토큰을 하나 더 두고 함께 바꿨다.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
note: "열세 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 셋이다. 카드가 전형적이라는 말, 폰트를 제대로 다루지 못합니다는 말, 그리고 우리 스킬을 진짜로 쓴 게 맞냐는 말.",
|
note: "열세 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 셋이다. 카드가 전형적이라는 말, 폰트를 제대로 다루지 못한다는 말, 그리고 우리 스킬을 진짜로 쓴 게 맞냐는 말.",
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
eyebrow: "한글 조판",
|
eyebrow: "한글 조판",
|
||||||
h2: "라틴 기본값은 한글에서 세 군데가 깨진다",
|
h2: "라틴 기본값은 한글에서 세 군데가 깨진다",
|
||||||
lead:
|
lead:
|
||||||
"영어권 디자인 도구에는 한글 조판 규칙이 없습니다. 그래서 기본값을 그대로 쓰면 아래 셋이 동시에 무너집니다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값이다. 글자도 폭도 폰트도 같습니다.",
|
"영어권 디자인 도구에는 한글 조판 규칙이 없다. 그래서 기본값을 그대로 쓰면 아래 셋이 동시에 무너진다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값이다. 글자도 폭도 폰트도 같다.",
|
||||||
sample: "스페셜티커피의 로스팅프로파일을 구분하려면 산미와 바디를 수치로 봐야 합니다.",
|
sample: "스페셜티커피의 로스팅프로파일을 구분하려면 산미와 바디를 수치로 봐야 한다.",
|
||||||
demos: [
|
demos: [
|
||||||
{
|
{
|
||||||
id: "break",
|
id: "break",
|
||||||
title: "줄바꿈",
|
title: "줄바꿈",
|
||||||
badLabel: "기본값",
|
badLabel: "기본값",
|
||||||
goodLabel: "word-break: keep-all",
|
goodLabel: "word-break: keep-all",
|
||||||
note: "기본값은 어절 한가운데를 끊는다. 왼쪽 마지막 줄을 보라 — 한 단어가 두 줄에 걸쳐 있습니다.",
|
note: "기본값은 어절 한가운데를 끊는다. 왼쪽 마지막 줄을 보라 — 한 단어가 두 줄에 걸쳐 있다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "leading",
|
id: "leading",
|
||||||
title: "행간",
|
title: "행간",
|
||||||
badLabel: "line-height 1.45",
|
badLabel: "line-height 1.45",
|
||||||
goodLabel: "line-height 1.75",
|
goodLabel: "line-height 1.75",
|
||||||
note: "라틴 표준 1.4~1.5 는 받침이 있는 글자에 좁습니다. 줄이 서로 붙어 보입니다.",
|
note: "라틴 표준 1.4~1.5 는 받침이 있는 글자에 좁다. 줄이 서로 붙어 보인다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "size",
|
id: "size",
|
||||||
title: "크기",
|
title: "크기",
|
||||||
badLabel: "11px",
|
badLabel: "11px",
|
||||||
goodLabel: "15px",
|
goodLabel: "15px",
|
||||||
note: "라틴에서 11px 은 라벨로 씁니다. 한글은 획이 많아 같은 크기에서 뭉갠다.",
|
note: "라틴에서 11px 은 라벨로 쓴다. 한글은 획이 많아 같은 크기에서 뭉갠다.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
rules: [
|
rules: [
|
||||||
"word-break: keep-all 없으면 단어가 아무 데서나 잘립니다",
|
"word-break: keep-all 없으면 단어가 아무 데서나 잘린다",
|
||||||
"line-height 1.6에서 1.8 사이. 라틴보다 넉넉하게",
|
"line-height 1.6에서 1.8 사이. 라틴보다 넉넉하게",
|
||||||
"음수 자간 금지. 한글은 이미 고정폭에 가까워 즉시 뭉개집니다",
|
"음수 자간 금지. 한글은 이미 고정폭에 가까워 즉시 뭉개진다",
|
||||||
"한 줄 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 깁니다",
|
"한 줄 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 길다",
|
||||||
"한글 폰트를 지정하지 않는 것 자체가 완성도 미달 신호입니다",
|
"한글 폰트를 지정하지 않는 것 자체가 완성도 미달 신호다",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
h2: "규칙마다 왜 그런지 적혀 있다",
|
h2: "규칙마다 왜 그런지 적혀 있다",
|
||||||
lead: "근거 없는 금지는 지켜지지 않습니다. 사용자가 명시적으로 요구하면 규칙보다 그쪽이 이깁니다.",
|
lead: "근거 없는 금지는 지켜지지 않는다. 사용자가 명시적으로 요구하면 규칙보다 그쪽이 이긴다.",
|
||||||
items: [
|
items: [
|
||||||
{ rule: "레퍼런스 없이 시작하지 않는다", why: "머릿속 기본값에서 출발하면 결과는 그 카테고리의 평균이 됩니다." },
|
{ rule: "레퍼런스 없이 시작하지 않는다", why: "머릿속 기본값에서 출발하면 결과는 그 카테고리의 평균이 된다." },
|
||||||
{ rule: "강조색은 하나다", why: "두 개가 필요하다고 느끼면 위계 설계가 실패한 것입니다." },
|
{ rule: "강조색은 하나다", why: "두 개가 필요하다고 느끼면 위계 설계가 실패한 것이다." },
|
||||||
{ rule: "대담함은 한 곳에만", why: "두 곳에서 소리치면 둘 다 죽습니다." },
|
{ rule: "대담함은 한 곳에만", why: "두 곳에서 소리치면 둘 다 죽는다." },
|
||||||
{ rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10입니다." },
|
{ rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10이다." },
|
||||||
{ rule: "지어낸 수치를 쓰지 않는다", why: "숫자 모양의 구멍은 정직하고, 지어낸 숫자는 신뢰를 깎습니다." },
|
{ rule: "지어낸 수치를 쓰지 않는다", why: "숫자 모양의 구멍은 정직하고, 지어낸 숫자는 신뢰를 깎는다." },
|
||||||
{ rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반이다. 무너지면 이펙트가 구조를 대신하고 있는 것입니다." },
|
{ rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반이다. 무너지면 이펙트가 구조를 대신하고 있는 것이다." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
install: {
|
install: {
|
||||||
h2: "한 줄이면 된다",
|
h2: "한 줄이면 된다",
|
||||||
lead: "설치 대상을 감지해 기본 선택해준다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않습니다.",
|
lead: "설치 대상을 감지해 기본 선택해준다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않는다.",
|
||||||
command: "npx designpaca",
|
command: "npx designpaca",
|
||||||
targets: [
|
targets: [
|
||||||
{ name: "Claude Code", path: "~/.claude/skills/designpaca/" },
|
{ name: "Claude Code", path: "~/.claude/skills/designpaca/" },
|
||||||
{ name: "Codex CLI", path: "~/.codex/skills/designpaca/" },
|
{ name: "Codex CLI", path: "~/.codex/skills/designpaca/" },
|
||||||
{ name: "Antigravity", path: ".agents/skills/designpaca/" },
|
|
||||||
{ name: "Cursor", path: ".cursor/rules/designpaca.mdc" },
|
{ name: "Cursor", path: ".cursor/rules/designpaca.mdc" },
|
||||||
{ name: "Windsurf", path: ".windsurf/rules/designpaca.md" },
|
{ name: "Windsurf", path: ".windsurf/rules/designpaca.md" },
|
||||||
{ name: "AGENTS.md", path: "블록 주입. 에이전트 무관" },
|
{ name: "AGENTS.md", path: "블록 주입. 에이전트 무관" },
|
||||||
],
|
],
|
||||||
commands: [
|
commands: [
|
||||||
{ cmd: "npx designpaca update", what: "최신 스킬로 갱신. 직접 고친 파일은 건너뜁니다" },
|
{ cmd: "npx designpaca update", what: "최신 스킬로 갱신. 직접 고친 파일은 건너뛴다" },
|
||||||
{ cmd: "npx designpaca doctor", what: "설치 상태와 드리프트 진단" },
|
{ cmd: "npx designpaca doctor", what: "설치 상태와 드리프트 진단" },
|
||||||
{ cmd: "npx designpaca uninstall", what: "매니페스트 기반 정확한 제거" },
|
{ cmd: "npx designpaca uninstall", what: "매니페스트 기반 정확한 제거" },
|
||||||
],
|
],
|
||||||
|
|
@ -394,7 +393,7 @@ export const ko: Content = {
|
||||||
nav: {
|
nav: {
|
||||||
items: [
|
items: [
|
||||||
{ label: "파이프라인", href: "#pipeline" },
|
{ label: "파이프라인", href: "#pipeline" },
|
||||||
{ label: "사례", href: "/showcase" },
|
{ label: "사례", href: "#showcase" },
|
||||||
{ label: "수치", href: "#metrics" },
|
{ label: "수치", href: "#metrics" },
|
||||||
{ label: "설치", href: "#install" },
|
{ label: "설치", href: "#install" },
|
||||||
],
|
],
|
||||||
|
|
@ -647,7 +646,6 @@ export const en: Content = {
|
||||||
targets: [
|
targets: [
|
||||||
{ name: "Claude Code", path: "~/.claude/skills/designpaca/" },
|
{ name: "Claude Code", path: "~/.claude/skills/designpaca/" },
|
||||||
{ name: "Codex CLI", path: "~/.codex/skills/designpaca/" },
|
{ name: "Codex CLI", path: "~/.codex/skills/designpaca/" },
|
||||||
{ name: "Antigravity", path: ".agents/skills/designpaca/" },
|
|
||||||
{ name: "Cursor", path: ".cursor/rules/designpaca.mdc" },
|
{ name: "Cursor", path: ".cursor/rules/designpaca.mdc" },
|
||||||
{ name: "Windsurf", path: ".windsurf/rules/designpaca.md" },
|
{ name: "Windsurf", path: ".windsurf/rules/designpaca.md" },
|
||||||
{ name: "AGENTS.md", path: "block injection, agent agnostic" },
|
{ name: "AGENTS.md", path: "block injection, agent agnostic" },
|
||||||
|
|
@ -661,7 +659,7 @@ export const en: Content = {
|
||||||
nav: {
|
nav: {
|
||||||
items: [
|
items: [
|
||||||
{ label: "Pipeline", href: "#pipeline" },
|
{ label: "Pipeline", href: "#pipeline" },
|
||||||
{ label: "Work", href: "/showcase" },
|
{ label: "Work", href: "#showcase" },
|
||||||
{ label: "Numbers", href: "#metrics" },
|
{ label: "Numbers", href: "#metrics" },
|
||||||
{ label: "Install", href: "#install" },
|
{ label: "Install", href: "#install" },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import Base from "../../layouts/Base.astro";
|
import Base from "../../layouts/Base.astro";
|
||||||
import Hero from "../../components/Hero.astro";
|
import Hero from "../../components/Hero.astro";
|
||||||
import Pipeline from "../../components/Pipeline.astro";
|
import Pipeline from "../../components/Pipeline.astro";
|
||||||
|
import Showcase from "../../components/Showcase.astro";
|
||||||
import Materials from "../../components/Materials.astro";
|
import Materials from "../../components/Materials.astro";
|
||||||
import Metrics from "../../components/Metrics.astro";
|
import Metrics from "../../components/Metrics.astro";
|
||||||
import Preflight from "../../components/Preflight.astro";
|
import Preflight from "../../components/Preflight.astro";
|
||||||
|
|
@ -19,7 +20,7 @@ import { en as c } from "../../i18n/content";
|
||||||
|
|
||||||
① 무엇인가 히어로 · 파이프라인
|
① 무엇인가 히어로 · 파이프라인
|
||||||
② 무엇을 아는가 SVG 필터 · 한글 조판
|
② 무엇을 아는가 SVG 필터 · 한글 조판
|
||||||
③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다)
|
③ 정말 그런가 사례 · 수치 · 자기 감사 · 규칙
|
||||||
④ 행동 설치
|
④ 행동 설치
|
||||||
*/}
|
*/}
|
||||||
<Hero c={c.hero} ui={c.ui} />
|
<Hero c={c.hero} ui={c.ui} />
|
||||||
|
|
@ -28,6 +29,7 @@ import { en as c } from "../../i18n/content";
|
||||||
<Materials c={c.materials} />
|
<Materials c={c.materials} />
|
||||||
<Typography c={c.typography} />
|
<Typography c={c.typography} />
|
||||||
|
|
||||||
|
<Showcase c={c.showcase} />
|
||||||
<Metrics c={c.metrics} />
|
<Metrics c={c.metrics} />
|
||||||
<Preflight c={c.preflight} />
|
<Preflight c={c.preflight} />
|
||||||
<Rules c={c.rules} />
|
<Rules c={c.rules} />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import Base from "../layouts/Base.astro";
|
import Base from "../layouts/Base.astro";
|
||||||
import Hero from "../components/Hero.astro";
|
import Hero from "../components/Hero.astro";
|
||||||
import Pipeline from "../components/Pipeline.astro";
|
import Pipeline from "../components/Pipeline.astro";
|
||||||
|
import Showcase from "../components/Showcase.astro";
|
||||||
import Materials from "../components/Materials.astro";
|
import Materials from "../components/Materials.astro";
|
||||||
import Metrics from "../components/Metrics.astro";
|
import Metrics from "../components/Metrics.astro";
|
||||||
import Preflight from "../components/Preflight.astro";
|
import Preflight from "../components/Preflight.astro";
|
||||||
|
|
@ -19,7 +20,7 @@ import { ko as c } from "../i18n/content";
|
||||||
|
|
||||||
① 무엇인가 히어로 · 파이프라인
|
① 무엇인가 히어로 · 파이프라인
|
||||||
② 무엇을 아는가 SVG 필터 · 한글 조판
|
② 무엇을 아는가 SVG 필터 · 한글 조판
|
||||||
③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다)
|
③ 정말 그런가 사례 · 수치 · 자기 감사 · 규칙
|
||||||
④ 행동 설치
|
④ 행동 설치
|
||||||
*/}
|
*/}
|
||||||
<Hero c={c.hero} ui={c.ui} />
|
<Hero c={c.hero} ui={c.ui} />
|
||||||
|
|
@ -28,6 +29,7 @@ import { ko as c } from "../i18n/content";
|
||||||
<Materials c={c.materials} />
|
<Materials c={c.materials} />
|
||||||
<Typography c={c.typography} />
|
<Typography c={c.typography} />
|
||||||
|
|
||||||
|
<Showcase c={c.showcase} />
|
||||||
<Metrics c={c.metrics} />
|
<Metrics c={c.metrics} />
|
||||||
<Preflight c={c.preflight} />
|
<Preflight c={c.preflight} />
|
||||||
<Rules c={c.rules} />
|
<Rules c={c.rules} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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}개 문자열`);
|
|
||||||
|
|
@ -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 와 같아 변환 없이 그대로 복사된다.
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
# designpaca
|
# designpaca
|
||||||
|
|
||||||
**웹 디자인 파이프라인 스킬을 어떤 에이전트 하네스에든 한 줄로 설치한다.**
|
**웹 디자인 파이프라인 스킬을 Claude Code · Codex · Cursor 에 한 줄로 설치한다.**
|
||||||
|
|
||||||
Agent Skills 규약(SKILL.md)을 그대로 따르므로, 규약을 읽는 하네스라면 종류를 가리지 않는다.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx designpaca
|
npx designpaca
|
||||||
|
|
@ -55,19 +53,11 @@ npx designpaca install -t claude-code,codex -s user -y
|
||||||
npx designpaca install -t cursor -s project
|
npx designpaca install -t cursor -s project
|
||||||
```
|
```
|
||||||
|
|
||||||
모든 타깃이 같은 SKILL.md 를 쓴다. 중립 경로(`agents`)는 이 규약을 읽는 하네스라면 무엇이든 잡는다.
|
|
||||||
|
|
||||||
| 대상 | 설치 위치 |
|
| 대상 | 설치 위치 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `claude-code` | `~/.claude/skills/designpaca/` (또는 프로젝트 `.claude/`) |
|
| `claude-code` | `~/.claude/skills/designpaca/` (또는 프로젝트 `.claude/`) |
|
||||||
| `codex` | `~/.codex/skills/designpaca/` (또는 프로젝트 `.codex/`) |
|
| `codex` | `~/.codex/skills/designpaca/` |
|
||||||
| `cursor` | `~/.cursor/skills/designpaca/` (또는 프로젝트 `.cursor/`) |
|
| `cursor` | `.cursor/rules/designpaca.mdc` (프로젝트 단위) |
|
||||||
| `zcode` | `~/.zcode/skills/designpaca/` (또는 프로젝트 `.zcode/`) |
|
|
||||||
| `agents` | `~/.agents/skills/designpaca/` (또는 프로젝트 `.agents/`) — **중립 경로** |
|
|
||||||
| `antigravity` | 전역 `~/.gemini/antigravity-cli/skills/` · 워크스페이스 `.agents/skills/` |
|
|
||||||
| `gemini-cli` | `~/.gemini/skills/designpaca/` (또는 프로젝트 `.gemini/`) |
|
|
||||||
| `windsurf` | `~/.codeium/windsurf/skills/designpaca/` (또는 프로젝트 `.windsurf/`) |
|
|
||||||
| `copilot` | `~/.copilot/skills/designpaca/` (또는 프로젝트 `.github/skills/`) |
|
|
||||||
| `agents-md` | `AGENTS.md` 에 블록 주입 |
|
| `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` |
|
| `-s, --scope <범위>` | `user` (전역) \| `project` |
|
||||||
| `-y, --yes` | 확인 없이 진행 |
|
| `-y, --yes` | 확인 없이 진행 |
|
||||||
| `-f, --force` | 직접 수정한 파일도 덮어쓴다 (`.orig` 백업) |
|
| `-f, --force` | 직접 수정한 파일도 덮어쓴다 (`.orig` 백업) |
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,16 @@
|
||||||
{
|
{
|
||||||
"name": "designpaca",
|
"name": "designpaca",
|
||||||
"version": "0.5.0",
|
"version": "0.1.0",
|
||||||
"description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다",
|
"description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다",
|
||||||
"keywords": [
|
"keywords": ["design", "web-design", "claude-code", "codex", "cursor", "agent-skill", "svg-filter", "threejs"],
|
||||||
"design",
|
|
||||||
"web-design",
|
|
||||||
"claude-code",
|
|
||||||
"codex",
|
|
||||||
"cursor",
|
|
||||||
"agent-skill",
|
|
||||||
"svg-filter",
|
|
||||||
"threejs"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Yun Chan",
|
"author": "Yun Chan",
|
||||||
"homepage": "https://designpaca.chanpaca.net",
|
"homepage": "https://designpaca.chanpaca.net",
|
||||||
"repository": {
|
"repository": { "type": "git", "url": "git+https://git.chanpaca.net/yunchan/designpaca.git" },
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://git.chanpaca.net/yunchan/designpaca.git"
|
|
||||||
},
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": { "designpaca": "./dist/index.js" },
|
||||||
"designpaca": "./dist/index.js"
|
"files": ["dist", "README.md"],
|
||||||
},
|
"engines": { "node": ">=20.11" },
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"README.md"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.11"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/bundle-skill.mjs && tsup",
|
"build": "node scripts/bundle-skill.mjs && tsup",
|
||||||
"dev": "node scripts/bundle-skill.mjs && tsup --watch",
|
"dev": "node scripts/bundle-skill.mjs && tsup --watch",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export async function runDoctor(): Promise<number> {
|
||||||
if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`));
|
if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`));
|
||||||
|
|
||||||
if (manifest.installs.length === 0) {
|
if (manifest.installs.length === 0) {
|
||||||
console.log(warn("아직 설치된 곳이 없습니다. `npx designpaca install` 로 설치하실 수 있습니다."));
|
console.log(warn("설치된 곳이 없다. `npx designpaca install` 로 설치해라."));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ export async function runDoctor(): Promise<number> {
|
||||||
if (rec.version !== version) problems++;
|
if (rec.version !== version) problems++;
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
problems++;
|
problems++;
|
||||||
console.log(bad(`파일 ${missing.length}개가 보이지 않습니다 — \`designpaca update --force\` 로 복구하실 수 있습니다`));
|
console.log(bad(`파일 ${missing.length}개가 사라졌다 — \`designpaca update --force\` 로 복구해라`));
|
||||||
for (const m of missing.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
|
for (const m of missing.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
|
||||||
}
|
}
|
||||||
if (modified.length > 0) {
|
if (modified.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void
|
||||||
const verb = dryRun ? "쓸 파일" : "설치됨";
|
const verb = dryRun ? "쓸 파일" : "설치됨";
|
||||||
console.log(ok(`${o.target} — ${verb} ${o.written}개 ${dim(tildify(o.root))}`));
|
console.log(ok(`${o.target} — ${verb} ${o.written}개 ${dim(tildify(o.root))}`));
|
||||||
if (o.skipped.length > 0) {
|
if (o.skipped.length > 0) {
|
||||||
console.log(warn(` 직접 수정하신 파일이라 그대로 두었습니다 (--force 로 덮어쓸 수 있습니다):`));
|
console.log(warn(` 직접 수정한 파일이라 건드리지 않았다 (--force 로 덮어쓴다):`));
|
||||||
for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`));
|
for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,41 +70,16 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void
|
||||||
export function printNextSteps(targets: TargetId[]): void {
|
export function printNextSteps(targets: TargetId[]): void {
|
||||||
console.log(heading("다음 단계"));
|
console.log(heading("다음 단계"));
|
||||||
if (targets.includes("claude-code")) {
|
if (targets.includes("claude-code")) {
|
||||||
console.log(info(`Claude Code 를 새로 연 뒤 ${dim("/designpaca")} 를 실행해 주세요`));
|
console.log(info(`Claude Code 를 새로 열고 ${dim("/designpaca")} 를 실행해라`));
|
||||||
}
|
}
|
||||||
if (targets.includes("codex")) {
|
if (targets.includes("codex")) {
|
||||||
console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`));
|
console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`));
|
||||||
}
|
}
|
||||||
if (targets.includes("zcode")) {
|
|
||||||
console.log(info(`ZCode 는 새 세션부터 designpaca 스킬이 자동으로 잡힌다`));
|
|
||||||
}
|
|
||||||
if (targets.includes("cursor")) {
|
if (targets.includes("cursor")) {
|
||||||
console.log(info(`Cursor 는 새 창을 열 때 ${dim(".cursor/skills/designpaca")} 스킬을 읽는다`));
|
console.log(info(`Cursor 는 ${dim(".cursor/rules/designpaca.mdc")} 를 프로젝트 열 때 읽는다`));
|
||||||
}
|
|
||||||
if (targets.includes("agents")) {
|
|
||||||
console.log(
|
|
||||||
info(
|
|
||||||
`${dim(".agents/skills")} 는 중립 경로다 — Cursor · Antigravity · ZCode 등 규약을 읽는 하네스가 함께 잡는다`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (targets.includes("antigravity")) {
|
|
||||||
console.log(
|
|
||||||
info(`Antigravity 는 전역 ${dim("~/.gemini/antigravity-cli/skills")} 를 읽는다 (워크스페이스는 .agents/skills)`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (targets.includes("gemini-cli")) {
|
|
||||||
console.log(info(`Gemini CLI 는 ${dim("~/.gemini/skills")} 에서 스킬을 읽는다`));
|
|
||||||
}
|
}
|
||||||
if (targets.includes("windsurf")) {
|
if (targets.includes("windsurf")) {
|
||||||
console.log(
|
console.log(info(`Windsurf 는 ${dim(".windsurf/rules/designpaca.md")} 를 읽는다`));
|
||||||
info(`Windsurf(Cascade) 는 ${dim(".windsurf/skills/designpaca")} 스킬을 읽는다`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (targets.includes("copilot")) {
|
|
||||||
console.log(
|
|
||||||
info(`GitHub Copilot 은 ${dim(".github/skills")} (~/.copilot/skills) 의 SKILL.md 를 읽는다`),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (targets.includes("agents-md")) {
|
if (targets.includes("agents-md")) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,7 @@ export async function runUninstall(opts: {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
console.log(warn("제거할 설치 기록이 없습니다"));
|
console.log(warn("제거할 설치 기록이 없다"));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise<number>
|
||||||
const manifest = await readManifest();
|
const manifest = await readManifest();
|
||||||
|
|
||||||
if (manifest.installs.length === 0) {
|
if (manifest.installs.length === 0) {
|
||||||
console.log(warn("설치 기록이 없습니다. `npx designpaca install` 을 먼저 실행해 주세요."));
|
console.log(warn("설치 기록이 없다. `npx designpaca install` 을 먼저 실행해라."));
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise<number>
|
||||||
const stale = manifest.installs.filter((i) => i.version !== version);
|
const stale = manifest.installs.filter((i) => i.version !== version);
|
||||||
|
|
||||||
if (stale.length === 0) {
|
if (stale.length === 0) {
|
||||||
console.log(ok("설치된 스킬이 모두 최신 버전입니다"));
|
console.log(ok("모든 설치가 이미 최신이다"));
|
||||||
|
|
||||||
// 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다.
|
// 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다.
|
||||||
let broken = 0;
|
let broken = 0;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { getVersion } from "./skill.ts";
|
||||||
import { checkForUpdate } from "./update-check.ts";
|
import { checkForUpdate } from "./update-check.ts";
|
||||||
import { printNextSteps, printOutcomes, runInstall } from "./commands/install.ts";
|
import { printNextSteps, printOutcomes, runInstall } from "./commands/install.ts";
|
||||||
import { runDoctor } from "./commands/doctor.ts";
|
import { runDoctor } from "./commands/doctor.ts";
|
||||||
import { runTools } from "./commands/tools.ts";
|
|
||||||
import { runUpdate } from "./commands/update.ts";
|
import { runUpdate } from "./commands/update.ts";
|
||||||
import { runUninstall } from "./commands/uninstall.ts";
|
import { runUninstall } from "./commands/uninstall.ts";
|
||||||
import { runList } from "./commands/list.ts";
|
import { runList } from "./commands/list.ts";
|
||||||
|
|
@ -71,15 +70,14 @@ ${bold("사용법")}
|
||||||
npx designpaca uninstall 제거
|
npx designpaca uninstall 제거
|
||||||
npx designpaca doctor 설치 상태 진단
|
npx designpaca doctor 설치 상태 진단
|
||||||
npx designpaca list 설치 가능 대상과 현재 설치 목록
|
npx designpaca list 설치 가능 대상과 현재 설치 목록
|
||||||
npx designpaca tools 브라우저 도구 상태 확인 ${dim("(--yes 로 설치)")}
|
|
||||||
|
|
||||||
${bold("옵션")}
|
${bold("옵션")}
|
||||||
-t, --target <id[,id]> 설치 대상: ${VALID_TARGETS.join(", ")}
|
-t, --target <id[,id]> 설치 대상: ${VALID_TARGETS.join(", ")}
|
||||||
-s, --scope <범위> user | project ${dim("(기본: user)")}
|
-s, --scope <범위> user | project ${dim("(기본: user)")}
|
||||||
-y, --yes 확인 없이 진행합니다
|
-y, --yes 확인 없이 진행
|
||||||
-f, --force 직접 수정한 파일도 덮어씁니다 ${dim("(.orig 로 백업)")}
|
-f, --force 직접 수정한 파일도 덮어쓴다 ${dim("(.orig 로 백업)")}
|
||||||
--dry-run 쓰지 않고 계획만 보여줍니다
|
--dry-run 쓰지 않고 계획만 출력
|
||||||
--no-update-check 새 버전 확인을 건너뜁니다
|
--no-update-check 새 버전 확인을 건너뛴다
|
||||||
-h, --help 이 도움말
|
-h, --help 이 도움말
|
||||||
-v, --version 버전
|
-v, --version 버전
|
||||||
|
|
||||||
|
|
@ -87,7 +85,6 @@ ${bold("예시")}
|
||||||
${dim("npx designpaca install -t claude-code,codex -s user -y")}
|
${dim("npx designpaca install -t claude-code,codex -s user -y")}
|
||||||
${dim("npx designpaca install -t cursor -s project")}
|
${dim("npx designpaca install -t cursor -s project")}
|
||||||
${dim("npx designpaca update --force")}
|
${dim("npx designpaca update --force")}
|
||||||
${dim("npx designpaca tools --yes")}
|
|
||||||
`.trimStart();
|
`.trimStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,16 +146,12 @@ async function main(): Promise<number> {
|
||||||
case "doctor":
|
case "doctor":
|
||||||
code = await runDoctor();
|
code = await runDoctor();
|
||||||
break;
|
break;
|
||||||
case "tools":
|
|
||||||
// 수백 MB 를 받는 일이라 --yes 없이는 상태만 보여준다
|
|
||||||
code = await runTools({ install: args.yes });
|
|
||||||
break;
|
|
||||||
case "list":
|
case "list":
|
||||||
case "ls":
|
case "ls":
|
||||||
code = await runList();
|
code = await runList();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.error(bad(`알 수 없는 명령입니다: ${args.command}`));
|
console.error(bad(`알 수 없는 명령: ${args.command}`));
|
||||||
console.log(usage(version));
|
console.log(usage(version));
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +160,7 @@ async function main(): Promise<number> {
|
||||||
const latest = await checkForUpdate(version);
|
const latest = await checkForUpdate(version);
|
||||||
if (latest) {
|
if (latest) {
|
||||||
console.log(
|
console.log(
|
||||||
"\n" + warn(`새 버전 v${latest} 이 나왔습니다 — ${dim("npx designpaca@latest update")}`),
|
"\n" + warn(`새 버전 ${bold(`v${latest}`)} 이 있다 — ${dim("npx designpaca@latest update")}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return code;
|
return code;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import * as p from "@clack/prompts";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
ADAPTERS,
|
ADAPTERS,
|
||||||
checkTools,
|
|
||||||
detectTargets,
|
detectTargets,
|
||||||
getAdapter,
|
getAdapter,
|
||||||
planInstall,
|
planInstall,
|
||||||
|
|
@ -28,21 +27,19 @@ export async function onboard(): Promise<number> {
|
||||||
|
|
||||||
p.note(
|
p.note(
|
||||||
[
|
[
|
||||||
"웹 디자인을 순서대로 진행하게 만드는 스킬입니다.",
|
"브리프에서 시작해 레퍼런스 조사 · 방향 결정 · 디자인 토큰 ·",
|
||||||
"브리프 정리 → 레퍼런스 조사 → 방향 결정 → 디자인 토큰 →",
|
"구현 · 셀프 감사까지 끌고 가는 웹 디자인 파이프라인 스킬.",
|
||||||
"구현 → 셀프 감사까지, 단계마다 통과 조건을 두고 진행합니다.",
|
|
||||||
"",
|
"",
|
||||||
dim("설치하면 에이전트에서 /designpaca 로 부를 수 있습니다."),
|
`${dim("SVG 필터 · three.js · 인터랙티브 모션을 기본 재료로 쓴다.")}`,
|
||||||
dim("스킬 파일만 복사하며, 기존 설정은 건드리지 않습니다."),
|
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"designpaca 는 무엇인가요",
|
"무엇을 설치하나",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다
|
// 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다
|
||||||
const detected = await detectTargets(skill);
|
const detected = await detectTargets(skill);
|
||||||
|
|
||||||
const targets = await p.multiselect<TargetId>({
|
const targets = await p.multiselect<TargetId>({
|
||||||
message: "어디에 설치할까요?",
|
message: "어디에 설치할까?",
|
||||||
options: ADAPTERS.map((a) => ({
|
options: ADAPTERS.map((a) => ({
|
||||||
value: a.id,
|
value: a.id,
|
||||||
label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""),
|
label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""),
|
||||||
|
|
@ -54,18 +51,10 @@ export async function onboard(): Promise<number> {
|
||||||
if (p.isCancel(targets)) return cancel();
|
if (p.isCancel(targets)) return cancel();
|
||||||
|
|
||||||
const wanted = await p.select<Scope>({
|
const wanted = await p.select<Scope>({
|
||||||
message: "어느 범위에 설치할까요?",
|
message: "설치 범위",
|
||||||
options: [
|
options: [
|
||||||
{
|
{ value: "user", label: "전역", hint: "홈 디렉터리 — 모든 프로젝트에서 쓴다" },
|
||||||
value: "user",
|
{ value: "project", label: "이 프로젝트만", hint: tildify(process.cwd()) },
|
||||||
label: "전역 (권장)",
|
|
||||||
hint: "홈 디렉터리에 두어 모든 프로젝트에서 쓸 수 있습니다",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "project",
|
|
||||||
label: "이 프로젝트만",
|
|
||||||
hint: `${tildify(process.cwd())} · 팀과 함께 쓰려면 이쪽이 좋습니다`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
initialValue: "user",
|
initialValue: "user",
|
||||||
});
|
});
|
||||||
|
|
@ -78,7 +67,7 @@ export async function onboard(): Promise<number> {
|
||||||
downgraded
|
downgraded
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
const a = getAdapter(t);
|
const a = getAdapter(t);
|
||||||
return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원해서, ${effectiveScope(t, wanted)} 범위로 설치됩니다`;
|
return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원한다 → ${effectiveScope(t, wanted)} 로 설치한다`;
|
||||||
})
|
})
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
);
|
);
|
||||||
|
|
@ -102,126 +91,35 @@ export async function onboard(): Promise<number> {
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
p.note(
|
p.note(previews.join("\n\n"), "설치 계획");
|
||||||
[
|
|
||||||
previews.join("\n\n"),
|
|
||||||
"",
|
|
||||||
dim("아래 경로에 스킬 파일을 복사합니다."),
|
|
||||||
dim("직접 수정하신 파일이 있으면 덮어쓰지 않고 알려드립니다."),
|
|
||||||
].join("\n"),
|
|
||||||
"이렇게 설치됩니다",
|
|
||||||
);
|
|
||||||
|
|
||||||
const go = await p.confirm({ message: "이대로 진행할까요?", initialValue: true });
|
const go = await p.confirm({ message: "이대로 설치할까?", initialValue: true });
|
||||||
if (p.isCancel(go) || !go) return cancel();
|
if (p.isCancel(go) || !go) return cancel();
|
||||||
|
|
||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
s.start("설치하고 있습니다");
|
s.start("설치 중");
|
||||||
const outcomes = [];
|
const outcomes = [];
|
||||||
try {
|
try {
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
const scope = effectiveScope(t, wanted);
|
const scope = effectiveScope(t, wanted);
|
||||||
s.message(`${getAdapter(t).label} 에 설치하고 있습니다`);
|
s.message(`설치 중 — ${getAdapter(t).label}`);
|
||||||
outcomes.push(...(await runInstall({ targets: [t], scope })));
|
outcomes.push(...(await runInstall({ targets: [t], scope })));
|
||||||
}
|
}
|
||||||
s.stop("설치가 끝났습니다");
|
s.stop("설치 완료");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
s.stop("설치하지 못했습니다", 1);
|
s.stop("설치 실패", 1);
|
||||||
p.log.error(err instanceof Error ? err.message : String(err));
|
p.log.error(err instanceof Error ? err.message : String(err));
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
printOutcomes(outcomes);
|
printOutcomes(outcomes);
|
||||||
|
|
||||||
await offerTools();
|
|
||||||
|
|
||||||
printNextSteps(targets);
|
printNextSteps(targets);
|
||||||
|
|
||||||
p.outro(`${accent("designpaca")} 준비됐습니다 — 만들고 싶은 것을 말씀해 주세요`);
|
p.outro(`${accent("designpaca")} 준비됨 — 이제 브리프를 던져라`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택 도구를 제안한다. 스킬 설치가 끝난 뒤에 묻는다 —
|
|
||||||
* 본 작업을 이것 때문에 붙잡지 않기 위해서다. 건너뛰어도 스킬은 돈다.
|
|
||||||
*/
|
|
||||||
export async function offerTools(): Promise<void> {
|
|
||||||
const status = await checkTools();
|
|
||||||
const missing = status.filter((s) => !s.installed);
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
p.log.success(
|
|
||||||
`브라우저 도구가 이미 준비돼 있습니다 ${dim("— 레퍼런스 조사에서 바로 활용됩니다")}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.note(
|
|
||||||
[
|
|
||||||
"이 스킬은 디자인을 시작하기 전에 실제 사이트를 찾아 분석합니다.",
|
|
||||||
"그런데 참고할 만한 사이트일수록 자동 접근을 막아두는 경우가 많습니다.",
|
|
||||||
"",
|
|
||||||
"브라우저 도구가 있으면 그런 사이트도 직접 열어서",
|
|
||||||
"화면을 캡처하고 글자 크기·여백 같은 값을 실제로 잴 수 있습니다.",
|
|
||||||
"",
|
|
||||||
dim("설치하지 않아도 스킬은 정상 동작합니다."),
|
|
||||||
dim("나중에 `designpaca tools` 로 언제든 추가하실 수 있습니다."),
|
|
||||||
].join("\n"),
|
|
||||||
"브라우저 도구를 함께 설치할까요?",
|
|
||||||
);
|
|
||||||
|
|
||||||
const picked = await p.multiselect<string>({
|
|
||||||
message: "설치할 도구를 골라주세요 (스페이스로 선택 / 엔터로 확인)",
|
|
||||||
options: missing.map(({ tool }) => ({
|
|
||||||
value: tool.id,
|
|
||||||
label: tool.label,
|
|
||||||
hint: `${tool.why} · ${tool.size}`,
|
|
||||||
})),
|
|
||||||
initialValues: missing.map((m) => m.tool.id),
|
|
||||||
required: false,
|
|
||||||
});
|
|
||||||
if (p.isCancel(picked) || picked.length === 0) {
|
|
||||||
p.log.info(
|
|
||||||
`건너뛰겠습니다. ${dim("필요해지면 `designpaca tools` 로 설치하실 수 있습니다.")}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = p.spinner();
|
|
||||||
const followUps: string[] = [];
|
|
||||||
for (const id of picked) {
|
|
||||||
const entry = missing.find((m) => m.tool.id === id);
|
|
||||||
if (!entry) continue;
|
|
||||||
s.start(`${entry.tool.label} 을(를) 설치하고 있습니다 ${dim(`(${entry.tool.size})`)}`);
|
|
||||||
try {
|
|
||||||
await entry.tool.install();
|
|
||||||
s.stop(`${entry.tool.label} 설치 완료`);
|
|
||||||
if (entry.tool.followUp) followUps.push(entry.tool.followUp);
|
|
||||||
} catch (err) {
|
|
||||||
// 도구 설치 실패로 스킬 설치까지 실패시키지 않는다. 스킬은 이미 깔렸다.
|
|
||||||
s.stop(`${entry.tool.label} 을(를) 설치하지 못했습니다`, 1);
|
|
||||||
p.log.warn(
|
|
||||||
[
|
|
||||||
err instanceof Error ? err.message.split("\n")[0] : String(err),
|
|
||||||
dim("스킬 설치는 정상적으로 끝났습니다. 이 도구만 나중에 다시 시도하셔도 됩니다."),
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (followUps.length > 0) {
|
|
||||||
p.note(
|
|
||||||
[
|
|
||||||
"아래 명령은 에이전트 설정을 바꾸는 것이라 직접 실행해 주세요.",
|
|
||||||
"",
|
|
||||||
...followUps,
|
|
||||||
].join("\n"),
|
|
||||||
"마지막 한 단계",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel(): number {
|
function cancel(): number {
|
||||||
p.cancel("설치를 취소했습니다. 아무것도 변경하지 않았습니다.");
|
p.cancel("설치를 취소했다. 아무것도 바꾸지 않았다.");
|
||||||
return 130;
|
return 130;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, "도움말에 명령조 표현이 남아 있다");
|
|
||||||
});
|
|
||||||
|
|
@ -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 와 같아 변환 없이 그대로 복사된다.
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@designpaca/core",
|
"name": "@designpaca/core",
|
||||||
"version": "0.5.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지",
|
"description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,4 @@ export * from "./marker.ts";
|
||||||
export * from "./manifest.ts";
|
export * from "./manifest.ts";
|
||||||
export * from "./skill-source.ts";
|
export * from "./skill-source.ts";
|
||||||
export * from "./installer.ts";
|
export * from "./installer.ts";
|
||||||
export * from "./tools.ts";
|
|
||||||
export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts";
|
export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts";
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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")),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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")),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,41 +1,57 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { exists } from "../fsx.ts";
|
import { exists } from "../fsx.ts";
|
||||||
import type { TargetAdapter, TargetContext } from "../types.ts";
|
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
|
||||||
import { anyExists, scopeRoot, skillDirActions } from "./common.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)와
|
* Windsurf 는 이 파일을 읽지 않는다(.windsurf/rules/*.md 를 쓴다) — 별도 어댑터로 분리했다.
|
||||||
* 중립 경로(.agents/skills)를 살피고, 호환 경로(.claude/skills · .codex/skills)까지 함께
|
|
||||||
* 읽는다 — 그래서 SKILL.md 를 변환 없이 그대로 복사하면 끝난다.
|
|
||||||
*
|
|
||||||
* 예전엔 .cursor/rules/designpaca.mdc 로 변환했지만 그 접근은 버렸다. 규칙 파일은
|
|
||||||
* 프론트매터가 다르고 본문이 통째로 프롬프트에 들어가는 규약이라 설명만 띄우고
|
|
||||||
* 본문·references 는 필요할 때 여는 스킬 방식이 맞다. 이제 전역 설치도 된다.
|
|
||||||
*/
|
*/
|
||||||
export const cursor: TargetAdapter = {
|
export const cursor: TargetAdapter = {
|
||||||
id: "cursor",
|
id: "cursor",
|
||||||
label: "Cursor",
|
label: "Cursor",
|
||||||
hint: "~/.cursor/skills/designpaca — SKILL.md 그대로 (전역 지원)",
|
hint: ".cursor/rules/designpaca.mdc — 프로젝트 단위",
|
||||||
scopes: ["user", "project"],
|
// Cursor 의 전역 규칙은 파일이 아니라 앱 설정(User Rules)이라 프로젝트 범위만 지원한다
|
||||||
|
scopes: ["project"],
|
||||||
|
|
||||||
async detect(ctx: TargetContext) {
|
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) {
|
async plan(ctx: TargetContext) {
|
||||||
const root = scopeRoot(
|
const rulesDir = path.join(ctx.cwd, ".cursor", "rules");
|
||||||
ctx,
|
const mdc = path.join(rulesDir, "designpaca.mdc");
|
||||||
[".cursor", "skills", "designpaca"],
|
const refRoot = path.join(rulesDir, "designpaca");
|
||||||
[".cursor", "skills", "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 {
|
return {
|
||||||
target: this.id,
|
target: this.id,
|
||||||
scope: ctx.scope,
|
scope: ctx.scope,
|
||||||
root,
|
root: rulesDir,
|
||||||
actions: skillDirActions(root, ctx.skill),
|
actions,
|
||||||
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
|
alreadyInstalled: await exists(mdc),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -2,26 +2,10 @@ import type { TargetAdapter, TargetId } from "../types.ts";
|
||||||
import { claudeCode } from "./claude-code.ts";
|
import { claudeCode } from "./claude-code.ts";
|
||||||
import { codex } from "./codex.ts";
|
import { codex } from "./codex.ts";
|
||||||
import { cursor } from "./cursor.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 { windsurf } from "./windsurf.ts";
|
||||||
import { copilot } from "./copilot.ts";
|
|
||||||
import { agentsMd } from "./agents-md.ts";
|
import { agentsMd } from "./agents-md.ts";
|
||||||
|
|
||||||
export const ADAPTERS: TargetAdapter[] = [
|
export const ADAPTERS: TargetAdapter[] = [claudeCode, codex, cursor, windsurf, agentsMd];
|
||||||
claudeCode,
|
|
||||||
codex,
|
|
||||||
cursor,
|
|
||||||
zcode,
|
|
||||||
agents,
|
|
||||||
antigravity,
|
|
||||||
geminiCli,
|
|
||||||
windsurf,
|
|
||||||
copilot,
|
|
||||||
agentsMd,
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getAdapter(id: TargetId): TargetAdapter {
|
export function getAdapter(id: TargetId): TargetAdapter {
|
||||||
const a = ADAPTERS.find((x) => x.id === id);
|
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 { MARKER, rewriteRefPaths } from "./common.ts";
|
||||||
export {
|
export { claudeCode, codex, cursor, windsurf, agentsMd };
|
||||||
claudeCode,
|
|
||||||
codex,
|
|
||||||
cursor,
|
|
||||||
zcode,
|
|
||||||
agents,
|
|
||||||
antigravity,
|
|
||||||
geminiCli,
|
|
||||||
windsurf,
|
|
||||||
copilot,
|
|
||||||
agentsMd,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,66 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { exists } from "../fsx.ts";
|
import { exists } from "../fsx.ts";
|
||||||
import type { TargetAdapter, TargetContext } from "../types.ts";
|
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
|
||||||
import { anyExists, scopeRoot, skillDirActions } from "./common.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/ (프로젝트).
|
* Windsurf — .windsurf/rules/designpaca.md.
|
||||||
* Cascade 가 Agent Skills 규약(SKILL.md)을 네이티브로 읽는다.
|
* Cursor 와 경로·프론트매터가 모두 다르다(trigger/globs, .mdc 아님).
|
||||||
*
|
*
|
||||||
* 예전 .windsurf/rules/designpaca.md 방식은 규칙 파일에 12,000자 상한이 있어서
|
* trigger 는 model_decision 을 쓴다. always_on 으로 두면 디자인 스킬이 모든 메시지의
|
||||||
* 25KB 짜리 실제 스킬이 아예 설치되지 못했다 — 네이티브 스킬 경로로 옮기며 풀렸다.
|
* 시스템 프롬프트에 상주한다.
|
||||||
* 스킬은 name/description 만 띄우고 본문은 필요할 때 열기 때문에 크기 제약이 없다.
|
|
||||||
*/
|
*/
|
||||||
export const windsurf: TargetAdapter = {
|
export const windsurf: TargetAdapter = {
|
||||||
id: "windsurf",
|
id: "windsurf",
|
||||||
label: "Windsurf",
|
label: "Windsurf",
|
||||||
hint: "~/.codeium/windsurf/skills/designpaca — SKILL.md 그대로 (전역 지원)",
|
hint: ".windsurf/rules/designpaca.md — 프로젝트 단위",
|
||||||
scopes: ["user", "project"],
|
scopes: ["project"],
|
||||||
|
|
||||||
async detect(ctx: TargetContext) {
|
async detect(ctx: TargetContext) {
|
||||||
return anyExists([
|
return anyExists([
|
||||||
path.join(ctx.cwd, ".windsurf"),
|
path.join(ctx.cwd, ".windsurf"),
|
||||||
|
path.join(ctx.cwd, ".windsurfrules"),
|
||||||
path.join(ctx.home, ".windsurf"),
|
path.join(ctx.home, ".windsurf"),
|
||||||
path.join(ctx.home, ".codeium"),
|
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
async plan(ctx: TargetContext) {
|
async plan(ctx: TargetContext) {
|
||||||
const root = scopeRoot(
|
const rulesDir = path.join(ctx.cwd, ".windsurf", "rules");
|
||||||
ctx,
|
const rule = path.join(rulesDir, "designpaca.md");
|
||||||
[".codeium", "windsurf", "skills", "designpaca"],
|
const refRoot = path.join(rulesDir, "designpaca");
|
||||||
[".windsurf", "skills", "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 {
|
return {
|
||||||
target: this.id,
|
target: this.id,
|
||||||
scope: ctx.scope,
|
scope: ctx.scope,
|
||||||
root,
|
root: rulesDir,
|
||||||
actions: skillDirActions(root, ctx.skill),
|
actions,
|
||||||
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
|
alreadyInstalled: await exists(rule),
|
||||||
|
// 상한을 넘으면 설치는 되지만 Windsurf 가 뒷부분을 버린다. 조용히 깨지느니 막는다.
|
||||||
|
...(body.length > WINDSURF_CHAR_LIMIT
|
||||||
|
? {
|
||||||
|
blocked: `규칙 본문이 ${body.length}자로 Windsurf 상한(${WINDSURF_CHAR_LIMIT}자)을 넘는다. SKILL.md 를 줄여야 한다.`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
/** 설치 대상 식별자 */
|
/** 설치 대상 식별자 */
|
||||||
export type TargetId =
|
export type TargetId = "claude-code" | "codex" | "cursor" | "windsurf" | "agents-md";
|
||||||
| "claude-code"
|
|
||||||
| "codex"
|
|
||||||
| "cursor"
|
|
||||||
| "zcode"
|
|
||||||
| "agents"
|
|
||||||
| "antigravity"
|
|
||||||
| "gemini-cli"
|
|
||||||
| "windsurf"
|
|
||||||
| "copilot"
|
|
||||||
| "agents-md";
|
|
||||||
|
|
||||||
/** 설치 범위 — user: 홈 디렉터리 전역, project: 현재 프로젝트 */
|
/** 설치 범위 — user: 홈 디렉터리 전역, project: 현재 프로젝트 */
|
||||||
export type Scope = "user" | "project";
|
export type Scope = "user" | "project";
|
||||||
|
|
|
||||||
|
|
@ -118,118 +118,46 @@ test("agents-md: 포인터만 주입하고 본문은 별도 디렉터리에 푼
|
||||||
assert.ok(!after2.includes("designpaca:start"));
|
assert.ok(!after2.includes("designpaca:start"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cursor: 네이티브 스킬 경로에 SKILL.md 그대로 싣는다 (전역·프로젝트)", async () => {
|
test("cursor 는 user 범위를 거부한다", async () => {
|
||||||
const user = await planInstall("cursor", "user", skill, env);
|
const plan = await planInstall("cursor", "user", skill, env);
|
||||||
assert.equal(user.root, path.join(home, ".cursor", "skills", "designpaca"));
|
assert.ok(plan.blocked);
|
||||||
const proj = await planInstall("cursor", "project", skill, env);
|
await assert.rejects(() => applyPlan(plan, skill.version, { env }));
|
||||||
assert.equal(proj.root, path.join(cwd, ".cursor", "skills", "designpaca"));
|
});
|
||||||
|
|
||||||
const res = await applyPlan(proj, skill.version, { env });
|
test("cursor: .mdc 프론트매터로 변환하고 참조 경로를 재작성한다", async () => {
|
||||||
const md = await fs.readFile(path.join(proj.root, "SKILL.md"), "utf8");
|
const plan = await planInstall("cursor", "project", skill, env);
|
||||||
assert.match(md, /^---\n/, "프론트매터가 그대로 있어야 한다");
|
const res = await applyPlan(plan, skill.version, { env });
|
||||||
assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다");
|
|
||||||
await fs.access(path.join(proj.root, "references", "tokens.md"));
|
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 });
|
await removeInstall(res.record, { env });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("windsurf: 네이티브 스킬 경로에 싣는다 — 본문 크기 제한이 없다", async () => {
|
test("windsurf: Cursor 와 다른 경로·프론트매터를 쓴다", async () => {
|
||||||
const proj = await planInstall("windsurf", "project", skill, env);
|
const plan = await planInstall("windsurf", "project", skill, env);
|
||||||
assert.equal(proj.root, path.join(cwd, ".windsurf", "skills", "designpaca"));
|
const res = await applyPlan(plan, skill.version, { env });
|
||||||
const user = await planInstall("windsurf", "user", skill, env);
|
|
||||||
assert.equal(user.root, path.join(home, ".codeium", "windsurf", "skills", "designpaca"));
|
|
||||||
|
|
||||||
// 예전 규칙 파일 방식은 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 huge: SkillSource = { ...skill, body: "가".repeat(13_000) };
|
||||||
const hugePlan = await planInstall("windsurf", "project", huge, env);
|
const plan = await planInstall("windsurf", "project", huge, env);
|
||||||
assert.ok(!hugePlan.blocked, "본문이 크다고 막히면 안 된다");
|
assert.ok(plan.blocked, "상한 초과인데 막지 않았다");
|
||||||
|
assert.match(plan.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));
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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` 로 바로잡는다.
|
|
||||||
|
|
@ -88,77 +88,7 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬.
|
||||||
**애매하면 긴 쪽으로.** 단 국소 조건에 해당하면 국소로 가라 — 버튼 하나에 갤러리 3곳을 여는 것은 사용자가 이 스킬을 끄게 만든다.
|
**애매하면 긴 쪽으로.** 단 국소 조건에 해당하면 국소로 가라 — 버튼 하나에 갤러리 3곳을 여는 것은 사용자가 이 스킬을 끄게 만든다.
|
||||||
**국소로 시작했다가 조건이 깨지면 멈추고 올린다.** 토큰을 새로 정의하게 됐거나, 손댄 섹션이 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)를 먼저 한다: 지금 무엇이 작동하고 무엇이 무너져 있는가, 유지해야 할 자산(로고·색·기존 사용자의 기대)은 무엇인가. 감사 없는 리디자인은 파괴다.
|
**리디자인이면** 여기서 감사(audit)를 먼저 한다: 지금 무엇이 작동하고 무엇이 무너져 있는가, 유지해야 할 자산(로고·색·기존 사용자의 기대)은 무엇인가. 감사 없는 리디자인은 파괴다.
|
||||||
|
|
||||||
|
|
@ -185,11 +115,6 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된
|
||||||
|
|
||||||
**갤러리 목록 페이지가 아니라 원본 사이트를 열어라.** 이미지를 볼 수 없어도 구조는 읽을 수 있다.
|
**갤러리 목록 페이지가 아니라 원본 사이트를 열어라.** 이미지를 볼 수 없어도 구조는 읽을 수 있다.
|
||||||
|
|
||||||
**403·404·타임아웃을 두 번 만나면 텍스트 페처를 버리고 브라우저를 띄워라.**
|
|
||||||
좋은 레퍼런스일수록 봇을 막는다. 대체 갤러리를 찾아 헤매는 것은 시간 낭비이고,
|
|
||||||
찾아낸 대체 소스는 애초에 보려던 것보다 나쁘다. `designpaca tools` 로 설치된
|
|
||||||
headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다 — 방법은 `galleries.md` §5.
|
|
||||||
|
|
||||||
> 통과 조건: R1/R2/R3 각각의 URL과, 그것에서 **무엇을 가져올지** 한 줄씩. 형식은 `reference-method.md` 참조.
|
> 통과 조건: 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)
|
**4-1. 레이아웃과 타이포그래피** → `references/layout.md` (+ 폰트 적용은 `references/typography.md` §5, §6)
|
||||||
그리드, 여백 리듬, 시선 흐름. 3단계의 토큰을 그대로 쓴다. 이 단계가 끝나면 **아무 이펙트 없이도 완성된 페이지**여야 한다. 이것이 모든 폴백의 기반이다.
|
그리드, 여백 리듬, 시선 흐름. 3단계의 토큰을 그대로 쓴다. 이 단계가 끝나면 **아무 이펙트 없이도 완성된 페이지**여야 한다. 이것이 모든 폴백의 기반이다.
|
||||||
|
|
||||||
|
|
@ -342,7 +261,6 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
|
||||||
| `references/presets/README.md` | 2단계 — 미학 방향을 고를 때 (고른 프리셋 하나만 추가로 읽는다) |
|
| `references/presets/README.md` | 2단계 — 미학 방향을 고를 때 (고른 프리셋 하나만 추가로 읽는다) |
|
||||||
| `references/tokens.md` | 3단계 — 토큰을 정할 때 |
|
| `references/tokens.md` | 3단계 — 토큰을 정할 때 |
|
||||||
| `references/typography.md` | 3단계 — 폰트를 고를 때 / 4-1 — 적용할 때 |
|
| `references/typography.md` | 3단계 — 폰트를 고를 때 / 4-1 — 적용할 때 |
|
||||||
| `references/images.md` | **4-0 — 사진을 구하고 최적화할 때** |
|
|
||||||
| `references/layout.md` | 4-1 — 그리드와 타이포 |
|
| `references/layout.md` | 4-1 — 그리드와 타이포 |
|
||||||
| `references/svg-filters.md` | 4-2 — 재질을 만들 때 |
|
| `references/svg-filters.md` | 4-2 — 재질을 만들 때 |
|
||||||
| `references/three.md` | 4-3 — 입체가 필요할 때 |
|
| `references/three.md` | 4-3 — 입체가 필요할 때 |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@designpaca/skill",
|
"name": "@designpaca/skill",
|
||||||
"version": "0.5.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서",
|
"description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -328,29 +328,7 @@ const s = await sharp(buf).stats(); // 잘라낸 버퍼를 다시 물려야
|
||||||
> 지나가는 프레임은 방문자도 놓치고 측정도 매번 다른 값을 준다.
|
> 지나가는 프레임은 방문자도 놓치고 측정도 매번 다른 값을 준다.
|
||||||
> 정지된 before/after 두 장이 더 정확하게 말한다.
|
> 정지된 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 판이 본문 뒤를 지나가는 화면을 보고 "대비가 죽었다" 고 판단해 캔버스를
|
WebGL 판이 본문 뒤를 지나가는 화면을 보고 "대비가 죽었다" 고 판단해 캔버스를
|
||||||
어둡게 만들려 했다. 스크린샷에서 글자 사이 빈 띠의 배경 휘도를 재보니
|
어둡게 만들려 했다. 스크린샷에서 글자 사이 빈 띠의 배경 휘도를 재보니
|
||||||
최악 지점이 **9.83:1** — AAA(7:1)를 넘었다. 고칠 필요가 없었다.
|
최악 지점이 **9.83:1** — AAA(7:1)를 넘었다. 고칠 필요가 없었다.
|
||||||
|
|
@ -464,8 +442,6 @@ sticky 캔버스가 끝까지 돌려면 목록 뒤에 스크롤 구간이 필요
|
||||||
- [ ] (한국어) 한글 폰트가 명시되어 있고 `word-break: keep-all`이 있다
|
- [ ] (한국어) 한글 폰트가 명시되어 있고 `word-break: keep-all`이 있다
|
||||||
- [ ] 모든 섹션에서 제목→본문 간격이 **같은 값**이다 (0px 인 섹션이 하나도 없다)
|
- [ ] 모든 섹션에서 제목→본문 간격이 **같은 값**이다 (0px 인 섹션이 하나도 없다)
|
||||||
- [ ] 화면에 적은 수치는 **전부 그 화면을 캡처해 잰 값**이다 (추정치·기대치가 섞여 있지 않다)
|
- [ ] 화면에 적은 수치는 **전부 그 화면을 캡처해 잰 값**이다 (추정치·기대치가 섞여 있지 않다)
|
||||||
- [ ] 이미지 높이를 실제로 쟀다 — 원본 픽셀 높이가 그대로 나오면 `aspect-ratio` 가 죽은 것이다
|
|
||||||
- [ ] 내비·버튼 그룹의 **행 수를 `top` 값으로** 셌다 (개별 높이로는 못 잡는다)
|
|
||||||
|
|
||||||
**판정**
|
**판정**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,51 +170,4 @@ Awwwards는 **타이포 완성도와 야심의 상한선**으로만 쓰고, 섹
|
||||||
| adfolio.design | 429 | Love The Work More |
|
| adfolio.design | 429 | Love The Work More |
|
||||||
| **commercecream.com** | **HTTPS 연결 거부 (방치 상태)** | **ecomm.design로 대체. 쓰지 마라** |
|
| **commercecream.com** | **HTTPS 연결 거부 (방치 상태)** | **ecomm.design로 대체. 쓰지 마라** |
|
||||||
|
|
||||||
### 막히면 갤러리를 갈아타지 말고 **브라우저로 직접 들어가라**
|
|
||||||
|
|
||||||
위 표는 "대체 소스"를 적어뒀지만, 그건 차선책이다. **보려던 그 사이트를 봐야 한다.**
|
|
||||||
403·404·타임아웃을 만나면 대체 갤러리를 찾아 헤매지 말고 실제 브라우저를 띄워라.
|
|
||||||
실측에서 Aesop(403) → Kinto(404) → Hasami(DNS 실패) → MUJI(타임아웃)로 네 번 왕복하고도
|
|
||||||
**톤 레퍼런스를 하나도 못 얻었다.** 브라우저를 열었으면 첫 번째에 끝났다.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// playwright 가 없으면: npm i -g playwright && npx playwright install chromium
|
|
||||||
const path = require('path');
|
|
||||||
const { chromium } = require(path.join(process.env.PW_ROOT, 'playwright'));
|
|
||||||
const b = await chromium.launch({ headless: false }); // headed 여야 봇 차단을 덜 받는다
|
|
||||||
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
|
|
||||||
await p.goto(url, { waitUntil: 'networkidle', timeout: 45000 });
|
|
||||||
await p.screenshot({ path: 'ref-1.png', fullPage: true });
|
|
||||||
```
|
|
||||||
|
|
||||||
**스크린샷은 텍스트 요약보다 낫다.** `WebFetch` 는 마크다운으로 바꾸면서
|
|
||||||
여백·비율·크기 대비를 전부 버린다 — 6축 중 절반이 그 정보다.
|
|
||||||
그림을 보고 나서 **재라**: 히어로가 첫 화면의 몇 %인지, 제목이 몇 px 인지, 칼럼이 몇 개인지.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// 눈으로 보지 말고 숫자로 받아라
|
|
||||||
await p.evaluate(() => {
|
|
||||||
const h1 = document.querySelector('h1');
|
|
||||||
return {
|
|
||||||
h1px: h1 && getComputedStyle(h1).fontSize,
|
|
||||||
h1가족: h1 && getComputedStyle(h1).fontFamily.split(',')[0],
|
|
||||||
본문px: getComputedStyle(document.body).fontSize,
|
|
||||||
배경: getComputedStyle(document.body).backgroundColor,
|
|
||||||
첫화면_이미지비율: (() => {
|
|
||||||
const im = [...document.querySelectorAll('img,video,canvas')]
|
|
||||||
.filter(e => e.getBoundingClientRect().top < innerHeight);
|
|
||||||
const a = im.reduce((s, e) => { const r = e.getBoundingClientRect(); return s + r.width * r.height; }, 0);
|
|
||||||
return (a / (innerWidth * innerHeight) * 100).toFixed(0) + '%';
|
|
||||||
})(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**headed 로 띄우는 이유**: 헤드리스는 Cloudflare·Akamai 봇 판정에 자주 걸린다.
|
|
||||||
그래도 막히면 그때 대체 소스로 간다 — 순서가 반대다.
|
|
||||||
|
|
||||||
> 규칙: **접근 실패 2회 → 브라우저를 띄운다.** 대체 갤러리 탐색은 브라우저도 막힌 뒤의 일이다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> 근거: research/references/01-gallery-catalog.md, 03-trends-2026.md (조사일 2026-08-20)
|
> 근거: research/references/01-gallery-catalog.md, 03-trends-2026.md (조사일 2026-08-20)
|
||||||
|
|
|
||||||
|
|
@ -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 는 짧게. 같은 문장을 두 번 읽히지 않는다
|
|
||||||
|
|
@ -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. 이 단계의 통과 조건
|
## 5. 이 단계의 통과 조건
|
||||||
|
|
||||||
- [ ] CSS/HTML만으로 페이지가 완성됐다. JS를 꺼도 읽힌다
|
- [ ] CSS/HTML만으로 페이지가 완성됐다. JS를 꺼도 읽힌다
|
||||||
|
|
|
||||||
|
|
@ -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. 라이선스
|
## 7. 라이선스
|
||||||
|
|
||||||
**배포 전에 확인한다.** 폰트 라이선스 위반은 조용히 있다가 청구서로 온다.
|
**배포 전에 확인한다.** 폰트 라이선스 위반은 조용히 있다가 청구서로 온다.
|
||||||
|
|
|
||||||