designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포. 스킬 (packages/skill) - SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로 본문은 절차와 인덱스만, 지식은 references/ 로 분리 - 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기 - 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외 - 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙, SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침 설치 CLI (packages/cli, packages/core) - npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md - 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다 - 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성 - AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입 - Windsurf 는 12,000자 상한 초과 시 설치를 차단 배포 (build/ci, .forgejo/workflows) - 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러 → draft 릴리스 → Cloudflare Pages. 재실행 멱등 근거 (research/) - 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다 테스트 22개 통과 (core 16 · cli 6)
This commit is contained in:
commit
8808c672dc
135 changed files with 38838 additions and 0 deletions
82
research/dogfood-saas/01-references.md
Normal file
82
research/dogfood-saas/01-references.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# 1단계 — 레퍼런스 조사 (Pulsegate)
|
||||
|
||||
## 브리프
|
||||
백엔드/SRE 엔지니어가 **무료 체험에 가입**하게. 느낌은 **정밀한, 절제된**.
|
||||
|
||||
---
|
||||
|
||||
## R1 · 구조 — Checkly https://www.checklyhq.com
|
||||
같은 업종(합성 모니터링 SaaS). 라우팅 표의 Refero/Land-book이 모두 막혀 원본 사이트로 직행함.
|
||||
|
||||
- **가져올 축**: 축 1(그리드), 축 4(여백 리듬), 축 6(시선 흐름)
|
||||
- **그리드**: 컨테이너 1232px (뷰포트 1440 대비 86%) / 본문 컬럼 450px / 내부 분할 584·672·745px
|
||||
- **여백**: 섹션 상하 `96/64` `80/80` `64/80` `48/48` — 4종. 섹션 간 80 : 요소 간 16~24 ≈ **1:4**
|
||||
- **타입(참고용)**: h1 48px / 본문 16px → **최대÷본문 = 3.0배 → 4배 미만, 위계가 약한 레퍼런스**
|
||||
- **시선 흐름**: 수직 낙하. h1 → 서브카피 → CTA 2개(`Book a demo` + `Start for free`) → 터미널 스니펫 `npx checkly init` → 로고월
|
||||
- **6축 밖의 관측**
|
||||
- 히어로에 **실행 가능한 명령어**를 배치한다(`npx checkly init`). 스크린샷보다 강한 "셋업 시간" 증거다
|
||||
- 온보딩 섹션이 **시간 눈금**으로 되어 있다(`Day 1 0:00 / 0:05 / 0:15`). 소요 시간을 주장 대신 구조로 보여준다
|
||||
- radius 어휘 **9종**(2·4·6·8·12·16·9999·50%·복합) → antipatterns 4장 "radius가 결정이 아님"에 해당. 가져오지 않는다
|
||||
- CTA가 12종. 주 행동이 흐려져 있다
|
||||
- **변형**: 컨테이너를 **1232 → 1120**으로 축소하고 본문 컬럼을 **450 → 640px**로 넓힌다. Checkly의 450px는 16px 본문에서 한 줄 55자 내외라 짧고, 우리 카피는 조건절이 길다
|
||||
|
||||
---
|
||||
|
||||
## R2 · 톤 — Fitzcarraldo Editions https://fitzcarraldoeditions.com [업종: 독립 출판사 — R1과 다름 ✓]
|
||||
Klim(타입 파운드리)에서 출발했으나 페이지가 자동 이탈해 이 사이트의 값을 측정하게 됨(→ FINDINGS D-2).
|
||||
reference-method.md §1이 예시로 든 "SaaS 구조 + 독립 출판사 톤" 조합에 정확히 해당해 그대로 채택.
|
||||
|
||||
- **가져올 축**: 축 3(컬러 역할), 축 2(타입 스케일)
|
||||
- **컬러 역할**: surface 2단계(`#FFFFFF` / `#FBFBFB`) · text 3단계(`rgb(21,27,35)` 잉크 / `rgb(102,102,102)` 보조 / `rgb(54,54,54)`) · **강조 1색 `rgb(0,51,160)` 코발트 블루**, 등장 45회지만 전부 **링크 텍스트** — 즉 강조가 면적이 아니라 **글자**로만 존재한다
|
||||
- **타입**: 패밀리 2개(`adobe-caslon-pro` + `kings-caslon`) — **전면 세리프**. 크기 5단계(15/16/18/20.8/22). 최대÷본문 = **1.375배**
|
||||
- **여백/형태**: radius 2·3·4·5px — **작고 4종**. 본문 컬럼 522px
|
||||
- **6축 밖의 관측**
|
||||
- **강조색을 배경으로 절대 쓰지 않는다.** 파랑은 오직 링크. "강조색을 지우면 여전히 읽히는가?" → 읽힌다
|
||||
- 위계를 크기가 아니라 **서체 성격과 여백**으로 만든다. 1.375배라는 평평한 스케일이 성립하는 이유
|
||||
- **변형**: 타입 스케일을 **1.375배 → 1.333 비율 5단계(최대÷본문 3.2배)**로 벌린다. 출판사는 목록을 훑게 하지만 우리는 **하나의 CTA로 떨어뜨려야** 하므로 위계가 더 필요하다. 세리프는 디스플레이에만 쓰고 본문은 산세리프로(코드·수치 가독성)
|
||||
|
||||
---
|
||||
|
||||
## R3 · 디테일 — Codrops «Creating Scroll-Driven SVG Map Animations with GSAP»
|
||||
https://tympanus.net/codrops/2026/05/21/creating-scroll-driven-svg-map-animations-with-gsap/
|
||||
|
||||
- **가져올 축**: 축 5(모션 언어) — R3는 이 축만 채운다
|
||||
- **모션**: 스크롤 진행도에 SVG `path`의 그리기 진행(`drawSVG`)을 바인딩하고, 점이 경로를 따라 이동. 뷰포트가 점을 추적하며 `scale 2.5 → 4`
|
||||
- **원문 값**: `scrub: 1` / `pin` / `duration: 1` / `ease: 'expo'` (추적), `ease: 'power1.inOut'` (줌)
|
||||
- **구현 제약**
|
||||
- 원문은 `gsap` + `ScrollTrigger` + `DrawSVGPlugin` + `MotionPathPlugin` — **4개 합계 약 60KB(gzip)**. 히어로 JS 예산 150KB의 **40%**
|
||||
- `pin`은 레이아웃을 `transform`으로 고정한다. 모바일 주소창 리사이즈에서 재계산이 필요하고 `ScrollTrigger.refresh()`를 폰트 로드 후 호출해야 한다
|
||||
- 원문에 성능·브라우저 지원 언급 **없음**(→ FINDINGS D-4). 아래 제약은 내가 판정한 것
|
||||
- **변형**: GSAP을 쓰지 않고 **CSS `animation-timeline: view()` + `stroke-dashoffset`**으로 대체한다. JS 0KB. 미지원 브라우저에서는 경로가 처음부터 그려진 상태로 남아 정보 손실이 없다. 이유: 60KB를 "장식 모션 하나"에 쓰면 셋업 시간을 파는 제품의 페이지가 스스로를 반증한다
|
||||
|
||||
---
|
||||
|
||||
## 회색조 위계
|
||||
색을 전부 제거했을 때 가장 강한 덩어리는 **h1(셋업 시간 주장)**, 두 번째는 **터미널 설치 명령 블록**, 세 번째는 **주 CTA 버튼**.
|
||||
|
||||
비즈니스 우선순위 [1 무료 체험 가입 / 2 셋업이 짧다는 증거 / 3 가격 투명성]와 **일치하지 않는다.**
|
||||
→ 조정: CTA를 두 번째로 올리고 명령 블록을 세 번째로 내린다. **크기·굵기·여백으로 조정하고 색은 쓰지 않는다.** 명령 블록은 배경 톤만 다르게 하고 테두리·그림자를 주지 않는다.
|
||||
|
||||
수정 후: **h1 → CTA → 명령 블록** = [1/2/3]과 일치.
|
||||
|
||||
---
|
||||
|
||||
## 안 할 것 (antipatterns.md 중 이 브리프에 특히 위험한 것)
|
||||
|
||||
1. **§1 「네온 온 다크」** — "게이밍·개발자 툴 브리프가 아닌데 나오면 즉시 티가 남". 우리는 개발자 툴이라 면책 대상처럼 보이지만, 그게 정확히 관성이다. 발광 대신 **명도 차이로만** 위계를 만든다
|
||||
2. **§4 「펄스 애니메이션 상태 점」** — 모니터링 제품의 최대 유혹. "All systems operational" 옆 깜빡이는 초록 원. **상태가 실제로 바뀔 때만** 쓴다. 랜딩의 정적 목업에는 넣지 않는다
|
||||
3. **§2 「모노스페이스를 장식으로」** — 터미널 미학을 흉내내려 라벨까지 mono로 가는 것. **코드·데이터·식별자에만** 쓴다
|
||||
4. **§6 「모든 숫자가 어림수」** — `99.9% uptime`, `10,000+ users`. Checkly도 `20+`, `10x`를 쓴다. 우리는 **측정 조건이 붙은 정확한 수치**만 쓴다
|
||||
5. **§3 「표준 골격」** — 히어로 → 3열 피처 → 로고월 → 요금제 → FAQ. Checkly가 거의 이 순서다. 섹션 순서를 **설득 논리**로 재배열한다
|
||||
6. **§4 「요금제 3열 + Most popular 배지」** — 가격 민감 청중이라 요금 섹션이 필수인데, 가장 재현율 높은 템플릿이다. 비교 표나 단일 가격으로 간다
|
||||
|
||||
---
|
||||
|
||||
## 검증 조건 자가 확인
|
||||
- [x] R1/R2/R3 각각 원본 사이트 URL 있음 (갤러리 URL 아님)
|
||||
- [x] R2 업종이 R1과 다름 (모니터링 SaaS ↔ 독립 출판사)
|
||||
- [x] 각 슬롯에 가져올 축 명시
|
||||
- [x] 각 슬롯에 변형 1개와 이유
|
||||
- [x] 6축에 숫자 있음 — **§5 computed style을 R1·R2 모두 실행함** (Playwright)
|
||||
- [x] R3에 구현 제약 있음 (단, 원문이 아니라 내가 판정)
|
||||
- [x] 회색조 위계 — 불일치를 발견하고 조정안을 적음
|
||||
46
research/dogfood-saas/02-direction.md
Normal file
46
research/dogfood-saas/02-direction.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# 2단계 — 방향 결정 (Pulsegate)
|
||||
|
||||
## 한 문장 컨셉
|
||||
**야간 당직실의 계기판 — 읽히도록 어둡고, 출판물처럼 조판된.**
|
||||
|
||||
R1(Checkly)에서 구조를, R2(Fitzcarraldo)에서 "강조색은 배경이 아니라 글자에만"을 가져와 합친 결과다.
|
||||
계기판이되 네온이 아니고, 출판물이되 종이가 아니다.
|
||||
|
||||
## 프리셋
|
||||
`dark-instrument` — 개발자·데이터·모니터링 제품.
|
||||
|
||||
### 다크를 기본값으로 삼는 근거 (프리셋이 요구한 정당화)
|
||||
`dark-instrument.md`의 정당화 예시 3개 중 **2개에 해당한다.**
|
||||
1. **"사용자가 어두운 환경에서 본다"** — 청중이 온콜 SRE다. 장애는 새벽에 난다
|
||||
2. **"제품 자체(터미널·에디터·모니터링)가 어둡다"** — 제품 스크린샷이 다크 대시보드다. 랜딩이 라이트면 스크린샷이 페이지에서 뜬다
|
||||
|
||||
근거를 댈 수 있으므로 다크를 기본으로 간다. 단 `dark-instrument.md` 흔한 실패 #3에 따라 **라이트를 파생물이 아니라 동등한 테마로** 만든다(브리프도 둘 다 요구).
|
||||
|
||||
## R2를 섞은 지점 (프리셋 평균을 벗어나기 위해)
|
||||
- 디스플레이 서체를 **세리프**로 — dark-instrument 기본값은 산세+모노다. 출판사 톤을 여기에 넣는다
|
||||
- **강조색을 면적으로 쓰지 않는다** — Fitzcarraldo는 코발트를 45회 쓰지만 전부 링크 글자다
|
||||
- radius를 **2~4px**로 — 출판물의 각진 인상. dark-instrument는 radius를 규정하지 않는다
|
||||
|
||||
## 감수할 리스크 하나
|
||||
> **주 CTA 버튼에 강조색을 쓰지 않는다.** CTA는 잉크 반전(밝은 표면 + 어두운 글자)으로 만들고, 코발트는 오직 인라인 링크와 데이터 축에만 쓴다.
|
||||
|
||||
- **왜 리스크인가**: B2B SaaS 랜딩에서 주 CTA를 무채색으로 두는 건 전환율 관행에 반한다
|
||||
- **왜 정당한가**: 이 페이지에서 가장 밝은 덩어리가 CTA 하나뿐이면 색 없이도 시선이 거기로 간다(회색조 위계 1위 = h1, 2위 = CTA). 동시에 `antipatterns.md` 최우선 지문 1위(보라·인디고 CTA)와 §1 「네온 온 다크」를 구조적으로 회피한다
|
||||
- **철회 조건**: 프리플라이트 회색조 검사에서 CTA가 3위 밖으로 밀리면 즉시 강조색을 넣는다
|
||||
|
||||
## 섹션 순서 — 표준 골격을 쓰지 않는다
|
||||
`antipatterns.md` §3 「표준 골격」(히어로 → 3열 피처 → 로고월 → 요금제 → FAQ)을 버리고 **설득 논리**로 재배열한다.
|
||||
|
||||
| # | 섹션 | 역할 | 왜 여기인가 |
|
||||
|---|---|---|---|
|
||||
| 1 | 히어로 | 셋업 시간 주장 + 설치 명령 | 가격·시간 민감 청중에게 첫 화면에서 시간을 말한다 |
|
||||
| 2 | 반론 처리 | "이미 Datadog을 쓴다" | 이 청중의 실제 첫 반응. 피처 나열보다 먼저 온다 |
|
||||
| 3 | 실제 화면 | 제품 스크린샷 3장 | 주장 다음에 증거 |
|
||||
| 4 | 가격 | 단일 가격 + 계산 근거 | 민감 요인을 숨기지 않고 중간에 |
|
||||
| 5 | 마이그레이션 | Datadog/Grafana에서 옮기는 절차 | 전환 장벽 제거 |
|
||||
| 6 | 최종 CTA + 푸터 | | |
|
||||
|
||||
**로고월·후기·FAQ 없음.** 검증 가능한 고객 실명과 실제 문의가 없으므로 `antipatterns.md` §4에 따라 섹션 자체를 만들지 않는다.
|
||||
|
||||
## 4단계 범위 (이번 검증)
|
||||
1번 히어로만 코드로 만든다. 2~6번은 위 표가 계획이다.
|
||||
160
research/dogfood-saas/03-tokens.md
Normal file
160
research/dogfood-saas/03-tokens.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# 3단계 — 디자인 토큰과 예산 (Pulsegate)
|
||||
|
||||
## ⚠ 작업 중 만난 충돌 — 타입 스케일
|
||||
|
||||
세 문서가 **산술적으로 동시에 만족 불가능한** 값을 준다.
|
||||
|
||||
| 문서 | 요구 |
|
||||
|---|---|
|
||||
| `presets/dark-instrument.md` | 비율 **1.200~1.250** (정보 밀도가 높으니 위계 차이를 작게) |
|
||||
| `antipatterns.md` §2 | **최대÷본문 4배 이상** (17→68). 단계 **8개 이상은 통제 실패** |
|
||||
| `reference-method.md` 축2 | 단계 **3–5가 건강** |
|
||||
|
||||
1.25 비율로 5단계면 최대÷본문 = 1.25⁴ = **2.44배**. 6단계여도 3.05배. **4배를 만들려면 8단계가 필요**하고 그건 "통제 실패"다.
|
||||
|
||||
**해소**: `tokens.md` §1에 답이 있다 — *"헤드라인만 다른 비율을 쓰고 싶으면 그건 비율이 아니라 **디스플레이 사이즈를 따로 정의**하는 것이다."*
|
||||
→ 본문 스케일은 1.25 5단계로 두고, **디스플레이 2개를 스케일 밖에 별도 정의**한다. h1÷본문 = 64/16 = **4.0배** 달성.
|
||||
이 해소 경로는 `antipatterns.md`나 `dark-instrument.md`에서 역참조되지 않는다(→ FINDINGS D-1).
|
||||
|
||||
---
|
||||
|
||||
## 1. 타입
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 본문 스케일 — 비율 1.250, 5단계 (dark-instrument 준수) */
|
||||
--step--1: 0.8125rem; /* 13 — 캡션, 모노 라벨, 표 셀 */
|
||||
--step-0: 1rem; /* 16 — 본문 */
|
||||
--step-1: 1.25rem; /* 20 — 리드 문단 */
|
||||
--step-2: 1.5625rem; /* 25 — h3 */
|
||||
--step-3: 1.9531rem; /* 31 — h2 */
|
||||
|
||||
/* 디스플레이 — 스케일 밖. 위계 차이를 만드는 유일한 장치 */
|
||||
--display-2: clamp(2rem, 1.4rem + 3vw, 2.5rem); /* 32→40 — 섹션 헤드 */
|
||||
--display-1: clamp(2.75rem, 1.8rem + 4.8vw, 4rem); /* 44→64 — h1 */
|
||||
|
||||
--leading-tight: 1.05; /* 디스플레이 */
|
||||
--leading-snug: 1.3; /* h2/h3 */
|
||||
--leading-normal: 1.55; /* 본문 (라틴) */
|
||||
--measure: 68ch; /* 영문 68자 — R1 변형에서 정한 640px 대역 */
|
||||
|
||||
--font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif;
|
||||
--font-body: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
--tracking-display: -0.025em; /* -0.04em 상한 준수 */
|
||||
--tracking-body: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**폰트 선택 근거**
|
||||
- 본문/모노를 **IBM Plex Sans + IBM Plex Mono**로 — `dark-instrument.md` 표의 후보. 한 슈퍼패밀리라 광학 크기가 맞는다
|
||||
- `antipatterns.md` §2가 **Inter를 이유 없이 쓰는 것**을 금지. R1(Checkly)이 정확히 Inter라 여기서 갈라진다
|
||||
- 디스플레이 세리프는 R2(전면 세리프 출판사)에서 온다. **주의**: `antipatterns.md` §2는 `Instrument Serif`를 `Space Grotesk + Geist`와의 **3종 조합**일 때 지문으로 지목한다. 셋 중 하나만 쓰므로 해당 없음 — 다만 대체 후보로 `Newsreader`(OFL)를 둔다
|
||||
|
||||
**금지 확인**: 「헤드라인 한 단어만 세리프 이탤릭」 안 쓴다. 디스플레이는 전체가 세리프다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 색 — 두 테마를 동등하게 정의
|
||||
|
||||
`dark-instrument.md` 흔한 실패 #3(다크만 만들고 라이트 안 만듦)에 따라 라이트를 파생물이 아니라 **동등한 정의**로 적는다.
|
||||
|
||||
```css
|
||||
/* 라이트를 먼저 쓴다 — tokens.md "라이트로 설계하고 다크는 토큰으로 파생" 형식은 지키되,
|
||||
기본 적용은 다크다(2단계에서 정당화). */
|
||||
:root {
|
||||
--surface: #F7F5F0; /* 오프화이트. 순백 금지 */
|
||||
--surface-raised: #FDFCF9;
|
||||
--line: #DCD8CF;
|
||||
--ink: #16181A; /* 순흑 금지 */
|
||||
--ink-muted: #5C646E;
|
||||
--accent: #1B4FD8; /* 코발트. 링크·데이터축 전용 */
|
||||
--accent-ink: #FAF9F6;
|
||||
--cta-bg: #16181A; /* 잉크 반전 — 강조색을 쓰지 않는다 */
|
||||
--cta-ink: #FAF9F6;
|
||||
--ok: #2F7D57; --warn: #8A6D14; --fail: #A8432E; /* 기능색 */
|
||||
}
|
||||
|
||||
/* 기본 적용 = 다크. 사용자가 라이트를 명시하면 위 값으로 되돌아간다 */
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { /* 아래 값 */ } }
|
||||
:root[data-theme="dark"] { }
|
||||
```
|
||||
|
||||
실제 값 (다크):
|
||||
|
||||
| 토큰 | 다크 | 라이트 |
|
||||
|---|---|---|
|
||||
| `--surface` | `#0D0F12` | `#F7F5F0` |
|
||||
| `--surface-raised` | `#151A20` | `#FDFCF9` |
|
||||
| `--line` | `#262E37` | `#DCD8CF` |
|
||||
| `--ink` | `#E6E8E6` | `#16181A` |
|
||||
| `--ink-muted` | `#9BA6B2` | `#5C646E` |
|
||||
| `--accent` | `#7FA6FF` | `#1B4FD8` |
|
||||
| `--cta-bg` / `--cta-ink` | `#E6E8E6` / `#0D0F12` | `#16181A` / `#FAF9F6` |
|
||||
| `--ok` / `--warn` / `--fail` | `#4FA97A` / `#C9A227` / `#D46A55` | `#2F7D57` / `#8A6D14` / `#A8432E` |
|
||||
|
||||
**대비 실측** (`tools/contrast.mjs`, WCAG 2.x): 두 테마 **20개 항목 전부 통과**. 최저값은 다크 `--fail` 4.99:1, 라이트 `--warn` 4.78:1.
|
||||
|
||||
**규칙**
|
||||
- 강조색 hue는 **1개**(코발트). 상태색 3개는 `tokens.md` 정의상 강조색이 아니라 **기능색**이므로 "hue 3개 이하" 계산에서 제외한다
|
||||
- **강조색을 배경으로 쓰지 않는다** (R2). 코발트는 링크 글자와 데이터 축선에만
|
||||
- `--line`은 대비 1.4:1이라 **정보를 전달하지 않는 장식**으로만 쓴다. 구분이 필요한 곳은 `--surface-raised` 톤 차이로
|
||||
|
||||
---
|
||||
|
||||
## 3. 간격 — 4px 그리드 (dark-instrument 요구)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 1rem; /* 16 */
|
||||
--space-4: 1.5rem; /* 24 */
|
||||
--space-5: 2.5rem; /* 40 */
|
||||
--space-6: 4rem; /* 64 */
|
||||
--space-7: 6rem; /* 96 */
|
||||
--space-8: 10rem; /* 160 — 섹션 간 */
|
||||
--container: 70rem; /* 1120 — R1 변형 */
|
||||
--gutter: clamp(1rem, 4vw, 5rem);
|
||||
}
|
||||
```
|
||||
섹션 간 160 : 요소 간 24 = **1:6.7**. 여백 종류 8종은 스케일 전체이고, 히어로에서 실제로 쓰는 값은 4종(`--space-2/3/5/7`)이다.
|
||||
|
||||
## 4. 형태
|
||||
|
||||
```css
|
||||
:root { --radius-sm: 2px; --radius-md: 4px; }
|
||||
```
|
||||
**어휘 2종.** `rounded-2xl`·16px 균일·24px 이상 전부 회피. 그림자는 쓰지 않고 `--surface-raised` 톤 차이로 깊이를 만든다(1px 보더 + 그림자 동시 사용 금지).
|
||||
|
||||
## 5. 모션 (tokens.md 값 그대로)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--dur-instant: 100ms; --dur-quick: 200ms;
|
||||
--dur-normal: 350ms; --dur-slow: 600ms;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
|
||||
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
```
|
||||
`motion.md`가 없어 이 값만 쓴다(→ FINDINGS D-5).
|
||||
|
||||
## 6. 성능 예산 — 기본값 사용 (브리프 지시)
|
||||
|
||||
| 항목 | 예산 |
|
||||
|---|---|
|
||||
| 히어로까지 JS (gzip) | **150KB** |
|
||||
| WebGL/3D 추가분 | +200KB 이내, 아니면 채택 안 함 |
|
||||
| 첫 인터랙션 (모바일 4G) | 3초 |
|
||||
| 폰트 파일 | 2개 이하, 서브셋 |
|
||||
| 애니메이션 속성 | transform / opacity 만 |
|
||||
|
||||
폰트 3종(Plex Sans / Plex Mono / Instrument Serif)은 예산의 "2개 이하"를 넘는다.
|
||||
→ **해소**: 디스플레이 세리프는 h1 한 곳에만 쓰므로 `unicode-range` 서브셋(라틴 기본 + 숫자)으로 8KB 이하로 만들고, Plex Mono는 `font-synthesis` 없이 400 단일 웨이트만 로드한다. 실질 2.3개.
|
||||
|
||||
---
|
||||
|
||||
## 산출물 한 줄
|
||||
**성능 예산 = JS 150KB / 첫 인터랙션 3초** (기본값, 상향 없음)
|
||||
159
research/dogfood-saas/05-preflight.md
Normal file
159
research/dogfood-saas/05-preflight.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# 5단계 — 프리플라이트 감사 (히어로 실측)
|
||||
|
||||
측정 대상: `hero/preview.html` + `hero/src/styles/*.css` (Hero.jsx와 동일 CSS, 마크업 복제)
|
||||
도구: Playwright(Chromium), `tools/contrast.mjs`
|
||||
|
||||
---
|
||||
|
||||
## 0. 기계 검사 3종
|
||||
|
||||
### A. 이펙트 전부 끄기 — **통과**
|
||||
`filter/backdrop-filter/animation/transition/transform` 전부 무력화한 상태에서:
|
||||
- h1 렌더 높이 > 0 ✓ / CTA 렌더 높이 > 0 ✓
|
||||
- 본문 텍스트 그대로 읽힘 ✓
|
||||
- 구조 유지 (그리드는 이펙트가 아님)
|
||||
|
||||
### B. 슬롭 지문 grep — **통과 (1건 검출 후 해소)**
|
||||
16개 패턴 중 **1건 검출**: `Space Grotesk|Instrument Serif|Geist` ← `Instrument Serif` 사용.
|
||||
→ `Newsreader`(OFL, 다른 파운드리)로 교체. **재검 0건.**
|
||||
*주의: antipatterns §2 산문은 "이 셋의 3종 조합"만 지문이라 하고 "셋 중 하나만 쓰라"고 허용한다. §9 grep은 OR이라 단독 사용도 잡는다 → FINDINGS D-3*
|
||||
|
||||
### C. 카피 경쟁사 치환 — **통과**
|
||||
|
||||
| 문장 | Datadog으로 치환하면 | 판정 |
|
||||
|---|---|---|
|
||||
| "Add every host you want. You pay for checks." | 거짓 (Datadog은 호스트 과금) | **통과** |
|
||||
| "…pages you when latency drifts. No agents to install." | 거짓 (Datadog은 에이전트 필요) | **통과** |
|
||||
| CTA "Start the free trial" | 성립함 | **경계** — 금지어(`Get Started`/`Learn More`)는 아니지만 결과를 말하지 않는다. 후속에서 "Start monitoring free"로 검토 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 접근성
|
||||
|
||||
| 항목 | 실측 | 판정 |
|
||||
|---|---|---|
|
||||
| 키보드 도달 | 6개 인터랙티브 전부 순회. Tab 순서 = 시각 순서 (Pricing → Docs → Dark → CTA → billing → Copy) | 통과 |
|
||||
| 포커스 표시 | `outline: 2px solid var(--accent)` + offset 3px, `:focus-visible` | 통과 |
|
||||
| 대비 | `tools/contrast.mjs` — 다크·라이트 **20항목 전부 4.5:1 이상**. 최저 다크 `--fail` 4.99:1 / 라이트 `--warn` 4.78:1 | 통과 |
|
||||
| 이미지 대체텍스트 | `<img>` 1개, 의미 있는 alt 있음, `width/height` 지정 | 통과 |
|
||||
| 폼 레이블 | 폼 없음 | N/A |
|
||||
| 제목 구조 | h1 1개, 건너뜀 없음 | 통과 |
|
||||
| 모션 감소 | `prefers-reduced-motion` 블록 있음. 애초에 위치 이동·자동재생 없음 | 통과 |
|
||||
| 자동재생 | 없음 | 통과 |
|
||||
| 캔버스/WebGL 실패 | WebGL 미채택. JS 꺼도 텍스트·레이아웃 유지(테마 토글·복사만 비활성) | 통과 |
|
||||
|
||||
**색만으로 정보 전달 안 함**: 링크는 밑줄 병행. 상태색은 이번 히어로에 미사용.
|
||||
|
||||
---
|
||||
|
||||
## 2. 성능 — 예산은 `tokens.md` §5
|
||||
|
||||
| 항목 | 예산 | 실측 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 히어로까지 JS (gzip) | 150KB | **미측정** — Vite 빌드 미실행(환경에 미설치). preview는 JS 0바이트 로드, React 버전은 react+react-dom ≈ 45KB gz **추정** | **미검증** |
|
||||
| WebGL/3D 추가분 | +200KB | **0KB** (4-3에서 미채택) | 통과 |
|
||||
| 총 전송량 (첫 화면) | 1MB | **12.4KB** (문서 2.9K + CSS 9.5K + 이미지 placeholder) | 통과 |
|
||||
| 첫 인터랙션 (모바일 4G) | 3초 | **미측정** (스로틀링 환경 없음) | **미검증** |
|
||||
| LCP | 2.5초 | **미측정** | **미검증** |
|
||||
| CLS | 0.1 | `aspect-ratio` + `width/height`로 이미지 자리 확보. 지연 삽입 요소 없음 → **구조적으로 0** | 통과(추론) |
|
||||
| 폰트 파일 수 | 2개 이하 | **3종 선언**(Newsreader / Plex Sans / Plex Mono), 실제 로드 0(시스템 폴백). 서브셋 계획은 03-tokens §6 | **초과, 사유 명시** |
|
||||
|
||||
**정직하게**: JS·LCP·TTI 3개는 **측정하지 못했다.** `preflight.md`가 "측정하지 않았으면 통과가 아니다"라고 했으므로 이 셋은 **미통과 상태로 남긴다.** 빌드 파이프라인이 생기면 재측정해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 반응형 (실측)
|
||||
|
||||
| 항목 | 실측 | 판정 |
|
||||
|---|---|---|
|
||||
| 320px 가로 스크롤 | 1차 **실패** (`.theme-toggle`가 nav flex에서 밀림, scrollW 318 > clientW 305) → `flex-wrap: wrap` 적용 후 **scrollW 305 = clientW 305** | 수정 후 통과 |
|
||||
| 1920px 늘어짐 | `--container: 70rem` 상한 | 통과 |
|
||||
| 중간 뷰포트 768~1024 | `max-width: 54rem`에서 1열 전환 | 통과 |
|
||||
| 터치 타깃 44×44 | 1차 **실패 5건** (Pricing 40×21, Docs 29×21, Dark 61×30, billing 링크 207×25, Copy 62×30) → `min-height/min-width: 44px` 적용 후 **0건** | 수정 후 통과 |
|
||||
| 호버 전용 기능 | 없음 | 통과 |
|
||||
| 긴 단어 넘침 | 영문 짧은 단어. `{{REGION_COUNT}}`도 넘치지 않음 | 통과 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 콘텐츠
|
||||
|
||||
- [x] 히어로가 무엇인지 말한다 — "watches your endpoints", "pages you"
|
||||
- [x] 로렘입숨 없음
|
||||
- [ ] **실제 콘텐츠 길이 미검증** — `{{REGION_COUNT}}`가 placeholder다. 실제 값이 두 자리면 서브 문장이 한 줄 늘어난다
|
||||
- [ ] **빈/오류/로딩 상태 없음** — 히어로만 만들었으므로 범위 밖. 후속 필수
|
||||
- [x] 링크 텍스트가 목적지를 말함 ("See how billing is calculated")
|
||||
- [x] 이미지 실패 시 레이아웃 유지 (`aspect-ratio` + `--surface-raised` 배경)
|
||||
|
||||
---
|
||||
|
||||
## 5. 하드 게이트 12개 — 전부 "아니오"여야 한다
|
||||
|
||||
| # | 질문 | 답 | 근거 |
|
||||
|---|---|---|---|
|
||||
| 1 | 토큰 밖 인라인 색상/폰트가 있는가 | **아니오** | 모든 색·폰트가 `tokens.css` 변수. grep으로 hex 0건 |
|
||||
| 2 | 강조색이 2개 이상인가 | **아니오** | 코발트 1개. 상태색 3개는 tokens.md 정의상 기능색 |
|
||||
| 3 | 코너 반경이 규칙 없이 섞였는가 | **아니오** | `--radius-sm: 2px` / `--radius-md: 4px` 2종만 |
|
||||
| 4 | 페이지 중간에 테마가 뒤집히는가 | **아니오** | 단일 테마, 토글로만 전환 |
|
||||
| 5 | 320~1920 어느 폭에서 가로 스크롤이 생기는가 | **아니오** (수정 후) | 320px 실측 scrollW = clientW |
|
||||
| 6 | 버튼·내비 라벨이 2줄로 접히는 폭이 있는가 | **아니오** | CTA `white-space: nowrap`, nav는 wrap으로 줄바꿈 대신 행 이동 |
|
||||
| 7 | 버튼 대비가 4.5:1 미만인가 | **아니오** | 다크 15.58:1 / 라이트 16.91:1 |
|
||||
| 8 | transform/opacity 외를 애니메이션하는가 | **아니오** | 브라우저에서 non-zero duration 전수 검사 → 0건 |
|
||||
| 9 | `100vh`를 썼는가 | **아니오** | `min(84dvh, 46rem)`. 스타일시트 전문에 `100vh` 0건, `dvh` 검출 |
|
||||
| 10 | `scroll` 리스너를 썼는가 | **아니오** | 스크롤 관련 JS 없음 |
|
||||
| 11 | 사용자가 주지 않은 수치가 있는가 | **아니오** | 브리프가 수치를 주지 않아 `{{REGION_COUNT}}` placeholder로 둠 |
|
||||
| 12 | div로 만든 가짜 스크린샷이 있는가 | **아니오** (수정 후) | 초안의 `.gauge` div 목업을 발견해 `<img>`로 교체 |
|
||||
|
||||
## 카운트 규칙
|
||||
|
||||
| 규칙 | 실측 | 판정 |
|
||||
|---|---|---|
|
||||
| eyebrow 라벨 ≤ ceil(섹션/3) | **0개** | 통과 |
|
||||
| 동일 이미지+텍스트 스플릿 연속 ≤ 2 | 1회 | 통과 |
|
||||
| 마퀴 ≤ 1 | 0 | 통과 |
|
||||
| 섹션 6개 이상이면 레이아웃 패밀리 3개 | 히어로만 구현 | N/A |
|
||||
| 히어로 헤드라인 ≤ 2줄 | 1440px에서 2줄, 320px에서 4줄 | **경계** — 모바일에서 4줄. `text-wrap: balance` 적용했으나 44px에서 문장이 길다 |
|
||||
| 서브텍스트 ≤ 20단어 | **17단어** | 통과 |
|
||||
| 히어로 텍스트 요소 ≤ 4개 | h1 / sub / CTA+보조링크 / install = **4** (보조링크를 CTA와 한 묶음으로 셈) | **경계** — 보조링크를 별개로 세면 5 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 디자인 자체 (정직하게)
|
||||
|
||||
1. **다른 회사 것으로 바꿔도 쓸 수 있는가?** → **아니오.** 헤드라인이 과금 모델을 주장하고 있어 호스트 과금 경쟁사가 못 쓴다
|
||||
2. **한 문장 컨셉이 화면에 보이는가?** → **부분적으로.** "계기판"은 모노스페이스 명령 블록과 `tabular-nums`로만 드러난다. 제품 스크린샷이 placeholder라 계기판 인상의 절반이 비어 있다
|
||||
3. **감수한 리스크가 실제로 들어갔는가?** → **예.** CTA가 강조색 없이 잉크 반전이다. 회색조 위계 검사에서 CTA가 2위를 유지해 철회 조건에 걸리지 않았다
|
||||
4. **R2(다른 업종 톤)의 흔적이 있는가?** → **예.** 디스플레이 세리프, 강조색을 링크 글자에만, radius 2~4px
|
||||
5. **강조색이 하나인가?** → **예**
|
||||
6. **여백이 의도적인가?** → **예.** 섹션 160 : 요소 24 = 1:6.7, 히어로에서 실제 사용 4종
|
||||
|
||||
---
|
||||
|
||||
## 7. antipatterns 자가 채점표
|
||||
|
||||
| 카테고리 | 걸린 항목 | 조치 |
|
||||
|---|---|---|
|
||||
| 0 최우선 3종 | 0 | — |
|
||||
| 1 컬러 | 0 | — |
|
||||
| 2 타이포 | 1 (`Instrument Serif` grep) | Newsreader로 교체 후 0 |
|
||||
| 3 레이아웃 | 0 | — |
|
||||
| 4 컴포넌트 | 0 | — |
|
||||
| 5 모션 | 0 (모션 없음) | — |
|
||||
| 6 카피 | 1 경계 (CTA가 결과를 말하지 않음) | 후속 검토로 남김 |
|
||||
| 7 이미지 | 0 | — |
|
||||
| 8 한글 조판 | N/A (영문 브리프) | — |
|
||||
|
||||
**최종 걸린 개수: 1 (경계)** → 판정 **통과** (0–1)
|
||||
|
||||
---
|
||||
|
||||
## 8. 최종 게이트
|
||||
|
||||
- [x] 0장 기계 검사 3종 통과
|
||||
- [x] 접근성 표 전 항목 통과
|
||||
- [ ] **성능 예산 — JS/LCP/TTI 3개 미측정. 빌드 환경 확보 후 재검 필요**
|
||||
- [x] 반응형 체크리스트 통과 (실패 2건 수정 후)
|
||||
- [ ] **콘텐츠 — 빈/오류/로딩 상태 없음 (히어로 범위 밖)**
|
||||
- [x] 5장 여섯 질문 정직하게 답함
|
||||
- [x] antipatterns 자가 채점표 통과
|
||||
|
||||
**판정: 조건부 통과.** 미측정 3항목과 상태 화면 부재를 남긴 채로는 출시 불가.
|
||||
91
research/dogfood-saas/design.md
Normal file
91
research/dogfood-saas/design.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# design.md — Pulsegate
|
||||
|
||||
이 프로젝트를 다음에 이어받는 사람(또는 에이전트)이 읽는 파일이다. **여기 적힌 결정을 designpaca 기본값보다 우선한다.**
|
||||
|
||||
## 브리프 3줄
|
||||
```
|
||||
무엇을: API 모니터링 SaaS "Pulsegate" 랜딩 페이지 (단일 페이지)
|
||||
누구에게: 백엔드/SRE 엔지니어. Datadog·Grafana 사용 중. 가격과 셋업 시간에 민감. 목표는 무료 체험 가입
|
||||
제약: 영어 / React + Vite / 다크·라이트 둘 다 / 제품 스크린샷 있음 / 성능 예산 스킬 기본값
|
||||
```
|
||||
|
||||
## 레퍼런스
|
||||
| 슬롯 | 사이트 | 가져온 것 |
|
||||
|---|---|---|
|
||||
| R1 구조 | https://www.checklyhq.com | 컨테이너 폭, 여백 리듬(섹션:요소 = 1:4), 히어로에 실행 가능한 명령을 두는 관행 |
|
||||
| R2 톤 | https://fitzcarraldoeditions.com | 강조색을 **링크 글자에만** 쓰는 규율, 전면 세리프, radius 2~5px |
|
||||
| R3 디테일 | https://tympanus.net/codrops/2026/05/21/creating-scroll-driven-svg-map-animations-with-gsap/ | 스크롤-진행도 바인딩 개념 (**최종 미채택** — 하드게이트 #8 위반) |
|
||||
|
||||
## 한 문장 컨셉
|
||||
**야간 당직실의 계기판 — 읽히도록 어둡고, 출판물처럼 조판된.**
|
||||
|
||||
## 프리셋
|
||||
`dark-instrument`.
|
||||
|
||||
**다크를 기본으로 삼은 근거** (프리셋이 요구하는 정당화):
|
||||
1. 청중이 온콜 SRE다 — 어두운 환경에서 본다
|
||||
2. 제품 스크린샷이 다크 대시보드다 — 라이트 랜딩이면 스크린샷이 뜬다
|
||||
|
||||
라이트는 파생물이 아니라 **동등한 정의**로 작성했다(`tokens.css`에 값이 따로 있다).
|
||||
적용 규칙: OS가 라이트를 선호하면 라이트, 그 외 다크. 토글이 있으면 토글이 이긴다.
|
||||
|
||||
## 감수한 리스크 하나
|
||||
> **주 CTA에 강조색을 쓰지 않는다.** 잉크 반전(밝은 표면 + 어두운 글자)으로만 만들고, 코발트는 인라인 링크와 데이터 축에만.
|
||||
|
||||
- 철회 조건: 회색조 위계에서 CTA가 3위 밖으로 밀리면 즉시 강조색 투입
|
||||
- 5단계 검사 결과 CTA는 2위 유지 → **철회하지 않음**
|
||||
|
||||
## 토큰
|
||||
전문은 `hero/src/styles/tokens.css`. 요약:
|
||||
- **타입**: 본문 스케일 비율 **1.250 / 5단계**(13·16·20·25·31). 디스플레이 2개는 **스케일 밖**(`--display-1` 44→64, `--display-2` 32→40). h1÷본문 = **4.0배**
|
||||
- **폰트**: Newsreader(디스플레이) / IBM Plex Sans(본문) / IBM Plex Mono(코드·수치)
|
||||
- **색**: surface 2단계 + ink 2단계 + line + accent 1개(코발트) + 상태 3색. 다크·라이트 각각 정의
|
||||
- **간격**: 4px 그리드, `--space-1`~`8`, `--container: 70rem`
|
||||
- **형태**: radius 2px / 4px **2종만**. 그림자 없음(표면 톤 차이로 깊이)
|
||||
- **모션**: `tokens.md` 값 그대로
|
||||
|
||||
## 성능 예산과 실측
|
||||
예산 원본은 `tokens.md` §5. 실측은 `05-preflight.md` §2.
|
||||
|
||||
| 항목 | 예산 | 실측 |
|
||||
|---|---|---|
|
||||
| 총 전송량 (첫 화면) | 1MB | **12.4KB** |
|
||||
| WebGL 추가분 | +200KB | **0KB** |
|
||||
| CLS | 0.1 | 구조적 0 (`aspect-ratio` + `width/height`) |
|
||||
| **JS gzip** | 150KB | **미측정** |
|
||||
| **LCP / 첫 인터랙션** | 2.5초 / 3초 | **미측정** |
|
||||
| 폰트 파일 | 2개 이하 | **3종 (초과)** — 디스플레이는 h1 전용 서브셋, Mono는 400 단일 웨이트 |
|
||||
|
||||
## 채택한 이펙트와 그 폴백
|
||||
| 이펙트 | 폴백 |
|
||||
|---|---|
|
||||
| SVG 필터 | **미채택.** `dark-instrument`는 그레인 금지, "재질을 거의 안 쓴다" |
|
||||
| three.js / WebGL | **미채택.** `three.md` §0 게이트 조건 1에서 탈락 — 필요한 것이 플랫 표면과 SVG 선뿐이라 CSS로 충분 |
|
||||
| 모션 | **거의 없음.** CTA·버튼 hover의 `translateY(-1px)`만. `prefers-reduced-motion`에서 1ms로 축소 |
|
||||
| 테마 토글 | JS 실패 시 OS 설정을 따르는 CSS 경로가 그대로 남는다 |
|
||||
| 복사 버튼 | JS 실패 시 명령 텍스트가 선택 가능한 `<code>`로 남는다 |
|
||||
|
||||
## 미확정 — 다음 사람이 채워야 할 것
|
||||
- `{{REGION_COUNT}}` — 실제 리전 수. **지어내지 않았다**(하드게이트 #11)
|
||||
- 과금 모델("호스트 무료, 체크 과금")이 실제 제품 사실인지 확인 필요. 헤드라인 전체가 여기 걸려 있다
|
||||
- 제품 스크린샷 실제 파일 (`/product-checks.png`가 placeholder)
|
||||
- 빈 상태·오류 상태·로딩 상태
|
||||
- 2~6번 섹션 (반론 처리 → 실제 화면 → 가격 → 마이그레이션 → 최종 CTA)
|
||||
|
||||
## 의도적으로 하지 않은 것과 그 이유
|
||||
**이 절이 가장 중요하다. 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 착각하고 되돌린다.**
|
||||
|
||||
| 안 한 것 | 이유 |
|
||||
|---|---|
|
||||
| **로고월** | 검증 가능한 고객 실명이 없다. `antipatterns.md` §4 — 회색조 로고 일렬은 신뢰를 깎는다 |
|
||||
| **후기 섹션** | 실명·직함·원문 링크가 없다. 없으면 넣지 않는다 |
|
||||
| **FAQ** | 실제로 받은 질문이 없다. 가상 질문 FAQ는 지문이다 |
|
||||
| **3열 피처 카드** | `antipatterns.md` §3 — 콘텐츠가 3개여서가 아니라 3이 예쁘게 떨어져서 3인 것 |
|
||||
| **지표 배너** (`99.9% uptime` 등) | 브리프가 수치를 주지 않았다. 하드게이트 #11 |
|
||||
| **펄스 상태 점** | 모니터링 제품의 최대 유혹. 정적 정보에 장식 애니메이션은 금지 |
|
||||
| **터미널 목업 div** | 하드게이트 #12. 실제 스크린샷을 쓴다 |
|
||||
| **스크롤 애니메이션** | R3에서 가져오려 했으나 `stroke-dashoffset` 애니메이션이 하드게이트 #8(transform/opacity만) 위반. GSAP 60KB도 예산의 40% |
|
||||
| **Inter** | R1(Checkly)이 Inter지만 `antipatterns.md` §2가 "이유 없는 Inter"를 금지 |
|
||||
| **CTA 2개 나란히** | 주 행동을 정하지 못했다는 신호. 보조는 텍스트 링크로 격하 |
|
||||
|
||||
> 근거: research/dogfood-saas/01~05 (작업일 2026-08-20)
|
||||
74
research/dogfood-saas/hero/preview.html
Normal file
74
research/dogfood-saas/hero/preview.html
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pulsegate — hero (preflight target)</title>
|
||||
<!-- Hero.jsx 와 동일한 CSS. 마크업만 감사용으로 복제했다. -->
|
||||
<link rel="stylesheet" href="./src/styles/tokens.css" />
|
||||
<link rel="stylesheet" href="./src/styles/hero.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<p class="nav__mark">Pulsegate</p>
|
||||
<a class="nav__link" href="#pricing">Pricing</a>
|
||||
<a class="nav__link" href="#docs">Docs</a>
|
||||
<button type="button" class="theme-toggle" id="theme" aria-pressed="false">Light</button>
|
||||
</nav>
|
||||
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1 class="hero__headline">Add every host you want. You pay for checks.</h1>
|
||||
<p class="hero__sub">Pulsegate watches your endpoints from {{REGION_COUNT}} regions and pages you when latency drifts. No agents to install.</p>
|
||||
<div class="hero__actions">
|
||||
<a class="cta" href="#signup">Start the free trial</a>
|
||||
<a class="link-quiet" href="#pricing">See how billing is calculated</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure style="margin:0">
|
||||
<img class="shot" src="/product-checks.png" width="960" height="720"
|
||||
alt="Pulsegate 체크 목록 화면. 엔드포인트 이름, 최근 응답 시간, 상태가 표 형태로 정렬되어 있다."
|
||||
loading="eager" decoding="async" />
|
||||
<figcaption class="shot-caption">checks/ — the only screen you need during an incident</figcaption>
|
||||
</figure>
|
||||
</header>
|
||||
|
||||
<div class="install">
|
||||
<span class="install__prompt" aria-hidden="true">$</span>
|
||||
<code class="install__cmd">npx pulsegate init</code>
|
||||
<button type="button" class="install__copy" id="copy">Copy</button>
|
||||
<span role="status" aria-live="polite" class="visually-hidden" id="copied"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const root = document.documentElement
|
||||
const btn = document.getElementById('theme')
|
||||
const resolved = () => root.getAttribute('data-theme')
|
||||
?? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
|
||||
const sync = () => {
|
||||
const t = resolved()
|
||||
btn.textContent = t === 'dark' ? 'Light' : 'Dark'
|
||||
btn.setAttribute('aria-pressed', String(t === 'light'))
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
root.setAttribute('data-theme', resolved() === 'dark' ? 'light' : 'dark')
|
||||
sync()
|
||||
})
|
||||
sync()
|
||||
|
||||
const copyBtn = document.getElementById('copy')
|
||||
const status = document.getElementById('copied')
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText('npx pulsegate init')
|
||||
copyBtn.textContent = 'Copied'
|
||||
status.textContent = 'Command copied to clipboard'
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy'; status.textContent = '' }, 1600)
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
109
research/dogfood-saas/hero/src/components/Hero.jsx
Normal file
109
research/dogfood-saas/hero/src/components/Hero.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 브리프가 수치를 주지 않았다. SKILL.md 0단계 "가정은 소리 내서 말해라"에 따라
|
||||
* 지어내지 않고 placeholder 로 두고, design.md 에 미확정 항목으로 적는다.
|
||||
* 하드게이트 #11(사용자가 주지 않은 수치) 회피.
|
||||
*/
|
||||
const COPY = {
|
||||
headline: 'Add every host you want. You pay for checks.',
|
||||
sub: 'Pulsegate watches your endpoints from {{REGION_COUNT}} regions and pages you when latency drifts. No agents to install.',
|
||||
cta: 'Start the free trial',
|
||||
secondary: 'See how billing is calculated',
|
||||
command: 'npx pulsegate init',
|
||||
shotAlt:
|
||||
'Pulsegate 체크 목록 화면. 엔드포인트 이름, 최근 응답 시간, 상태가 표 형태로 정렬되어 있다.',
|
||||
shotCaption: 'checks/ — the only screen you need during an incident',
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setTheme] = useState(null) // null = OS 설정을 따른다
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === null) root.removeAttribute('data-theme')
|
||||
else root.setAttribute('data-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const resolved =
|
||||
theme ??
|
||||
(window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
|
||||
|
||||
return [resolved, () => setTheme(resolved === 'dark' ? 'light' : 'dark')]
|
||||
}
|
||||
|
||||
function CopyCommand({ command }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1600)
|
||||
} catch {
|
||||
setCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="install">
|
||||
<span className="install__prompt" aria-hidden="true">$</span>
|
||||
<code className="install__cmd">{command}</code>
|
||||
<button type="button" className="install__copy" onClick={copy}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
{/* 상태 변화를 스크린리더에도 알린다 */}
|
||||
<span role="status" aria-live="polite" className="visually-hidden">
|
||||
{copied ? 'Command copied to clipboard' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
const [theme, toggleTheme] = useTheme()
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<nav className="nav" aria-label="Primary">
|
||||
<p className="nav__mark">Pulsegate</p>
|
||||
<a className="nav__link" href="#pricing">Pricing</a>
|
||||
<a className="nav__link" href="#docs">Docs</a>
|
||||
<button
|
||||
type="button"
|
||||
className="theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
aria-pressed={theme === 'light'}
|
||||
>
|
||||
{theme === 'dark' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<header className="hero">
|
||||
<div>
|
||||
<h1 className="hero__headline">{COPY.headline}</h1>
|
||||
<p className="hero__sub">{COPY.sub}</p>
|
||||
<div className="hero__actions">
|
||||
<a className="cta" href="#signup">{COPY.cta}</a>
|
||||
<a className="link-quiet" href="#pricing">{COPY.secondary}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure style={{ margin: 0 }}>
|
||||
<img
|
||||
className="shot"
|
||||
src="/product-checks.png"
|
||||
width="960"
|
||||
height="720"
|
||||
alt={COPY.shotAlt}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption className="shot-caption">{COPY.shotCaption}</figcaption>
|
||||
</figure>
|
||||
</header>
|
||||
|
||||
<CopyCommand command={COPY.command} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
research/dogfood-saas/hero/src/styles/hero.css
Normal file
216
research/dogfood-saas/hero/src/styles/hero.css
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/* 히어로. 값은 전부 tokens.css 에서 온다. */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
letter-spacing: var(--tracking-body);
|
||||
font-variant-numeric: tabular-nums; /* dark-instrument: 수치가 흔들리면 계기판이 아니다 */
|
||||
}
|
||||
|
||||
/* layout.md §1 — 하나의 그리드에서 전부 나온다 */
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
[full-start] minmax(var(--gutter), 1fr)
|
||||
[main-start] minmax(0, var(--container)) [main-end]
|
||||
minmax(var(--gutter), 1fr) [full-end];
|
||||
}
|
||||
.page > * { grid-column: main; }
|
||||
.page > .full { grid-column: full; }
|
||||
|
||||
/* ---------------------------------------------------------------- 내비 */
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* 320px 에서 밀려 가로 스크롤이 생겼다 */
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
padding-block: var(--space-4);
|
||||
}
|
||||
.nav__mark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
letter-spacing: var(--tracking-display);
|
||||
margin: 0;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
.nav__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px; /* 터치 타깃 44×44 */
|
||||
min-width: 44px;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.nav__link:hover { color: var(--ink); border-bottom-color: var(--line); }
|
||||
|
||||
/* ---------------------------------------------------------------- 히어로 */
|
||||
.hero {
|
||||
/* 100vh 금지(하드게이트 #9). dvh 를 쓰고 min-height 로만 잡는다 */
|
||||
min-height: min(84dvh, 46rem);
|
||||
display: grid;
|
||||
/* 비대칭 7:5. layout.md — 중앙 정렬 히어로는 쓰지 않는다 */
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: start;
|
||||
padding-block: var(--space-7) var(--space-8);
|
||||
}
|
||||
|
||||
.hero__headline {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--display-1);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-weight: 400;
|
||||
margin: 0 0 var(--space-4);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.hero__sub {
|
||||
max-width: var(--measure);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step-1);
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* 주 CTA — 감수한 리스크: 강조색을 쓰지 않고 잉크 반전으로만 만든다 */
|
||||
.cta {
|
||||
display: inline-block;
|
||||
background: var(--cta-bg);
|
||||
color: var(--cta-ink);
|
||||
font-size: var(--step-0);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap; /* 하드게이트 #6 — 라벨이 2줄로 접히지 않는다 */
|
||||
/* 하드게이트 #8 — transform/opacity 만 트랜지션한다 */
|
||||
transition: transform var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
.cta:hover { transform: translateY(-1px); }
|
||||
.cta:active { transform: translateY(0); }
|
||||
|
||||
.link-quiet {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px; /* 터치 타깃 */
|
||||
color: var(--accent); /* 강조색은 오직 글자에만 */
|
||||
font-size: var(--step-0);
|
||||
text-decoration: underline; /* 색만으로 구분하지 않는다 */
|
||||
text-underline-offset: 0.25em;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 제품 화면
|
||||
* 하드게이트 #12 — div 로 만든 가짜 스크린샷 금지.
|
||||
* 브리프가 실제 스크린샷을 제공하므로 <img> 를 쓴다.
|
||||
* aspect-ratio 로 자리를 미리 잡아 CLS 를 막는다.
|
||||
*/
|
||||
.shot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
object-position: left top;
|
||||
background: var(--surface-raised); /* 로드 전/실패 시의 자리 */
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.shot-caption {
|
||||
display: block;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 설치 명령 */
|
||||
.install {
|
||||
grid-column: main;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
.install__prompt { color: var(--ink-muted); user-select: none; }
|
||||
.install__cmd { color: var(--ink); }
|
||||
.install__copy {
|
||||
margin-inline-start: auto;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
background: transparent;
|
||||
color: var(--ink-muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: var(--step--1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-instant) var(--ease-out);
|
||||
}
|
||||
.install__copy:hover { color: var(--ink); transform: translateY(-1px); }
|
||||
|
||||
/* ---------------------------------------------------------------- 테마 토글 */
|
||||
.theme-toggle {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
background: transparent;
|
||||
color: var(--ink-muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: var(--step--1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-toggle:hover { color: var(--ink); }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 포커스 */
|
||||
:where(a, button):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 반응형 */
|
||||
/* 콘텐츠가 깨지는 지점에서만. 기기 크기를 가정하지 않는다 */
|
||||
@media (max-width: 54rem) {
|
||||
.hero { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition-duration: 1ms !important; animation-duration: 1ms !important; }
|
||||
}
|
||||
100
research/dogfood-saas/hero/src/styles/tokens.css
Normal file
100
research/dogfood-saas/hero/src/styles/tokens.css
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* 3단계에서 확정한 토큰. 하드코딩된 값은 이 파일 밖에 존재하지 않는다. */
|
||||
|
||||
:root {
|
||||
/* ---- 타입: 본문 스케일 (비율 1.250, 5단계) ---- */
|
||||
--step--1: 0.8125rem;
|
||||
--step-0: 1rem;
|
||||
--step-1: 1.25rem;
|
||||
--step-2: 1.5625rem;
|
||||
--step-3: 1.9531rem;
|
||||
|
||||
/* ---- 타입: 디스플레이 (스케일 밖. 위계를 만드는 유일한 장치) ---- */
|
||||
--display-2: clamp(2rem, 1.4rem + 3vw, 2.5rem);
|
||||
--display-1: clamp(2.75rem, 1.8rem + 4.8vw, 4rem);
|
||||
|
||||
--leading-tight: 1.05;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal: 1.55;
|
||||
--measure: 68ch;
|
||||
|
||||
--font-display: 'Newsreader', Georgia, 'Times New Roman', serif;
|
||||
--font-body: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--tracking-display: -0.025em;
|
||||
--tracking-body: 0;
|
||||
|
||||
/* ---- 간격: 4px 그리드 ---- */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: 10rem;
|
||||
--container: 70rem;
|
||||
--gutter: clamp(1rem, 4vw, 5rem);
|
||||
|
||||
/* ---- 형태: 어휘 2종 ---- */
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
|
||||
/* ---- 모션 ---- */
|
||||
--dur-instant: 100ms;
|
||||
--dur-quick: 200ms;
|
||||
--dur-normal: 350ms;
|
||||
--dur-slow: 600ms;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
|
||||
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ---- 색: 다크가 저작 기준 (2단계에서 정당화) ---- */
|
||||
color-scheme: dark light;
|
||||
--surface: #0D0F12;
|
||||
--surface-raised: #151A20;
|
||||
--line: #262E37;
|
||||
--ink: #E6E8E6;
|
||||
--ink-muted: #9BA6B2;
|
||||
--accent: #7FA6FF;
|
||||
--accent-ink: #0D0F12;
|
||||
--cta-bg: #E6E8E6;
|
||||
--cta-ink: #0D0F12;
|
||||
--ok: #4FA97A;
|
||||
--warn: #C9A227;
|
||||
--fail: #D46A55;
|
||||
}
|
||||
|
||||
/* OS가 라이트를 선호하면 라이트. 토글이 명시적으로 다크를 잡으면 무시한다. */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme='dark']) {
|
||||
--surface: #F7F5F0;
|
||||
--surface-raised: #FDFCF9;
|
||||
--line: #DCD8CF;
|
||||
--ink: #16181A;
|
||||
--ink-muted: #5C646E;
|
||||
--accent: #1B4FD8;
|
||||
--accent-ink: #FAF9F6;
|
||||
--cta-bg: #16181A;
|
||||
--cta-ink: #FAF9F6;
|
||||
--ok: #2F7D57;
|
||||
--warn: #8A6D14;
|
||||
--fail: #A8432E;
|
||||
}
|
||||
}
|
||||
|
||||
/* 토글이 라이트를 잡으면 OS와 무관하게 라이트 */
|
||||
:root[data-theme='light'] {
|
||||
--surface: #F7F5F0;
|
||||
--surface-raised: #FDFCF9;
|
||||
--line: #DCD8CF;
|
||||
--ink: #16181A;
|
||||
--ink-muted: #5C646E;
|
||||
--accent: #1B4FD8;
|
||||
--accent-ink: #FAF9F6;
|
||||
--cta-bg: #16181A;
|
||||
--cta-ink: #FAF9F6;
|
||||
--ok: #2F7D57;
|
||||
--warn: #8A6D14;
|
||||
--fail: #A8432E;
|
||||
}
|
||||
52
research/dogfood-saas/tools/contrast.mjs
Normal file
52
research/dogfood-saas/tools/contrast.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// WCAG 2.x 상대 명도 대비 검사. node tools/contrast.mjs
|
||||
const hex = h => h.replace('#','').match(/../g).map(x => parseInt(x,16)/255)
|
||||
const lin = c => c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4)
|
||||
const L = h => { const [r,g,b] = hex(h).map(lin); return 0.2126*r + 0.7152*g + 0.0722*b }
|
||||
const ratio = (a,b) => { const l1 = L(a), l2 = L(b); const [hi,lo] = l1>l2 ? [l1,l2] : [l2,l1]; return (hi+0.05)/(lo+0.05) }
|
||||
|
||||
const themes = {
|
||||
dark: {
|
||||
surface: '#0D0F12', raised: '#151A20', line: '#262E37',
|
||||
ink: '#E6E8E6', inkMuted: '#9BA6B2', accent: '#7FA6FF',
|
||||
ctaBg: '#E6E8E6', ctaInk: '#0D0F12',
|
||||
ok: '#4FA97A', warn: '#C9A227', fail: '#D46A55',
|
||||
},
|
||||
light: {
|
||||
surface: '#F7F5F0', raised: '#FDFCF9', line: '#DCD8CF',
|
||||
ink: '#16181A', inkMuted: '#5C646E', accent: '#1B4FD8',
|
||||
ctaBg: '#16181A', ctaInk: '#FAF9F6',
|
||||
ok: '#2F7D57', warn: '#8A6D14', fail: '#A8432E',
|
||||
},
|
||||
}
|
||||
|
||||
// [라벨, 전경, 배경키, 최소요구]
|
||||
const checks = [
|
||||
['본문 ink / surface', 'ink', 'surface', 4.5],
|
||||
['본문 ink / raised', 'ink', 'raised', 4.5],
|
||||
['보조 inkMuted / surface', 'inkMuted', 'surface', 4.5],
|
||||
['보조 inkMuted / raised', 'inkMuted', 'raised', 4.5],
|
||||
['링크 accent / surface', 'accent', 'surface', 4.5],
|
||||
['링크 accent / raised', 'accent', 'raised', 4.5],
|
||||
['상태 ok / raised', 'ok', 'raised', 4.5],
|
||||
['상태 warn / raised', 'warn', 'raised', 4.5],
|
||||
['상태 fail / raised', 'fail', 'raised', 4.5],
|
||||
]
|
||||
|
||||
let fail = 0
|
||||
for (const [name, t] of Object.entries(themes)) {
|
||||
console.log(`\n=== ${name} ===`)
|
||||
for (const [label, fg, bg, min] of checks) {
|
||||
const r = ratio(t[fg], t[bg])
|
||||
const ok = r >= min
|
||||
if (!ok) fail++
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${r.toFixed(2)}:1 (min ${min}) ${label} ${t[fg]} on ${t[bg]}`)
|
||||
}
|
||||
const r = ratio(t.ctaInk, t.ctaBg)
|
||||
const ok = r >= 4.5
|
||||
if (!ok) fail++
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${r.toFixed(2)}:1 (min 4.5) CTA 글자/버튼 ${t.ctaInk} on ${t.ctaBg}`)
|
||||
// 하드게이트 #7: 버튼 텍스트/배경 대비
|
||||
const rb = ratio(t.line, t.surface)
|
||||
console.log(`INFO ${rb.toFixed(2)}:1 경계선 line / surface (3:1 미만이면 비필수 장식으로만)`)
|
||||
}
|
||||
console.log(fail === 0 ? '\n전부 통과' : `\n${fail}건 실패`)
|
||||
Loading…
Add table
Add a link
Reference in a new issue