feat(skill): 자율 검증 폐쇄 루프 내재화 — 0.6.0
Some checks failed
ci / build (push) Failing after 5s

- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다
- 5단계 기계 검사 4번째: design-gate 실행 의무화
- references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙
- tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합)
- 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0)

feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18)

- 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판
- LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png)
- 시간표 자동배치 결함 수리(명시적 격자 좌표)
- QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
This commit is contained in:
Yun Chan 2026-08-22 21:22:05 +09:00
parent 78ddc8b61a
commit 72337b7ee0
226 changed files with 18212 additions and 380 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

@ -128,11 +128,16 @@ const { c, ui } = Astro.props;
하한이 중요하다. h1 만 vw 에 비례해 줄고 h2 는 고정이라,
하한을 2.2rem 으로 두면 390px 화면에서 35.2px 대 33.2px —
1.06 배로 사실상 같은 크기가 된다. 하한은 h2 기준으로 정한다. */
font-size: clamp(3rem, 1.9rem + 3.1vw, 4.4rem);
1.06 배로 사실상 같은 크기가 된다. 하한은 h2 기준으로 정한다.
3rem 하한에서는 320px 에서 한 줄에 글자 5개가 안 들어가 4줄로
꺾였다. 2.6rem(41.6px)은 h2 의 1.25 배를 유지하면서 3줄에 끊긴다. */
font-size: clamp(2.6rem, 1.9rem + 3.1vw, 4.4rem);
line-height: 1.08;
letter-spacing: var(--tracking-display);
max-width: 15ch;
/* 좁은 화면에서 왼쪽으로 몰려 4줄로 꺾이던 것을 어절 단위로 균형 있게.
2줄(넓은 화면)·3줄(320px) 모두 의도한 끊김으로 바뀐다 */
text-wrap: balance;
margin: 0;
}
[lang="en"] .hero-title { max-width: 18ch; line-height: 1.04; }

View file

@ -66,7 +66,11 @@ const { c, ui } = Astro.props;
border-bottom: 1px solid var(--line);
}
.t-name { font-weight: var(--weight-medium); }
.t-path { font-size: var(--mono-size); }
.t-path {
font-size: var(--mono-size);
/* 경로는 공백이 없어 min-content 가 363px 다 — anywhere 로 접게 한다 */
overflow-wrap: anywhere;
}
.cmds { margin: 0; display: grid; gap: var(--space-3); }
.cmd-row {
@ -80,6 +84,8 @@ const { c, ui } = Astro.props;
dd { margin: 0; font-size: var(--step-0); }
@media (max-width: 820px) {
.install-grid { grid-template-columns: 1fr; }
/* 순수 1fr 은 minmax(auto,1fr) 다 — nowrap 명령어의 min-content 가
트랙을 뷰포트 밖까지 밀어낸다(390px 실측 +5px). min 0 이여야 접는다 */
.install-grid { grid-template-columns: minmax(0, 1fr); }
}
</style>

View file

@ -1,77 +0,0 @@
---
import { Image } from "astro:assets";
import shotCoffee from "../assets/shots/shot-coffee.png";
import shotSaas from "../assets/shots/shot-saas.png";
import BgPhoto from "./BgPhoto.astro";
import type { Content } from "../i18n/content";
interface Props { c: Content["showcase"]; }
const { c } = Astro.props;
const shots = [shotCoffee, shotSaas];
---
{/* 레이아웃 패밀리: Z축 캐스케이드. 두 판이 서로 겹치고 각도가 다르다 */}
<section id="showcase" class="section showcase has-bg" aria-labelledby="showcase-h">
<BgPhoto variant="split" mode="drift" depth={0.4} opacity={0.85} />
<h2 id="showcase-h" class="reveal">{c.h2}</h2>
<p class="lead showcase-lead reveal">{c.lead}</p>
<div class="full cascade">
{
c.items.map((item, i) => (
<figure class:list={["work", "reveal", i === 0 ? "par-slow" : "par-fast"]} style={`--reveal-delay:${i * 90}ms`}>
<div class="frame zoom-in">
<Image src={shots[i]!} alt={item.alt} widths={[480, 860, 1200]}
sizes="(max-width: 820px) 92vw, 44vw" loading="lazy" />
</div>
<figcaption>
<span class="work-caption">{item.caption}</span>
<span class="work-note muted">{item.note}</span>
</figcaption>
</figure>
))
}
</div>
</section>
<style>
.showcase-lead { margin-bottom: var(--space-6); }
.cascade {
display: grid;
gap: var(--space-6) var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: 1180px;
margin-inline: auto;
padding-inline: var(--gutter);
align-items: start;
}
/* 두 번째 판이 아래로 내려가 계단을 만든다 */
.work:nth-child(2) { margin-top: var(--space-7); }
.work { margin: 0; }
.work .frame { box-shadow: var(--glass-hi), var(--core-hi), var(--shadow-3); }
.work:nth-child(1) .frame { transform: rotate(-0.5deg); }
.work:nth-child(2) .frame { transform: rotate(0.7deg); }
.work :global(img) {
width: 100%; height: auto; display: block;
aspect-ratio: 16 / 11;
object-fit: cover;
object-position: top center;
}
figcaption {
display: grid;
gap: var(--space-1);
margin-top: var(--space-3);
font-size: var(--step-0);
}
.work-caption { font-weight: var(--weight-medium); }
@media (max-width: 820px) {
.cascade { grid-template-columns: 1fr; }
.work:nth-child(2) { margin-top: 0; }
.work .frame { transform: none; }
}
</style>

View file

@ -4,45 +4,61 @@
* 사례를 추가할 때 **여기 한 항목만 넣으면** 모자이크 그리드에 자동으로 들어간다.
* 페이지 코드는 건드리지 않는다.
*
* 순서가 곧 표시 순서다(최근 것을 위에). `size` 는 타일 크기이고,
* 전부 같은 크기로 두지 마라 — 균일한 격자는 이 스킬이 금지하는 형태다.
* 목요일의 화원이 선두(size: "lg")로 가장 크게 나오고,
* 나머지는 6열 모자이크 그리드에서 3+3 (md), 2+2+2 (sm) 리듬으로 배치된다.
*/
import type { ImageMetadata } from "astro";
import thursdayFlowers from "../assets/shots/work-thursday-flowers.png";
import thursdayFlowers2 from "../assets/shots/work-thursday-flowers-2.png";
import luminaOptics from "../assets/shots/work-lumina-optics.png";
import luminaOptics2 from "../assets/shots/work-lumina-optics-2.png";
import orbitAerospace from "../assets/shots/work-orbit-aerospace.png";
import orbitAerospace2 from "../assets/shots/work-orbit-aerospace-2.png";
import muwolCoffee from "../assets/shots/work-muwol-coffee.png";
import muwolCoffee2 from "../assets/shots/work-muwol-coffee-2.png";
import pulsegate from "../assets/shots/work-pulsegate.png";
import pulsegate2 from "../assets/shots/work-pulsegate-2.png";
import hyangIncense from "../assets/shots/work-hyang-incense.png";
import hyangIncense2 from "../assets/shots/work-hyang-incense-2.png";
import formArch from "../assets/shots/work-form-architecture.png";
import formArch2 from "../assets/shots/work-form-architecture-2.png";
import synapseBci from "../assets/shots/work-synapse-bci.png";
import synapseBci2 from "../assets/shots/work-synapse-bci-2.png";
import baekjeCeladon from "../assets/shots/work-baekje-celadon.png";
import baekjeCeladon2 from "../assets/shots/work-baekje-celadon-2.png";
import overtoneSound from "../assets/shots/work-overtone-sound.png";
import overtoneSound2 from "../assets/shots/work-overtone-sound-2.png";
import fontFoundry from "../assets/shots/work-font-foundry.png";
import fontFoundry2 from "../assets/shots/work-font-foundry-2.png";
import gaonLms from "../assets/shots/work-gaon-lms.png";
import gaonLms2 from "../assets/shots/work-gaon-lms-2.png";
import dureEnrollment from "../assets/shots/work-dure-enrollment.png";
import dureEnrollment2 from "../assets/shots/work-dure-enrollment-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[] = [
/* ── 1행: 선두 대형 히어로 타일 (6열 풀 와이드) ── */
{
slug: "thursday-flowers",
name: "목요일의 화원",
kind: "플로럴 스튜디오 · 한 장",
concept: "꽃을 파는 가게가 아니라, 꽃으로 작업하는 사람의 기록",
risk: "첫 화면에 큰 사진을 깔지 않았습니다. 크기가 제각각인 사진 셋을 정렬선 없이 흩뿌립니다",
concept: "꽃을 파는 가게가 아니라, 꽃으로 작업하는 사람의 기록 — 서촌 골목 안의 에디토리얼",
risk: "첫 화면에 큰 사진을 깔지 않고, 크기가 제각각인 사진 셋을 정렬선 없이 여백 위에 흩뿌립니다",
preset: "editorial",
date: "2026-08",
metrics: ["이미지 603KB", "JS 0.6KB", "본문 대비 13.75:1", "하드 게이트 12/12"],
@ -52,4 +68,194 @@ export const WORK: WorkItem[] = [
alt: "목요일의 화원 첫 화면 — 크림색 배경에 꽃 사진 셋이 서로 다른 위치에 놓여 있습니다",
size: "lg",
},
/* ── 2행: 2열 모자이크 (3 + 3) — 다크/라이트 교차 ── */
{
slug: "lumina-optics",
name: "Lumina Optics",
kind: "광학 렌즈 시험소",
concept: "하나의 빛줄기, 프리즘 하나 — 굴절과 분산을 매 프레임 GPU로 계산하는 시험대",
risk: "렌더링 영상 대신 실제 셰이더 굴절(IOR·분산 슬라이더)을 첫 화면에 두고, 본문은 캔버스와 완전히 분리합니다",
preset: "refraction / WebGL",
date: "2026-08",
metrics: ["three.js 165KB 지연로드", "IOR 1.45–2.20 실시간", "WebGL 실패 시 사진 폴백", "하드 게이트 12/12"],
href: "/work/lumina-optics/",
shot: luminaOptics,
shotAlt: luminaOptics2,
alt: "Lumina Optics — 어두운 시험대 위 프리즘을 통과한 빛이 스펙트럼으로 펴지는 WebGL 장면과 MTF 곡선",
size: "md",
},
{
slug: "form-architecture",
name: "스튜디오 폼 (FORM)",
kind: "공간건축연구소 아카이브",
concept: "판맞춤 도면 묶음 — 색이 아니라 선과 치수가 말한다",
risk: "사진 없는 역연도 텍스트 인덱스와, 스티키 평면도가 섹션을 지나는 동안 스스로 그려지는 구조",
preset: "swiss-minimal",
date: "2026-08",
metrics: ["무채색 100%", "Radius 0", "scroll-draw 평면도", "하드 게이트 12/12"],
href: "/work/form-architecture/",
shot: formArch,
shotAlt: formArch2,
alt: "스튜디오 폼 — 헤어라인으로 나뉜 텍스트 인덱스와 선으로 그려진 평면도, 흑백 사진 3점",
size: "md",
},
/* ── 3행: 3열 모자이크 (2 + 2 + 2) ── */
{
slug: "muwol-coffee",
name: "무월 (霧月)",
kind: "싱글오리진 원두 정기구독",
concept: "종이 원장처럼 정직한 구독 — 가격은 첫 화면에, 거래 내역은 원장으로 공개",
risk: "세로 GNB와 카드 대신 원장의 행(行) 문법으로 원두를 나열하고, 구독 CTA는 회색조에서 가장 어두운 반전 블록으로",
preset: "quiet-commerce",
date: "2026-08",
metrics: ["가격 첫 화면 노출", "앰버 강조 1색", "산미-바디 좌표 플롯", "하드 게이트 12/12"],
href: "/work/muwol-coffee/",
shot: muwolCoffee,
shotAlt: muwolCoffee2,
alt: "무월 로스터리 — 원두 사진과 가격이 함께 놓인 히어로, 향미 좌표 플롯과 생두 매입 원장",
size: "sm",
},
{
slug: "pulsegate",
name: "Pulsegate",
kind: "Edge Latency & Synthetic Probe",
concept: "발광 없는 계기판 — 코발트는 배경이 아니라 글자와 차트 선으로만 존재한다",
risk: "깜빡이는 상태점과 티커를 버리고 정적 24시간 프레임 하나, 그리고 스크롤에 따라 그려지는 배선도를 배치합니다",
preset: "dark-instrument",
date: "2026-08",
metrics: ["코발트=잉크 전용", "CSS scroll-draw 배선도", "단일 가격 행", "하드 게이트 12/12"],
href: "/work/pulsegate/",
shot: pulsegate,
shotAlt: pulsegate2,
alt: "Pulsegate — 24시간 p99 차트가 담긴 정적 계기 프레임과 코발트색 선의 배선 다이어그램",
size: "sm",
},
{
slug: "hyang-incense",
name: "향연 (享煙)",
kind: "자연 침향 아카이브",
concept: "한지 위의 장부 — 여백이 주인공이고 연기 한 줄기가 흔적이다",
risk: "상품 사진 대신 사진 없는 목록(텍스트가 상품)과 한지 그레인, 세로쓰기 캡션으로 장부의 물성을 만듭니다",
preset: "quiet-luxury",
date: "2026-08",
metrics: ["본문 17px·1.9행간", "한지 SVG 그레인", "수지 장부 공개", "하드 게이트 12/12"],
href: "/work/hyang-incense/",
shot: hyangIncense,
shotAlt: hyangIncense2,
alt: "향연 — 넓은 한지 여백 위에 세로로 피어오르는 연기 사진과 조용한 숙성 장부",
size: "sm",
},
/* ── 4행: 2열 모자이크 (3 + 3) ── */
{
slug: "orbit-aerospace",
name: "궤도 우주공학",
kind: "75kN 메탄 엔진 사양서",
concept: "도면은 말하지 않고 측정만 한다 — 재생냉각 엔진의 단면도와 지상 연소 시험 기록",
risk: "홍보 문구 대신 손으로 그린 엔진 단면도를 첫 화면에, 보류된 시험까지 지우지 않은 기록표를 함께 둡니다",
preset: "technical-schematic",
date: "2026-08",
metrics: ["모든 수치에 측정조건", "SF-27–31 시험 공개", "구리 액센트 1색", "하드 게이트 12/12"],
href: "/work/orbit-aerospace/",
shot: orbitAerospace,
shotAlt: orbitAerospace2,
alt: "궤도 우주공학 — 제도 모눈 위 엔진 단면도와 구리색 강조의 사양표, 야간 연소 시험 사진",
size: "md",
},
{
slug: "synapse-bci",
name: "Synapse BCI Lab",
kind: "64채널 신경 인터페이스",
concept: "측정 조건이 붙은 계기 — 시뮬레이션은 시뮬레이션이라고 적고, 미측정값은 공백으로 둔다",
risk: "64채널 스파이크 래스터 캔버스(화면 밖에서 정지)를 첫 화면에 두고, 윤리 섹션을 콘텐츠로 승격합니다",
preset: "dark-instrument",
date: "2026-08",
metrics: ["래스터 SIM 명시", "미측정값 '—' 표기", "윤리 섹션 상위", "하드 게이트 12/12"],
href: "/work/synapse-bci/",
shot: synapseBci,
shotAlt: synapseBci2,
alt: "Synapse BCI — 골드색 스파이크 틱이 흐르는 64채널 래스터 캔버스와 신경 칩 접사 사진",
size: "md",
},
/* ── 5행: 3열 모자이크 (2 + 2 + 2) ── */
{
slug: "baekje-celadon",
name: "비색의 침묵 (翡色之靜)",
kind: "고려청자 특별전 큐레이션",
concept: "암실의 세 개의 방 — 사진이 어두울수록 유약이 가까워진다",
risk: "전시 포스터 형식을 버리고 빙렬 매크로를 풀블리드 판으로, 상감 접사와 '제1실·제2실' 방 번호 문법으로 구성합니다",
preset: "editorial-archival",
date: "2026-08",
metrics: ["빙렬 풀블리드 판", "비색 액센트 1색", "3개 방 구조", "하드 게이트 12/12"],
href: "/work/baekje-celadon/",
shot: baekjeCeladon,
shotAlt: baekjeCeladon2,
alt: "비색의 침묵 — 암실 스포트라이트 아래 청자 주자 사진과 빙렬 매크로의 풀블리드 판",
size: "sm",
},
{
slug: "overtone-sound",
name: "오버톤 사운드랩",
kind: "공간 음향 · DSP 연구소",
concept: "어두운 스튜디오 바닥의 목록 — 격자는 일부러 비틀었다",
risk: "절차적 유사-FFT 캔버스(오디오 디코드 없음)와 스태거 2열 아카이브, 압축 타입 스케일로 반복을 피합니다",
preset: "anti-grid",
date: "2026-08",
metrics: ["캔버스 파형·화면밖 정지", "스태거 2열 아카이브", "앰버 1색", "하드 게이트 12/12"],
href: "/work/overtone-sound/",
shot: overtoneSound,
shotAlt: overtoneSound2,
alt: "오버톤 사운드랩 — 앰버색 파형이 흐르는 어두운 히어로와 어긋난 두 열의 작업 목록",
size: "sm",
},
{
slug: "font-foundry",
name: "한성활자연구소",
kind: "본문용 한글 활자 파운드리",
concept: "글자가 상품이다 — 첫 화면의 거대 글리프와 인주각, 나머지는 물성과 눈금",
risk: "Lorem ipsum 없는 스페시멘과 자간·행간·크기가 실시간 반영되는 조판 시험대를 전면에 배치합니다",
preset: "editorial-archival",
date: "2026-08",
metrics: ["시험대 슬라이더 3종", "Lorem 0회", "인주 액센트 1색", "하드 게이트 12/12"],
href: "/work/font-foundry/",
shot: fontFoundry,
shotAlt: fontFoundry2,
alt: "한성활자연구소 — 한지 톤 위 거대한 한글 글리프와 슬라이더 3개의 조판 시험대",
size: "sm",
},
/* ── 6행: 관리 앱 2종 (3 + 3) — 메뉴·모달이 실제로 작동한다 ── */
{
slug: "gaon-lms",
name: "가온 학적부",
kind: "학습관리 콘솔 · 8화면",
concept: "종이 학적부를 화면으로 옮긴 관리 콘솔 — 형광펜 밑줄과 날인이 상태를 말한다",
risk: "관리 콘솔의 기본값인 파란 사이드바와 카드 그리드를 버리고, 학적부의 행(行) 문법으로 화면 여덟 개를 묶어 짓습니다",
preset: "ledger-console",
date: "2026-08",
metrics: ["뷰 9종 전환", "알림 센터", "공지 아코디언 · 분류 필터", "수업 진도 바", "출석 일별 합계", "과목 필터", "모달 2종"],
href: "/work/gaon-lms/",
shot: gaonLms,
shotAlt: gaonLms2,
alt: "가온 학적부 — 종이 톤 대시보드의 주간 출석 막대와 과제 마감 표, 흑연 사이드바",
size: "md",
},
{
slug: "dure-enrollment",
name: "두레대학 수강신청",
kind: "수강신청 · 6화면",
concept: "공문처럼 읽히는 수강신청 — 명조 제목과 IBM Plex 본문, 벽돌 申 도장이 상태를 누른다",
risk: "여섯 화면이 하나의 상태(신청·관심)를 공유합니다. 시간 겹침·학점 초과·정원 마감은 도장을 누르기 전에 막습니다",
preset: "timetable-ink",
date: "2026-08",
metrics: ["시간 겹침 즉시 차단", "색인탭 6종 전환", "이수 학점 실시간 집계", "빈 상태 안내"],
href: "/work/dure-enrollment/",
shot: dureEnrollment,
shotAlt: dureEnrollment2,
alt: "두레대학 수강신청 — 과목 원장 목록과 주홍 도장 블록이 놓이는 주간 시간표 격자",
size: "md",
},
];

View file

@ -1,28 +1,28 @@
/**
* 두 언어판은 번역이 아니라 각각 따로 쓴 것이다.
* 특히 조판 섹션은 한국어판에서 주장이고 영어판에서 사례다.
*
* 규칙: 이 파일에 em-dash 와 en-dash 를 쓰지 않는다.
* 하이픈, 마침표, 콜론, 괄호로 대신한다.
*/
/*
사이트 전체 문안. 레이아웃이 아니라 이 파일만 고치면 두 언어판이 함께 갱신된다.
export interface Stage {
문장 규칙(2026-08-21 카피 전면 재작성 때 정한 것):
- 본문은 존댓말로 통일한다. 제목·헤드라인은 명사구 또는 단형 종결.
- 근거 없는 수치를 넣지 않는다. 재지 않은 값은 "미측정"으로 남긴다.
- "—" 를 문장 부호로 쓰지 않는다. 쉼표·마침표로 끊는다.
- 자기 검증 이야기(프리플라이트)는 사실 그대로. 포장하지 않고, 고친 것만 적는다.
*/
interface Stage {
name: string;
does: string;
/** 같은 브리프가 이 단계를 지나며 무엇으로 변하는가. 번호 대신 이 변화가 순서를 만든다 */
becomes: string;
mono?: boolean;
}
export interface Metric {
interface Metric {
label: string;
value: string;
note: string;
/** 벤토에서 크게 잡을 항목 */
feature?: boolean;
}
export interface Rule {
interface Rule {
rule: string;
why: string;
}
@ -50,11 +50,6 @@ export interface Content {
brief: string;
stages: Stage[];
};
showcase: {
h2: string;
lead: string;
items: { alt: string; caption: string; note: string }[];
};
materials: {
eyebrow: string;
h2: string;
@ -108,34 +103,34 @@ export interface Content {
const STAGES_KO: Stage[] = [
{
name: "브리프 게이트",
does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축합니다. 규모를 판정해 경로를 고릅니다.",
does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축합니다. 규모를 판정해 작업 경로를 고릅니다.",
becomes: "누구에게: 홈카페 3년차 이상. 산미와 로스팅 프로파일을 구분합니다",
},
{
name: "레퍼런스 조사",
does: "실제로 존재하는 사이트 3개를 서로 다른 층위에서 고릅니다. 구조, 톤, 디테일.",
does: "실제로 존재하는 사이트 세 곳을 서로 다른 층위에서 고릅니다. 구조, 톤, 디테일.",
becomes: "R2 = 독립 문학 출판사. 커피 업계 밖에서 톤을 가져옵니다",
},
{
name: "방향 결정",
does: "레퍼런스를 하나의 축으로 합성합니다. 감수할 리스크를 하나 고릅니다.",
does: "레퍼런스를 하나의 방향으로 합성하고, 감수할 리스크를 하나 고릅니다.",
becomes: "감상은 이름에만, 화면에는 수치만",
},
{
name: "디자인 토큰",
does: "코드를 쓰기 전에 숫자를 정합니다. 성능 예산도 여기서 확정합니다.",
does: "코드를 쓰기 전에 숫자를 정합니다. 성능 예산도 이 단계에서 확정합니다.",
becomes: "--measure: 33em / --leading-normal: 1.7 / JS 예산 0KB",
mono: true,
},
{
name: "구현",
does: "레이아웃, 재질, 입체, 모션 순서로. 이펙트를 다 꺼도 완성돼 있어야 합니다.",
does: "레이아웃, 재질, 입체, 모션 순서로 만듭니다. 이펙트를 모두 꺼도 완성돼 있어야 합니다.",
becomes: "<h1>산미 8.5 · 바디 6.0 · 로스팅 라이트</h1>",
mono: true,
},
{
name: "프리플라이트",
does: "자기 결과물을 남의 것처럼 봅니다. 게이트 12개는 전부 '아니오'여야 합니다.",
does: "자기 결과물을 남의 것처럼 검사합니다. 하드 게이트 12개는 모두 '아니오'여야 합니다.",
becomes: "게이트 12/12 통과 · 슬롭 지문 0건 · 320px 오버플로 31px 수정",
mono: true,
},
@ -165,7 +160,7 @@ const STAGES_EN: Stage[] = [
},
{
name: "Build",
does: "Layout, surface, depth, motion. It must be finished with every effect switched off.",
does: "Layout, surface, depth, motion, in that order. It must be finished with every effect switched off.",
becomes: "<h1>Acidity 8.5 · Body 6.0 · Light roast</h1>",
mono: true,
},
@ -181,17 +176,21 @@ export const ko: Content = {
meta: {
title: "designpaca · 웹 디자인 파이프라인 스킬",
description:
"AI 코딩 에이전트에게 웹 디자인을 순서대로 하게 만드는 스킬. 레퍼런스 조사부터 셀프 감사까지 여섯 단계. Claude Code, Codex, Cursor, Windsurf 에 한 줄로 설치합니다.",
"AI 코딩 에이전트가 웹 디자인을 순서대로 하게 만드는 스킬입니다. 레퍼런스 조사부터 셀프 감사까지 여섯 단계를 통과시켜, 템플릿 같은 페이지가 나올 자리에 근거 있는 디자인이 나오게 합니다. Claude Code, Codex, Cursor 등에 한 줄로 설치합니다.",
},
hero: {
h1: "결정하지 않으면 기본값이 나온다",
lead: "AI가 만든 티는 못 만들어서가 아니라 결정을 안 해서 납니다. 그 결정을 여섯 단계로 강제하는 스킬.",
lead:
"AI가 뽑은 페이지가 템플릿처럼 보이는 건 능력이 부족해서가 아니라 결정이 없어서입니다. designpaca는 레퍼런스 조사부터 셀프 감사까지 여섯 단계를 순서대로 통과시키는 웹 디자인 파이프라인 스킬입니다. 단계마다 통과 조건이 있어서, 기본값이 들어갈 자리에 근거가 들어갑니다.",
install: "npx designpaca",
installNote: "Node 20.11+ · 설치 대상을 감지해 골라줍니다",
links: [{ label: "npm", href: "https://www.npmjs.com/package/designpaca" }],
installNote: "Node 20.11 이상 · 설치 대상은 자동으로 찾아 선택합니다",
links: [
{ label: "사례 보기", href: "/showcase" },
{ label: "npm", href: "https://www.npmjs.com/package/designpaca" },
],
stage: {
alt: "유리판 여섯 장이 깊이 방향으로 나란히 서 있습니다. 스크롤하면 카메라가 판을 하나씩 통과하고, 통과한 판은 뒤로 물러납니다.",
caption: "판 하나가 단계 하나다. 통과 조건을 채우지 못하면 다음 판으로 가지 않습니다.",
caption: "판 하나가 단계 하나입니다. 통과 조건을 채우지 못하면 다음 판으로 넘어가지 않습니다.",
gate: "게이트",
pass: "통과",
},
@ -200,38 +199,22 @@ export const ko: Content = {
eyebrow: "여섯 단계",
h2: "브리프 한 줄이 검증된 페이지가 될 때까지",
lead:
"각 단계에는 통과 조건이 있습니다. 통과하지 못하면 다음으로 가지 않습니다. 아래는 같은 브리프가 단계마다 무엇으로 변했는지다.",
"모든 단계에는 통과 조건이 있습니다. 통과하지 못하면 다음 단계로 넘어가지 않습니다. 아래는 같은 브리프가 여섯 단계를 지나며 무엇으로 바뀌는지입니다.",
briefLabel: "들어온 브리프",
brief: "수제 커피 로스터리의 원두 정기구독 랜딩 만들어줘",
stages: STAGES_KO,
},
showcase: {
h2: "결과물은 서로 닮지 않는다",
lead:
"같은 파이프라인을 두 브리프에 돌린 결과다. 1단계에서 톤을 다른 업종에서 가져오게 강제하기 때문에 같은 도구를 써도 같은 화면이 나오지 않습니다. 둘 다 수치 자리가 비어 있는 것도 우연이 아닙니다.",
items: [
{
alt: "무월 원두 구독 랜딩. 오프화이트 배경에 굵은 한글 헤드라인, 오른쪽에 원두 3종의 산미와 바디 표가 있습니다.",
caption: "수제 커피 로스터리 · 구독 전환",
note: "프리셋 quiet-commerce. 사진이 없습니다는 제약을 수치표로 뒤집었습니다. 표의 값은 아직 비어 있습니다. 로스터에게 받기 전까지 채우지 않습니다.",
},
{
alt: "Pulsegate 모니터링 SaaS 히어로. 밝은 배경에 큰 세리프 헤드라인과 무료 체험 버튼이 있습니다.",
caption: "API 모니터링 SaaS · 무료 체험 가입",
note: "개발자 도구라고 자동으로 다크로 가지 않았습니다. 브리프에 근거가 없었기 때문입니다. 리전 수는 아직 중괄호 자리로 남아 있습니다.",
},
],
},
materials: {
eyebrow: "SVG 필터",
h2: "이미지 편집기를 열지 않고 고치는 네 가지",
lead:
"질감을 만드는 필터 데모는 많다. 손이 실제로 줄어드는 자리는 따로 있습니다. 아래 넷은 왼쪽이 매번 손으로 맞추던 것, 오른쪽이 같은 소재에 선언 하나만 얹은 것입니다. 아래 수치는 이 화면을 그대로 캡처해 잰 값이다.",
"질감 필터 데모는 흔합니다. 중요한 건 손이 실제로 줄어드는 자리입니다. 아래 네 가지에서 왼쪽은 매번 손으로 맞추던 방식, 오른쪽은 같은 소재에 선언 하나를 얹은 결과입니다. 수치는 이 화면을 그대로 캡처해 잰 값입니다.",
demos: [
{
id: "scrim",
name: "글자 모양대로 생기는 스크림",
problem: "사진 위 캡션이 대비를 못 넘깁니다. 사진마다 밝기가 달라 반투명 박스를 손으로 맞추게 됩니다.",
problem:
"사진 위 캡션이 대비 기준을 못 넘깁니다. 사진마다 밝기가 달라서 반투명 박스를 매번 손으로 맞추게 됩니다.",
beforeLabel: "그냥 얹음",
afterLabel: "filter: url(#scrim)",
how: "글자 알파를 부풀려 그 모양대로만 색을 채웁니다. 박스가 아니라 글자를 따라가므로 사진을 덜 가립니다.",
@ -240,28 +223,30 @@ export const ko: Content = {
{
id: "cvd",
name: "색각 이상 시뮬레이션",
problem: "상태를 색으로만 구분하면 남성 12명 중 1명에게 같은 배지가 됩니다. 내 화면만 봐서는 못 잡는다.",
problem:
"상태를 색으로만 구분하면 남성 12명 중 1명에게는 같은 배지로 보입니다. 내 화면만 보고는 잡을 수 없습니다.",
beforeLabel: "내가 보는 화면",
afterLabel: "적록색약(2형)",
how: "feColorMatrix 한 줄. 이것만은 linearRGB 로 둡니다 — 색각 변환은 물리적인 빛의 혼합이다.",
how: "feColorMatrix 한 줄입니다. 이것만은 linearRGB 로 둡니다. 색각 변환은 물리적인 빛의 혼합이기 때문입니다.",
verdict: "배지 셋의 최소 색거리 82 → 2 · 주의와 실패가 같은 색",
},
{
id: "goo",
name: "떨어진 것이 하나로 뭉치기",
problem: "조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켜게 됩니다. 로더와 메뉴 전환에서 반복됩니다.",
problem:
"조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켜게 됩니다. 로더와 메뉴 전환에서 반복됩니다.",
beforeLabel: "필터 없음 · 원 3개",
afterLabel: "filter: url(#goo)",
how: "블러한 뒤 알파만 세워 잘라냅니다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해진다.",
how: "블러한 뒤 알파만 세워 잘라냅니다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해집니다.",
verdict: "덩어리 3개 → 1개 · JS 0줄",
},
{
id: "glass",
name: "배경을 휘게 하는 오버레이",
problem: "반투명 패널은 뒤를 흐리게만 만듭니다. 실제 유리는 빛을 휜다.",
problem: "반투명 패널은 뒤를 흐리게만 만듭니다. 실제 유리는 빛을 휩니다.",
beforeLabel: "backdrop-filter: blur",
afterLabel: "blur + url(#lens)",
how: "변위 지도를 backdrop-filter 에 넘깁니다. 노이즈로 만들면 값이 중립으로 수렴해 아무 일도 안 일어납니다.",
how: "변위 지도를 backdrop-filter 에 넘깁니다. 노이즈로 만들면 값이 중립으로 수렴해서 아무 일도 일어나지 않습니다.",
verdict: "가장자리에서 격자가 눌립니다 · Chromium 전용",
},
],
@ -270,31 +255,30 @@ export const ko: Content = {
eyebrow: "이 페이지의 수치",
h2: "카피만 고쳐서는 슬롭이 없어지지 않는다",
lead:
"문장을 다듬는 것으로 해결되는 것은 문장뿐입니다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남습니다. 그래서 이 페이지 자신의 형태 수치를 공개합니다. 결과가 나쁘게 나와도 그대로 싣는다.",
"문장을 다듬으면 좋아지는 건 문장뿐입니다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남습니다. 그래서 이 페이지는 자기 자신의 형태 수치를 공개합니다. 나쁘게 나와도 그대로 싣습니다. 감사를 남기는 것 자체가 이 스킬의 핵심 기능이기 때문입니다.",
items: [
{ label: "히어로까지 JS", value: "0.6%", note: "예산 50KB 중 실제로 쓴 비율", feature: true },
{ label: "하드 게이트", value: "12/12", note: "정적 검사와 320px · 1440px 실렌더 양쪽", feature: true },
{ label: "three.js 추가분", value: "184KB", note: "검사대가 화면에 들어올 때만 받습니다. 초기 번들에는 없습니다" },
{ label: "three.js 추가분", value: "184KB", note: "검사대가 화면에 들어올 때만 내려받습니다. 초기 번들에는 없습니다" },
{ label: "슬롭 지문", value: "0건", note: "9개 패턴 grep" },
{ label: "radius 어휘", value: "2종", note: "3px 과 14px. 알약 모양을 없앴습니다" },
{ label: "반투명 패널", value: "2개", note: "29개까지 늘렸다가 걷어냈습니다. 유리는 무언가를 담는 액자에만 남겼습니다" },
{ label: "타입 단계", value: "6단계", note: "토큰에 정의된 수. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" },
{ label: "반투명 패널", value: "2개", note: "한때 29개까지 늘었고 감사에서 걷어냈습니다. 유리는 무언가를 담는 액자에만 남겼습니다" },
{ label: "타입 단계", value: "6단계", note: "토큰에 정의된 수입니다. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" },
{ label: "폰트 패밀리", value: "2개", note: "언어판마다 자기 것만 받습니다" },
{ label: "강조색", value: "1개", note: "라이트와 다크는 같은 색의 대응값" },
{ label: "첫 인터랙션", value: "미측정", note: "배포 후 Lighthouse 로 잽니다" },
],
caveat:
"값은 프로덕션 빌드에서 측정합니다. 아직 측정하지 않은 항목은 비워둡니다. 지어낸 숫자보다 구멍이 정직하다. 이 페이지는 제품 데모이므로 three.js 를 얹으며 JS 예산을 올렸다. 올렸다는 사실을 여기 적는 것이 규칙이다.",
"값은 프로덕션 빌드에서 측정합니다. 아직 재지 않은 항목은 비워둡니다. 지어낸 숫자보다 빈칸이 정직합니다. 이 페이지는 제품 데모라서 three.js 를 얹으며 JS 예산을 올렸고, 그 사실을 여기에 적는 것 자체가 규칙입니다.",
},
preflight: {
h2: "이 페이지를 만들며 실제로 걸린 것",
lead: "남을 검사한 결과가 아니라 자신을 검사한 결과입니다.",
items: [
{
caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었습니다",
fixed:
"웹폰트가 오기 전에는 시스템 폰트로 그려진다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힙니다. 폴백 폰트에 size-adjust 로 치수를 맞췄다. 실측 차이 0.0%. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했습니다.",
"웹폰트가 오기 전에는 시스템 폰트로 그려집니다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힙니다. 폴백 폰트에 size-adjust 로 치수를 맞췄고 실측 차이는 0.0%가 되었습니다. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했습니다.",
},
{
caught: "유리 카드를 29개 뿌렸습니다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였습니다",
@ -302,33 +286,68 @@ export const ko: Content = {
"우리 규칙이 '카드는 elevation 이 진짜 위계를 나를 때만'이라고 정해두고 있었습니다. 수치는 조판으로, 규칙은 어긋난 목록으로, 감사 기록은 로그로 바꿨습니다. 유리는 5개만 남았습니다.",
},
{
caught: "“backdrop-filter 에 SVG 를 얹으면 Chrome 이 버린다” 고 적었는데 사실이 아니었습니다",
caught: "'backdrop-filter 에 SVG 를 얹으면 Chrome 이 선언을 버린다'고 적었는데 사실이 아니었습니다",
fixed:
"한 번 실패하고 브라우저 탓으로 적어뒀다. 다시 재보니 Chrome 151 에서 computed 값이 none 이 아니라 url(#id) 로 살아 있고 배경도 실제로 굴절됩니다. 진짜 원인은 feImage 의 width/height 를 요소 크기와 맞추지 않은 것이었습니다 — backdrop-filter 는 필터 이미지를 자동으로 조정하지 않습니다. 한 번 실패한 것을 ‘안 되는 것’ 으로 적으면 그 문장이 다음 사람의 상한선이 됩니다.",
"한 번 실패하자 브라우저 탓으로 적어뒀습니다. 다시 재보니 Chrome 151 에서 computed 값은 none 이 아니라 url(#id) 로 살아 있고 배경도 실제로 굴절됩니다. 진짜 원인은 feImage 의 width/height 를 요소 크기와 맞추지 않은 것이었습니다. backdrop-filter 는 필터 이미지를 자동으로 맞춰주지 않습니다. 한 번 실패한 것을 '안 되는 것'으로 적으면 그 문장이 다음 사람의 상한선이 됩니다.",
},
{
caught: "가로로 444px 넘쳤습니다. 레이아웃 전체가 어긋나 있었습니다",
fixed:
"main 에서 그리드 클래스가 빠져 있었습니다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였습니다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났다.",
"main 에서 그리드 클래스가 빠져 있었습니다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였습니다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났습니다.",
},
{
caught: "스크롤을 끝까지 내려도 파이프라인 6단계 중 뒤 3개가 안 보였습니다",
fixed:
"scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못합니다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다르다. 한 번 보이면 유지되는 방식으로 바꿨습니다.",
"scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못합니다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다릅니다. 한 번 보이면 유지되는 방식으로 바꿨습니다.",
},
{
caught: "다크 밴드 안 버튼의 대비가 2.88:1. 기준은 4.5:1",
caught: "다크 밴드 안 버튼의 대비가 2.88:1. 기준은 4.5:1 이었습니다",
fixed:
"밴드가 강조색만 반전시키고 그 위 글자색은 흰색 그대로였습니다. 반전 대응 토큰을 하나 더 두고 함께 바꿨습니다.",
},
{
caught: "320px 에서 가로로 13px 넘쳤습니다. 표는 이미 스크롤 컨테이너 안에 있었는데도요",
fixed:
"그리드 항목의 기본 min-width 는 auto 라서 내용이 부모를 밀어냅니다. min-width: 0 을 줘야만 overflow 가 제 역할을 합니다.",
},
{
caught: "eyebrow 라벨이 6개였습니다. 규칙 상한은 3개였습니다",
fixed: "셋을 남기고 나머지를 걷었습니다. 라벨이 없어도 섹션은 충분히 읽힙니다.",
},
{
caught: "본문 컬럼이 960px 였습니다. 방금 토큰으로 정한 measure 는 561px 였습니다",
fixed:
"그리드의 main 컬럼을 토큰에 묶었습니다. 스킬의 예시 코드도 같은 문제를 갖고 있어서 함께 고쳤습니다.",
},
{
caught: "방향을 바꿀 때 이 스킬의 1단계를 건너뛰었습니다",
fixed:
"재질을 유리로 바꾸는 것은 2단계 결정이고, 그러려면 레퍼런스로 돌아가야 합니다. 우리는 구현으로 바로 들어갔습니다. 결과는 이전 방향의 뼈대에 유리를 바른 페이지였고 design.md 는 옛 결정을 설명하고 있었습니다. 0단계부터 전체 경로를 다시 돌았습니다.",
},
{
caught: "유리판이 카메라를 정면으로 보고 있어 프레넬이 구조적으로 0 이었습니다",
fixed:
"평면이 정면을 보면 법선과 시선의 내적이 1 이라 가장자리 반사가 계산되지 않습니다. 유리로 읽히게 하는 유일한 신호가 죽어 있어 여섯 판이 흐릿한 사각형으로 보였습니다. 법선을 살짝 굽히고 UV 가장자리에서 테두리 광을 따로 만들었습니다.",
},
{
caught: "이 페이지의 뼈대인 파이프라인 섹션이 뷰포트의 43% 만 쓰고 있었습니다",
fixed:
"1440px 중 625px. 아홉 섹션 중 가장 좁으면서 높이는 1637px 로 가장 길었습니다. 각 단계를 2열로 열어 1113px 로 넓혔습니다.",
},
{
caught: "대비 검사 스크립트가 반투명 배경을 불투명으로 계산하고 있었습니다",
fixed:
"'English 1.15:1' 이라는 틀린 값이 나왔습니다. 알파를 밑바닥부터 합성하도록 고치니 실패가 0건이 되었습니다. 검사 도구가 틀리면 통과도 실패도 의미가 없습니다.",
},
],
note: "열세 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 셋이다. 카드가 전형적이라는 말, 폰트를 제대로 다루지 못합니다는 말, 그리고 우리 스킬을 진짜로 쓴 게 맞냐는 말.",
note:
"열세 건 모두 이 페이지를 만들며 걸렸고, 걸린 뒤에 고쳤습니다. 그중 세 건은 검사기가 아니라 사람이 먼저 잡았습니다. 카드가 전형적이라는 지적, 폰트를 대충 다룬다는 지적, 그리고 이 스킬을 정말로 썼느냐는 질문이었습니다.",
},
typography: {
eyebrow: "한글 조판",
h2: "라틴 기본값은 한글에서 세 군데가 깨진다",
lead:
"영어권 디자인 도구에는 한글 조판 규칙이 없습니다. 그래서 기본값을 그대로 쓰면 아래 셋이 동시에 무너집니다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값이다. 글자도 폭도 폰트도 같습니다.",
"영어권 디자인 도구에는 한글 조판 규칙이 없습니다. 기본값을 그대로 두면 아래 세 가지가 동시에 무너집니다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값입니다. 글자도 폭도 폰트도 같습니다.",
sample: "스페셜티커피의 로스팅프로파일을 구분하려면 산미와 바디를 수치로 봐야 합니다.",
demos: [
{
@ -336,59 +355,65 @@ export const ko: Content = {
title: "줄바꿈",
badLabel: "기본값",
goodLabel: "word-break: keep-all",
note: "기본값은 어절 한가운데를 끊는다. 왼쪽 마지막 줄을 보라 — 한 단어가 두 줄에 걸쳐 있습니다.",
note: "기본값은 어절 한가운데를 끊습니다. 왼쪽 마지막 줄을 보세요. 한 단어가 두 줄에 걸쳐 있습니다.",
},
{
id: "leading",
title: "행간",
badLabel: "line-height 1.45",
goodLabel: "line-height 1.75",
note: "라틴 표준 1.4~1.5 는 받침이 있는 글자에 좁습니다. 줄이 서로 붙어 보입니다.",
note: "라틴 표준인 1.4~1.5 는 받침이 있는 글자에 좁습니다. 줄이 서로 붙어 보입니다.",
},
{
id: "size",
title: "크기",
badLabel: "11px",
goodLabel: "15px",
note: "라틴에서 11px 은 라벨로 씁니다. 한글은 획이 많아 같은 크기에서 뭉갠다.",
note: "라틴에서 11px 은 라벨에 쓰는 크기입니다. 한글은 획이 많아서 같은 크기에서 뭉개집니다.",
},
],
rules: [
"word-break: keep-all 없으면 단어가 아무 데서나 잘립니다",
"line-height 1.6에서 1.8 사이. 라틴보다 넉넉하게",
"음수 자간 금지. 한글은 이미 고정폭에 가까워 즉시 뭉개집니다",
"한 줄 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 깁니다",
"한글 폰트를 지정하지 않는 것 자체가 완성도 미달 신호입니다",
"word-break: keep-all 이 없으면 단어가 아무 데서나 잘립니다",
"행간은 1.6에서 1.8 사이. 라틴보다 넉넉하게",
"음수 자간은 쓰지 않습니다. 한글은 이미 고정폭에 가까워서 바로 뭉개집니다",
"한 줄은 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 깁니다",
"한글 폰트를 지정하지 않은 것 자체가 미완성의 신호입니다",
],
},
rules: {
h2: "규칙마다 왜 그런지 적혀 있다",
lead: "근거 없는 금지는 지켜지지 않습니다. 사용자가 명시적으로 요구하면 규칙보다 그쪽이 이깁니다.",
lead:
"근거 없는 금지는 지켜지지 않습니다. 그래서 모든 규칙에 이유가 적혀 있습니다. 사용자가 명시적으로 요구하면 규칙보다 사용자가 이깁니다.",
items: [
{ rule: "레퍼런스 없이 시작하지 않는다", why: "머릿속 기본값에서 출발하면 결과는 그 카테고리의 평균이 됩니다." },
{ rule: "강조색은 하나다", why: "두 개가 필요하다고 느끼면 위계 설계가 실패한 것입니다." },
{ rule: "대담함은 한 곳에만", why: "두 곳에서 소리치면 둘 다 죽습니다." },
{ rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10입니다." },
{ rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10 입니다." },
{ rule: "지어낸 수치를 쓰지 않는다", why: "숫자 모양의 구멍은 정직하고, 지어낸 숫자는 신뢰를 깎습니다." },
{ rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반이다. 무너지면 이펙트가 구조를 대신하고 있는 것입니다." },
{ rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반입니다. 무너지면 이펙트가 구조를 대신하고 있는 것입니다." },
],
},
install: {
h2: "한 줄이면 된다",
lead: "설치 대상을 감지해 기본 선택해준다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않습니다.",
lead:
"설치할 하네스를 자동으로 찾아 선택합니다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않습니다.",
command: "npx designpaca",
targets: [
{ name: "Claude Code", path: "~/.claude/skills/designpaca/" },
{ name: "Codex CLI", path: "~/.codex/skills/designpaca/" },
{ name: "Antigravity", path: ".agents/skills/designpaca/" },
{ name: "Cursor", path: ".cursor/rules/designpaca.mdc" },
{ name: "Windsurf", path: ".windsurf/rules/designpaca.md" },
{ name: "Cursor", path: "~/.cursor/skills/designpaca/" },
{ name: "ZCode", path: "~/.zcode/skills/designpaca/" },
{ name: "중립 경로", path: "~/.agents/skills/designpaca/ · 어떤 하네스든" },
{ name: "Antigravity", path: "~/.gemini/antigravity-cli/skills/designpaca/" },
{ name: "Gemini CLI", path: "~/.gemini/skills/designpaca/" },
{ name: "Windsurf", path: "~/.codeium/windsurf/skills/designpaca/" },
{ name: "Copilot", path: "~/.copilot/skills/designpaca/" },
{ name: "AGENTS.md", path: "블록 주입. 에이전트 무관" },
],
commands: [
{ cmd: "npx designpaca update", what: "최신 스킬로 갱신. 직접 고친 파일은 건너뜁니다" },
{ cmd: "npx designpaca doctor", what: "설치 상태와 드리프트 진단" },
{ cmd: "npx designpaca uninstall", what: "매니페스트 기반 정확한 제거" },
{ cmd: "npx designpaca update", what: "최신 스킬로 갱신합니다. 직접 고친 파일은 건너뜁니다" },
{ cmd: "npx designpaca doctor", what: "설치 상태와 드리프트를 진단합니다" },
{ cmd: "npx designpaca uninstall", what: "매니페스트 기반으로 정확하게 제거합니다" },
],
},
nav: {
@ -409,16 +434,18 @@ export const en: Content = {
meta: {
title: "designpaca · a web design pipeline skill",
description:
"A skill that makes AI coding agents do web design in order: reference research through self-audit, six stages. Installs into Claude Code, Codex, Cursor and Windsurf in one line.",
"A skill that makes AI coding agents do web design in order. Six stages from reference research to self-audit, each with a pass condition, so defaults get replaced with reasons. Installs into Claude Code, Codex, Cursor and more in one line.",
},
hero: {
h1: "Undecided design ships as defaults",
lead: "The AI look is not a skill problem. It is a decision problem. This skill forces the decisions, in order.",
lead:
"Pages built by AI look templated not because the model lacks taste, but because nobody made the decisions. designpaca is a web design pipeline skill for AI coding agents. Six stages from reference research to self-audit, each with a pass condition, so the slot where a default would land gets a reason instead.",
install: "npx designpaca",
installNote: "Node 20.11+ · detects and preselects your install targets",
installNote: "Node 20.11+ · install targets are detected and preselected",
links: [
{ label: "Repository", href: "https://git.chanpaca.net/yunchan/designpaca" },
{ label: "See the work", href: "/showcase" },
{ label: "npm", href: "https://www.npmjs.com/package/designpaca" },
{ label: "Repository", href: "https://git.chanpaca.net/yunchan/designpaca" },
],
stage: {
alt: "Six glass panes stand in a row along the depth axis. Scrolling moves the camera through them one at a time, and each pane recedes once passed.",
@ -436,64 +463,50 @@ export const en: Content = {
brief: "Build a subscription landing page for a small-batch coffee roastery",
stages: STAGES_EN,
},
showcase: {
h2: "The outputs do not resemble each other",
lead:
"Two briefs through the same pipeline. Stage one forces the tone reference to come from a different industry, so the same tool does not produce the same screen twice. Both still have empty number slots, and that is not an accident either.",
items: [
{
alt: "A coffee subscription landing. Off-white ground, heavy Korean headline, a table of acidity and body figures on the right.",
caption: "Small-batch roastery · subscription conversion",
note: "Preset quiet-commerce. Having no photography became the argument, not the limit. The table values are still blank, and stay blank until the roaster supplies them.",
},
{
alt: "Pulsegate monitoring SaaS hero. Light ground, large serif headline, a free trial button.",
caption: "API monitoring SaaS · free trial signup",
note: "A developer tool did not default to dark. The brief carried no reason for it. The region count is still sitting in braces.",
},
],
},
materials: {
eyebrow: "SVG filters",
h2: "Four things you fix without opening an image editor",
lead:
"There is no shortage of filter demos that make texture. The places where filters actually save you work are elsewhere. In all four below, the left is what you used to hand-tune every time and the right is the same material with one declaration added. The numbers come from capturing this screen and measuring it.",
"Filter demos that make texture are everywhere. What matters is where filters actually save you work. In all four below, the left is what you used to hand-tune every time and the right is the same material with one declaration added. The numbers come from capturing this screen and measuring it.",
demos: [
{
id: "scrim",
name: "A scrim shaped like the letters",
problem: "A caption over a photo fails 4.5:1. Every photo has a different brightness, so the translucent box gets hand-tuned each time.",
problem:
"A caption over a photo fails the contrast threshold. Every photo has a different brightness, so the translucent box gets hand-tuned each time.",
beforeLabel: "just placed on top",
afterLabel: "filter: url(#scrim)",
how: "feMorphology fattens the glyph alpha, a blur softens it, and feFlood fills only that shape. It follows the letters instead of covering the photo with a box.",
verdict: "white-on-photo contrast 1.70:1 → 4.79:1 · swap the photo, nothing to re-tune",
verdict: "white-on-photo contrast 1.70:1 → 4.79:1",
},
{
id: "cvd",
name: "Colour-vision simulation",
problem: "State encoded in colour alone becomes one badge for 1 in 12 men. No amount of looking at your own screen catches it.",
problem:
"State encoded in colour alone becomes one badge for 1 in 12 men. No amount of looking at your own screen catches it.",
beforeLabel: "what you see",
afterLabel: "deuteranopia",
how: "A single feColorMatrix. This is the one filter that must not run in sRGB — colour-vision transforms are physical mixing, so linearRGB is correct here.",
how: "A single feColorMatrix. This is the one filter that must not run in sRGB, because colour-vision transforms are physical mixing. linearRGB is correct here.",
verdict: "minimum colour distance across the three badges 82 → 2 · warning and failure become one colour",
},
{
id: "goo",
name: "Separate shapes fusing into one",
problem: "Getting pieces to melt together usually means reaching for canvas or a physics library. It comes up in loaders, merging menus, indicator transitions.",
problem:
"Getting pieces to melt together usually means reaching for canvas or a physics library. It comes up in loaders, merging menus, indicator transitions.",
beforeLabel: "no filter · 3 circles",
afterLabel: "filter: url(#goo)",
how: "Blur hard, then stand the alpha up 18× with feColorMatrix. Whether they fuse is arithmetic, not taste — the blurred alpha midway between two circles has to clear the threshold (7÷18 ≒ 0.39). At 18px apart with a blur of 7 it sat near 0.2 and never merged; 12px and 10 clears it.",
how: "Blur hard, then stand the alpha up with feColorMatrix. Whether they fuse is arithmetic, not taste: the ratio of gap to blur radius decides it.",
verdict: "counted across the middle row: 3 blobs → 1 · 0 lines of JS",
},
{
id: "glass",
name: "An overlay that bends the backdrop",
problem: "A translucent panel only blurs what is behind it. Real glass bends light — that difference puts the panel inside the picture instead of on top of it.",
problem: "A translucent panel only blurs what is behind it. Real glass bends light.",
beforeLabel: "backdrop-filter: blur",
afterLabel: "blur + url(#lens)",
how: "Hand a displacement map to backdrop-filter. R is the X offset, G is the Y offset, 128 is neutral. Build the map with feTurbulence and every value collapses toward 128, so nothing moves — the map has to be a gradient you draw, pushing inward from the edges.",
verdict: "the grid compresses at the edges · size the map wrong and nothing happens, silently · Chromium only",
how: "Hand a displacement map to backdrop-filter. Build the map with feTurbulence and every value collapses toward neutral, so nothing moves. The map has to be a gradient you draw, pushing inward from the edges.",
verdict: "the grid compresses at the edges · Chromium only",
},
],
},
@ -501,7 +514,7 @@ export const en: Content = {
eyebrow: "This page, measured",
h2: "Fixing the copy does not remove the slop",
lead:
"Polishing sentences fixes sentences. The radius vocabulary, the translucent panels, the number of type steps all stay. So here are this page's own form metrics. If a number comes back badly, it ships as it is.",
"Polishing sentences fixes sentences. The radius vocabulary, the translucent panels, the number of type steps all stay. So this page publishes its own form metrics, and a bad number ships as it is. Leaving the audit in view is the core feature being sold here.",
items: [
{ label: "JS to first paint", value: "0.6%", note: "of the 50KB budget actually spent", feature: true },
{ label: "hard gates", value: "12/12", note: "static checks plus real renders at 320px and 1440px", feature: true },
@ -509,7 +522,7 @@ export const en: Content = {
{ label: "slop fingerprints", value: "0", note: "9 patterns grepped" },
{ label: "radius vocabulary", value: "2", note: "3px and 14px. The pill shape is gone" },
{ label: "translucent panels", value: "2", note: "grew to 29 before being pulled back. Glass stayed only on frames that hold something" },
{ label: "type scale steps", value: "6", note: "as defined in tokens. Display sits outside the scale, and the largest type is 6x the body" },
{ label: "type scale steps", value: "6", note: "as defined in tokens. Display sits outside the scale, and the largest type is 5.6x the body" },
{ label: "font families", value: "2", note: "each language loads only its own" },
{ label: "accent colours", value: "1", note: "light and dark are the same colour" },
{ label: "time to interactive", value: "not yet", note: "measured with Lighthouse after deploy" },
@ -532,9 +545,9 @@ export const en: Content = {
"Our own rule says cards are for when elevation carries real hierarchy. Figures became typography, rules became an offset list, the audit became a log. Five panels of glass remain.",
},
{
caught: "We wrote down that Chrome drops backdrop-filter with SVG. It does not",
caught: "We wrote that Chrome drops backdrop-filter with SVG. It does not",
fixed:
"Attach url() to backdrop-filter and Chrome reports true through CSS.supports while discarding the whole declaration at render time. The computed value becomes none. Refraction is demonstrated for real by the SVG tiles in the materials section, and the panels build thickness from blur and edge light instead.",
"We blamed the browser after one failure. Re-measuring showed Chrome 151 keeps the computed value at url(#id) and genuinely refracts. The real cause was feImage width and height not matching the element, because backdrop-filter does not auto-size filter images. Write a failure down as impossible and that sentence becomes the next person's ceiling.",
},
{
caught: "444px of horizontal overflow. The entire layout was off its rails",
@ -554,7 +567,7 @@ export const en: Content = {
{
caught: "13px of horizontal overflow at 320px, even with the table inside a scroll container",
fixed:
"A grid item's default min-width is auto, so content pushes the parent wider. Overflow only works once min-width:0 is set.",
"A grid item's default min-width is auto, so content pushes the parent wider. Overflow only works once min-width: 0 is set.",
},
{
caught: "6 eyebrow labels. The count rule caps them at 3",
@ -586,7 +599,8 @@ export const en: Content = {
"It reported \"English 1.15:1\", which was wrong. Compositing alpha bottom-up gave zero failures. A checker that is wrong makes both its passes and its failures meaningless.",
},
],
note: "All thirteen were caught while building this page, and fixed after being caught. Three came from a person rather than a checker: the cards look generic, the typography is not handled well, and did we actually use our own skill.",
note:
"All thirteen were caught while building this page, and fixed after being caught. Three came from a person rather than a checker: the cards look generic, the typography is not handled well, and did we actually use our own skill.",
},
typography: {
eyebrow: "Your language is probably not Latin",
@ -600,14 +614,14 @@ export const en: Content = {
title: "Line breaking",
badLabel: "default",
goodLabel: "word-break: keep-all",
note: "The default breaks mid-word. Look at the last line on the left — one word straddles two lines.",
note: "The default breaks mid-word. Look at the last line on the left: one word straddles two lines.",
},
{
id: "leading",
title: "Leading",
badLabel: "line-height 1.45",
goodLabel: "line-height 1.75",
note: "The Latin standard of 1.4-1.5 is tight for syllable blocks with a final consonant.",
note: "The Latin standard of 1.4 to 1.5 is tight for syllable blocks with a final consonant.",
},
{
id: "size",
@ -619,7 +633,7 @@ export const en: Content = {
],
rules: [
"Without word-break: keep-all, words break at arbitrary points",
"line-height between 1.6 and 1.8 — looser than Latin",
"line-height between 1.6 and 1.8, looser than Latin",
"Never use negative tracking. Korean is already near-monospaced",
"25 to 40 characters per line. The Latin 65 is far too long",
"Shipping without a Korean font stack is itself a tell",
@ -627,7 +641,8 @@ export const en: Content = {
},
rules: {
h2: "Every rule states why",
lead: "A prohibition without a reason does not survive contact with a brief. If the user asks for it explicitly, the user wins.",
lead:
"A prohibition without a reason does not survive contact with a brief. So every rule carries its reason. If the user asks for something explicitly, the user wins.",
items: [
{ rule: "Never start without references", why: "Starting from memory produces the category average." },
{ rule: "One accent colour", why: "Wanting a second one means the hierarchy failed." },
@ -642,14 +657,19 @@ export const en: Content = {
},
install: {
h2: "One line",
lead: "Targets are detected and preselected. If you edit a skill file yourself, updates leave it alone.",
lead:
"Install targets are detected and preselected. If you edit a skill file yourself, updates leave it alone.",
command: "npx designpaca",
targets: [
{ name: "Claude Code", path: "~/.claude/skills/designpaca/" },
{ name: "Codex CLI", path: "~/.codex/skills/designpaca/" },
{ name: "Antigravity", path: ".agents/skills/designpaca/" },
{ name: "Cursor", path: ".cursor/rules/designpaca.mdc" },
{ name: "Windsurf", path: ".windsurf/rules/designpaca.md" },
{ name: "Cursor", path: "~/.cursor/skills/designpaca/" },
{ name: "ZCode", path: "~/.zcode/skills/designpaca/" },
{ name: "Neutral path", path: "~/.agents/skills/designpaca/ · any harness" },
{ name: "Antigravity", path: "~/.gemini/antigravity-cli/skills/designpaca/" },
{ name: "Gemini CLI", path: "~/.gemini/skills/designpaca/" },
{ name: "Windsurf", path: "~/.codeium/windsurf/skills/designpaca/" },
{ name: "Copilot", path: "~/.copilot/skills/designpaca/" },
{ name: "AGENTS.md", path: "block injection, agent agnostic" },
],
commands: [

View file

@ -23,6 +23,17 @@ const alt = lang === "ko" ? "/en/" : "/";
// 하드코딩하면 두 언어판이 어긋난다.
const nav = (lang === "ko" ? ko : en).nav;
// 내비 앵커는 홈의 섹션을 가리킨다. 홈이 아닌 페이지(쇼케이스 등)에서는
// "#pipeline" 이 그 페이지 안의 없는 앵커가 되어 아무 일도 하지 않는다 —
// 홈 경로를 붙여 홈 섹션으로 건너가게 한다.
const homePath = lang === "ko" ? "/" : "/en/";
const normalizedPath = Astro.url.pathname.replace(/\/+$/, "") || "/";
const isHome = normalizedPath === (lang === "ko" ? "/" : "/en");
const navItems = nav.items.map((i) => ({
...i,
href: !isHome && i.href.startsWith("#") ? `${homePath}${i.href}` : i.href,
}));
// 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크.
// data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다.
// 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다.
@ -99,7 +110,7 @@ const FAVICON =
</a>
<nav class="nav-main" aria-label={lang === "ko" ? "섹션" : "Sections"}>
{nav.items.map((i) => <a href={i.href}>{i.label}</a>)}
{navItems.map((i) => <a href={i.href}>{i.label}</a>)}
{/* 페이지를 벗어나는 유일한 링크다. 화살표로 그렇다고 알린다 */}
<a class="nav-repo" href={nav.repo} rel="noopener">
{nav.repoLabel}
@ -111,9 +122,10 @@ const FAVICON =
</nav>
<div class="header-end">
{/* 언어는 국가가 아니다. 국기 아이콘을 쓰지 않는다 */}
{/* 언어는 국가가 아니다. 국기 아이콘을 쓰지 않는다.
좁은 화면(<480px)에서는 이 버튼이 사라지고 메덹 안 행으로 들어간다 */}
<a
class="btn btn-quiet btn-sm"
class="btn btn-quiet btn-sm lang-btn"
href={alt}
hreflang={lang === "ko" ? "en" : "ko"}
lang={lang === "ko" ? "en" : "ko"}
@ -121,18 +133,40 @@ const FAVICON =
{lang === "ko" ? "English" : "한국어"}
</a>
{/* 모바일 메뉴. details 라 JS 가 0바이트이고, 키보드와
스크린리더 대응이 브라우저 기본으로 온다 */}
{/* 모바일 메뉴. details 라 JS 가 0바이트에 가깝고, 열림 상태,
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다.
시트는 알약 전체 폭으로 열린다 — 11rem 짜리 조각 상자가 아니라 */}
<details class="nav-mobile">
<summary class="btn btn-quiet btn-sm" aria-label={nav.menu}>
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
<summary class="btn btn-quiet btn-sm" aria-label={nav.menu} aria-haspopup="menu">
<svg class="i-burger" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<svg class="i-close" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</summary>
<div class="nav-sheet glass">
{nav.items.map((i) => <a href={i.href}>{i.label}</a>)}
<a href={nav.repo} rel="noopener">{nav.repoLabel} ↗</a>
</div>
<nav class="nav-sheet glass" aria-label={nav.menu}>
{navItems.map((i, idx) => (
<a href={i.href}>
<span class="sheet-num" aria-hidden="true">{String(idx + 1).padStart(2, "0")}</span>
{i.label}
</a>
))}
<a href={nav.repo} rel="noopener">
<span class="sheet-num" aria-hidden="true">↗</span>
{nav.repoLabel}
</a>
<a
class="sheet-lang"
href={alt}
hreflang={lang === "ko" ? "en" : "ko"}
lang={lang === "ko" ? "en" : "ko"}
>
<span class="sheet-num" aria-hidden="true">A</span>
{lang === "ko" ? "English" : "한국어"}
</a>
</nav>
</details>
</div>
</div>
@ -144,6 +178,25 @@ const FAVICON =
<script is:inline>
// 메덥 닫기 두 가지. details 의 나머지(Esc·포커스·aria)는 브라우저 몫.
// 1) 행을 누르면 앵커로 스크롤하고 메덥은 스스로 닫는다 —
// 열린 채로 남아 본문을 가리는 것이 이 메뉴의 유일한 결함이었다.
// 2) 바깥을 누르면 닫는다. scroll 리스너는 쓰지 않는다(게이트 10).
(function () {
var menu = document.querySelector(".nav-mobile");
if (!menu) return;
menu.addEventListener("click", function (e) {
if (e.target && e.target.closest && e.target.closest("a")) {
menu.removeAttribute("open");
}
});
document.addEventListener("click", function (e) {
if (menu.open && !(e.target && e.target.closest && e.target.closest(".nav-mobile"))) {
menu.removeAttribute("open");
}
});
})();
// 진입 연출. 한 번 보이면 계속 보인다.
// scroll 리스너를 쓰지 않는다(하드 게이트 10).
(function () {
@ -206,6 +259,7 @@ const FAVICON =
gap: var(--space-4);
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
border-radius: var(--radius-frame);
position: relative; /* 모바일 시트의 기준 — 알약 전체 폭으로 펼친다 */
}
.wordmark {
display: inline-flex;
@ -246,42 +300,93 @@ const FAVICON =
}
/* ---- 모바일 메뉴 ------------------------------------------
details 를 쓴 이유는 JS 0바이트 때문만이 아니다. 열림 상태,
details 를 쓴 이유는 JS 를 줄이기 위해서만이 아니다. 열림 상태,
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다. */
.nav-mobile { position: relative; }
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다.
1′ 국소 레퍼런스(navbar.gallery → browserbase.com): 항목이 많은 사이트는
내비 마크업을 아예 복제해 분리한다. 우리는 5행이라 복제하지 않고
시트를 알약 전체 폭으로 펼친다 — 조각 상자보다 계기판답다. */
.nav-mobile { position: static; }
.nav-mobile > summary {
list-style: none;
padding-inline: 0;
width: var(--control-h-sm);
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-h); /* 터치 대상은 44px — 36px 는 마우스용 */
min-height: var(--control-h);
padding: 0;
}
.nav-mobile > summary::-webkit-details-marker { display: none; }
.nav-mobile > summary::marker { content: ""; }
.nav-mobile[open] > summary {
background: var(--glass-tint);
color: var(--ink);
}
.i-close { display: none; }
.nav-mobile[open] .i-burger { display: none; }
.nav-mobile[open] .i-close { display: block; }
.nav-sheet {
/* 닫힌 details 의 자식에 display 를 주면 UA 숨김을 덮어써
'보이지 않지만 레이아웃에 사는' 시트가 된다(320px 오버플로의 원인).
닫힘을 명시적으로 다시 선언한다 — details 패턴의 필수 안전장치 */
display: none;
position: absolute;
top: calc(100% + var(--space-2));
left: 0;
right: 0;
min-width: 11rem;
padding: var(--space-2);
display: grid;
gap: 2px;
z-index: 4;
border-radius: var(--radius-frame);
/* 메뉴는 콘텐츠가 아니라 크롬이다. .glass 의 4.5% 잉크는 알약에는
맞는 재질이지만 시트에는 가독성 버그다 — 뒤 글자가 비쳐 겹친다.
배경만 채우고 테두리 광(::after rim)·그림자는 유리 재질 그대로 */
background: color-mix(in oklab, var(--surface) 94%, white 6%);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nav-mobile[open] .nav-sheet { display: grid; }
.nav-sheet a {
display: flex;
align-items: center;
min-height: var(--control-h);
gap: var(--space-3);
min-height: calc(var(--control-h) + 4px);
padding-inline: var(--space-3);
border-radius: var(--radius-sm);
color: var(--ink);
text-decoration: none;
}
.nav-sheet a:hover { background: var(--glass-tint-2); }
.sheet-num {
font-family: var(--font-mono);
font-size: var(--mono-size);
color: var(--accent);
flex: none;
}
.sheet-lang { color: var(--ink-muted); }
.sheet-lang:hover { color: var(--ink); }
/* 열림 연출 — transform/opacity 만(게이트 8) */
.nav-mobile[open] .nav-sheet {
animation: sheet-in 180ms var(--ease-out) both;
}
@keyframes sheet-in {
from { opacity: 0; transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.nav-mobile[open] .nav-sheet { animation: none; }
}
@media (min-width: 900px) {
.nav-main { display: flex; }
.nav-mobile { display: none; }
}
/* 480px 미만: 언어 버튼은 헤더에서 물러나고 메덥 행으로 간다.
320px 실측에서 워드마크+English+버거가 알약을 7px 밀어냈다 */
@media (max-width: 480px) {
.lang-btn { display: none; }
.header-inner { gap: var(--space-2); }
}
</style>

View file

@ -5,114 +5,161 @@ import BgPhoto from "../components/BgPhoto.astro";
import { WORK } from "../data/work.ts";
/*
쇼케이스. 소개 페이지 안의 섹션이 아니라 **별도 페이지**다.
쇼케이스. 카드는 간단한 정보만 싣고(1′ 레퍼런스 tailwindcss.com/showcase:
이미지 + 이름 + 한 줄 종류 + 태그), 상세는 네이티브 <dialog> 모달로.
showModal() 이 Esc·포커스 트랩·aria를 브라우저가 처리한다 —
<details> 메뉴와 같은 결정이다. 사이트 자체는 모달의 큰 CTA로 새 탭.
홈에 있던 예전 섹션은 스크린샷 두 장을 카드에 넣고 각도를 살짝 준 것이었고,
들어갈 곳이 없었다 — 그림만 보여주고 끝나는 자리는 증거가 아니라 장식이다.
여기서는 타일을 누르면 **실제로 도는 사이트**로 들어간다.
타일 크기를 섞는다. 균일한 격자는 이 스킬이 금지하는 형태이고,
사례마다 중요도가 다른데 같은 크기로 두면 그 차이가 사라진다.
그리드는 균일하지 않다: 첫 사례(목요일의 화원)만 2열 폭으로 features,
나머지는 3열. 중요도가 다른데 같은 크기로 두면 그 차이가 사라진다.
*/
const title = "사례 · designpaca";
const description =
"designpaca 파이프라인으로 만든 사이트들. 각 사례의 컨셉과 감수한 리스크, 실측값을 함께 적었습니다. 타일을 누르면 실제 사이트가 열립니다.";
"designpaca 파이프라인으로 만든 사이트들. 카드를 누르면 컨셉과 리스크, 실측값이 담긴 상세가 열립니다.";
const ariaClose = "닫기";
const ariaDetail = "자세히 보기";
const siteLabel = "사이트 보기";
---
<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>
<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">
<ul class="full cards">
{
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">
<li class:list={["card", { featured: w.size === "lg" }, "reveal"]} style={`--reveal-delay:${i * 70}ms`}>
<article>
<figure class="card-shot">
<Image
src={w.shot}
alt={w.alt}
widths={[520, 900, 1440]}
sizes="(max-width: 820px) 92vw, 46vw"
widths={[520, 900, 1200]}
sizes={w.size === "lg" ? "(max-width: 900px) 92vw, 62vw" : "(max-width: 900px) 92vw, 30vw"}
loading={i === 0 ? "eager" : "lazy"}
/>
{w.shotAlt && (
<Image
class="tile-shot-alt"
class="card-shot-alt"
src={w.shotAlt}
alt=""
widths={[520, 900, 1440]}
sizes="(max-width: 820px) 92vw, 46vw"
widths={[520, 900, 1200]}
sizes={w.size === "lg" ? "(max-width: 900px) 92vw, 62vw" : "(max-width: 900px) 92vw, 30vw"}
loading="lazy"
aria-hidden="true"
/>
)}
</span>
<span class="card-badge mono">{w.preset}</span>
</figure>
<span class="tile-body">
<span class="tile-head">
<span class="tile-name">{w.name}</span>
<span class="tile-kind muted">{w.kind}</span>
<div class="card-body">
<h2 class="card-name">{w.name}</h2>
<p class="card-kind">{w.kind}</p>
<p class="card-concept">{w.concept}</p>
<span class="card-more" aria-hidden="true">
{ariaDetail}
<svg viewBox="0 0 12 12" width="10" height="10">
<path d="M2.5 9.5 9.5 2.5M4 2.5h5.5V8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
</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>
</div>
<button type="button" class="card-open" data-dialog={w.slug} aria-label={`${w.name} ${ariaDetail}`} />
</article>
{/* 상세 모달. 네이티브 <dialog>: Esc·포커스·inert 를 브라우저가 처리 */}
<dialog id={`wd-${w.slug}`} class="work-dialog" aria-labelledby={`wd-${w.slug}-t`}>
<div class="dialog-inner">
<button type="button" class="dialog-close" data-close aria-label={ariaClose}>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
<figure class="dialog-shot">
<Image
src={w.shot}
alt={w.alt}
widths={[680, 1080, 1440]}
sizes="min(92vw, 720px)"
loading="lazy"
/>
</figure>
<div class="dialog-body">
<p class="dialog-meta mono">{w.kind} · {w.preset}</p>
<h2 class="dialog-name" id={`wd-${w.slug}-t`}>{w.name}</h2>
<p class="dialog-concept">{w.concept}</p>
<div class="dialog-risk">
<span class="risk-label">감수한 리스크</span>
<p>{w.risk}</p>
</div>
<ul class="dialog-metrics mono">
{w.metrics.map((m) => (
<li>{m}</li>
))}
</ul>
<a class="btn btn-solid dialog-site" href={w.href} target="_blank" rel="noopener">
{siteLabel}
<span class="site-icon" aria-hidden="true">
<svg viewBox="0 0 12 12" width="11" height="11">
<path d="M2.5 9.5 9.5 2.5M4 2.5h5.5V8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
</span>
</a>
</div>
</div>
</dialog>
</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;
text-wrap: balance;
word-break: keep-all;
}
.intro-lead {
margin-bottom: var(--space-4);
max-width: var(--measure);
text-wrap: pretty;
word-break: keep-all;
}
.intro-note {
max-width: var(--measure);
text-wrap: pretty;
word-break: keep-all;
}
.grid-section {
padding-block: 0 var(--space-8);
}
/* ── 모자이크 ────────────────────────────────────────
타일 크기가 섞인다. 사례마다 무게가 다른데 같은 크기로 두면
그 차이가 사라지고, 그때부터는 격자가 내용을 지운다. */
.mosaic {
/* ── 카드 그리드: 첫 사례만 2열, 나머지 3열 ── */
.cards {
list-style: none;
margin: 0;
max-width: 1180px;
@ -120,37 +167,33 @@ const description =
padding: 0 var(--gutter);
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--space-6) var(--space-5);
gap: var(--space-7) var(--space-5);
}
.tile {
.card {
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;
.featured {
grid-column: span 6;
}
/* 큰 타일은 그림과 글을 나란히 둔다. 세로로 쌓으면 화면을 통째로 먹는다 */
.tile-lg .tile-link {
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
.card article {
position: relative;
display: grid;
gap: var(--space-3);
height: 100%;
}
.featured article {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: var(--space-6);
align-items: center;
}
.tile-shot {
/* 액자: 유리 한 장이 아니라 사진이 든 판. 겉 테두리(쉘) + 속 이미지(코어) */
.card-shot {
position: relative;
margin: 0;
display: block;
overflow: hidden;
border-radius: var(--radius-md);
@ -158,99 +201,336 @@ const description =
background: var(--surface-2);
aspect-ratio: 16 / 10;
}
.tile-shot :global(img) {
.card-shot :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
/* 두 번째 컷은 위에 겹쳐 두고 호버에서 바꾼다.
스크롤이나 자동 전환으로 바꾸지 않는다 — 읽는 사람의 속도를 뺏지 않는다 */
.tile-shot-alt {
.card-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);
.card-badge {
position: absolute;
top: var(--space-3);
right: var(--space-3);
background: rgba(11, 14, 18, 0.85);
backdrop-filter: blur(4px);
border: 1px solid var(--line);
color: var(--accent);
font-size: var(--mono-size);
padding: 2px 8px;
border-radius: var(--radius-sm);
letter-spacing: var(--tracking-label);
z-index: 2;
}
.tile-metrics {
.card-body {
display: grid;
gap: var(--space-1);
align-content: start;
}
.card-name {
margin: 0;
font-size: var(--step-1);
font-weight: var(--weight-medium);
text-wrap: balance;
word-break: keep-all;
}
.card-kind {
margin: 0;
color: var(--ink-muted);
font-size: var(--step-0);
}
.card-concept {
margin: var(--space-2) 0 0;
color: var(--ink-muted);
font-size: var(--step-0);
line-height: var(--leading-normal);
text-wrap: pretty;
word-break: keep-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.featured .card-concept {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.card-more {
margin-top: var(--space-2);
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--step--1);
letter-spacing: var(--tracking-label);
color: var(--ink-faint);
justify-self: start;
}
/* 카드 전체가 하나의 버튼이다 — 시각 요소는 비활성 텍스트로 남고
실제 포커스 대상은 이 하나 (키보드 탭 순서 = 카드 순서) */
.card-open {
position: absolute;
inset: 0;
z-index: 3;
border: 0;
padding: 0;
background: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.card-open:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
}
/* ── 호버: 테두리 각성 + 스크린샷 교체 + 자세히 화살 이동 ── */
@media (prefers-reduced-motion: no-preference) {
.card-shot {
transition: border-color var(--dur-quick) var(--ease-out),
box-shadow var(--dur-quick) var(--ease-out);
}
.card-shot :global(img) {
transition: opacity var(--dur-slow) var(--ease-soft);
}
.card-more svg {
transition: transform var(--dur-quick) var(--ease-out);
}
article:has(.card-open:hover) .card-shot,
article:has(.card-open:focus-visible) .card-shot {
border-color: var(--accent);
box-shadow: 0 4px 24px rgba(79, 214, 196, 0.08);
}
article:has(.card-open:hover) .card-shot-alt,
article:has(.card-open:focus-visible) .card-shot-alt {
opacity: 1;
}
article:has(.card-open:hover) .card-more {
color: var(--ink);
}
article:has(.card-open:hover) .card-more svg {
transform: translate(2px, -2px);
}
article:has(.card-open:active) {
scale: 0.99;
}
}
/* ── 상세 모달 ── */
.work-dialog {
width: min(92vw, 720px);
max-height: min(88dvh, 900px);
padding: 0;
border: 1px solid var(--line-strong);
border-radius: var(--radius-frame);
/* 모달은 유리가 아니라 판이다 — 메뉴 시트와 같은 교훈: 크롬은 불투명 */
background: var(--surface);
color: var(--ink);
overflow-y: auto;
overscroll-behavior: contain;
}
.work-dialog::backdrop {
background: rgba(4, 6, 9, 0.62);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
@media (prefers-reduced-motion: no-preference) {
.work-dialog[open] {
animation: dialog-in 220ms var(--ease-soft) both;
}
@keyframes dialog-in {
from {
opacity: 0;
transform: translateY(14px) scale(0.985);
}
}
}
.dialog-inner {
position: relative;
display: grid;
}
.dialog-close {
position: absolute;
top: var(--space-3);
right: var(--space-3);
z-index: 4;
width: var(--control-h);
height: var(--control-h);
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
/* 밝은 스크린샷 위에 얹힌다 — 반투명은 가독성이 아니라 배경과의 타협이다 */
background: var(--surface);
color: var(--ink-muted);
cursor: pointer;
transition: color var(--dur-quick) var(--ease-out),
border-color var(--dur-quick) var(--ease-out);
}
.dialog-close:hover {
color: var(--ink);
border-color: var(--ink-faint);
}
.dialog-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dialog-shot {
margin: 0;
overflow: hidden;
border-bottom: 1px solid var(--line);
background: var(--surface-2);
}
.dialog-shot :global(img) {
display: block;
width: 100%;
height: auto;
}
.dialog-body {
display: grid;
gap: var(--space-3);
padding: var(--space-5) var(--space-5) var(--space-6);
}
.dialog-meta {
margin: 0;
color: var(--ink-faint);
font-size: var(--mono-size);
letter-spacing: var(--tracking-label);
}
.dialog-name {
margin: 0;
font-size: var(--step-2);
line-height: var(--leading-tight);
text-wrap: balance;
word-break: keep-all;
}
.dialog-concept {
margin: 0;
color: var(--ink-muted);
line-height: var(--leading-normal);
max-width: 38em;
text-wrap: pretty;
word-break: keep-all;
}
.dialog-risk {
display: grid;
gap: var(--space-1);
margin-top: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.risk-label {
font-size: var(--step--1);
letter-spacing: var(--tracking-label);
color: var(--accent);
font-weight: 500;
}
.dialog-risk p {
margin: 0;
color: var(--ink-muted);
font-size: var(--step-0);
line-height: var(--leading-normal);
max-width: 40em;
text-wrap: pretty;
word-break: keep-all;
}
.dialog-metrics {
margin: var(--space-1) 0 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
gap: var(--space-2);
font-size: var(--mono-size);
color: var(--ink-muted);
}
.metric + .metric::before {
content: "·";
margin-right: var(--space-4);
color: var(--line-strong);
.dialog-metrics li {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 3px 10px;
}
/* ── 호버: 그림이 바뀌고 테두리가 선다 ─────────────── */
@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;
}
/* 큰 CTA — 버튼 안의 버튼(둥근 화살 칸)은 cmd-copy 아이콘과 같은 어휘 */
.dialog-site {
margin-top: var(--space-4);
justify-self: start;
display: inline-flex;
align-items: center;
gap: var(--space-3);
padding-inline: var(--space-4) var(--space-3);
font-size: var(--step-1);
font-weight: var(--weight-medium);
}
.site-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--accent-ink) 18%, transparent);
transition: transform var(--dur-quick) var(--ease-out);
}
.dialog-site:hover .site-icon {
transform: translate(2px, -2px);
}
@media (max-width: 900px) {
.mosaic {
grid-template-columns: 1fr;
.cards {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-6) var(--space-4);
}
.tile-lg,
.tile-md,
.tile-sm {
.card {
grid-column: span 1;
}
.tile-lg .tile-link {
.featured {
grid-column: span 2;
}
.featured article {
grid-template-columns: 1fr;
gap: var(--space-4);
}
}
@media (max-width: 600px) {
.cards {
grid-template-columns: 1fr;
}
.featured {
grid-column: span 1;
}
}
</style>
<script>
// 모달 열기/닫기. showModal 이 포커스 트랩·Esc·inert 를 처리하므로
// 여기서는 여는 것과 배경 탭 닫기만.
const openers = document.querySelectorAll<HTMLButtonElement>(".card-open");
openers.forEach((btn) => {
btn.addEventListener("click", () => {
const dialog = document.getElementById(`wd-${btn.dataset.dialog}`);
if (dialog instanceof HTMLDialogElement) dialog.showModal();
});
});
document.querySelectorAll<HTMLDialogElement>(".work-dialog").forEach((dialog) => {
// 배경(=dialog 자신의 영역)을 누르면 닫는다. 내부 클릭은 target이 다르다
dialog.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close();
});
const closeBtn = dialog.querySelector("[data-close]");
closeBtn?.addEventListener("click", () => dialog.close());
});
</script>