소개 페이지를 스킬 전체 경로로 다시 만들고, 배운 것을 스킬에 반영

방향을 글래스로 바꿀 때 2단계 재결정을 하고도 전체 경로로 올리지 않았다.
레퍼런스 없이 재질만 갈아끼운 결과가 스위스 미니멀 뼈대에 유리를 바른
페이지였고, design.md 는 초판 결정을 그대로 담고 있었다. 0단계부터 다시 돌았다.

1단계 — 레퍼런스 3개를 computed style 까지 실측
  R1 zed.dev      컨테이너 1120px 과 전체폭이 교대 · radius 2px 621회
  R2 lusion.co    3D 는 액자 안, 텍스트는 액자 밖 · 배경 흰색 · h1 36px
  R3 Codrops      DOM 이 구조를 소유하고 WebGL 은 uniform 만 만진다

2단계 — 프리셋 swiss-minimal → dark-instrument, 컨셉 "검사대 위의 물체"
        유리는 액자에만 남긴다(헤더 · 검사대 · 명령창)

4단계 — 히어로 재구성. 캔버스를 배경에서 빼 액자 안으로, 텍스트는 밖으로
        판독기가 통과한 게이트를 표시한다(값이 바뀔 때만 DOM 을 쓴다)
        파이프라인을 2열로 열어 625px → 1113px

5단계에서 실제로 걸린 것
  게이트 3  radius 4종 → 2종 (3px / 14px, pill 제거)
  게이트 1  base.css 토큰 밖 rgb() 7곳 → 토큰
  게이트 7  헤더 English 2.59:1 → ink 로 상향
  카운트    히어로 h1 4줄 → 2줄
  셰이더    판이 정면이라 프레넬이 구조적으로 0. 법선을 굽히고 UV 로 테두리
  셰이더    gl_FragCoord 를 렌더 타겟 크기로 나눠 UV 가 1 을 넘고 있었다
  수치      페이지가 실은 "타입 단계 5단계"가 실측 9종이었다. lang 속성이
            스케일을 재정의해 링크 하나가 16px 로 새고 있었다
  측정      대비 스크립트가 반투명 배경을 불투명으로 계산했다. 알파 합성으로 교정

스킬 반영
  three.md    §0-A 배치 원리(3D 는 액자 안) · 프레넬 0 함정 · gl_FragCoord 함정
  preflight.md 대비 측정 시 알파 합성. 검사 도구가 틀리면 통과도 의미가 없다

최종: 하드 게이트 12/12, 대비 실패 0, 320px 오버플로 0, 테스트 22개 통과
This commit is contained in:
Yun Chan 2026-08-20 13:33:28 +09:00
parent eb3ca69b5d
commit 9648d1d82a
15 changed files with 576 additions and 306 deletions

View file

@ -1,7 +1,21 @@
# design.md — designpaca 소개 페이지
> 6단계 산출물. 다음 실행(사람이든 에이전트든)은 이 파일을 먼저 읽고 같은 결정을 이어간다.
> 전체 조사 근거: `research/site-brief.md` (825줄)
## 개정 이력
| 날짜 | 판 | 무엇이 바뀌었나 |
|---|---|---|
| 2026-08-20 | 초판 | `swiss-minimal` · three.js 미사용 · 라이트 기반 |
| 2026-08-20 | **2판(현행)** | `dark-instrument` · three.js 채택 · 전체 경로 재주행 |
**초판이 거짓말이 됐던 경위를 남긴다.** 사용자가 "글래스모피즘·리퀴드글래스·히어로에 힘"을 요청했을 때
그것은 2단계 방향 재결정이었고, 스킬은 그럴 때 **전체 경로로 올려 1단계부터 다시 돌라**고 씌어 있다.
그러지 않고 4단계 구현으로 바로 들어갔다. 레퍼런스 없이 재질만 갈아끼운 결과가 **스위스 미니멀 뼈대에
유리를 바른 페이지**였고, `design.md` 는 초판 상태로 남아 프리셋·이펙트·형태 수치가 전부 어긋났다.
2판은 0단계부터 다시 돌았다.
---
## 브리프 3줄
@ -9,103 +23,136 @@
- **누구에게**: Claude Code · Codex · Cursor 를 쓰는 개발자. 이 페이지를 보고 `npx designpaca` 를 터미널에 치게 만드는 것이 목표
- **제약**: Astro 정적 → Cloudflare Pages. 이 페이지 자체가 스킬의 품질 증거물이다 — 스킬이 금지하는 것을 이 페이지가 하면 안 된다
## 레퍼런스
## 레퍼런스 (전부 실측. computed style 추출까지 돌렸다)
| 슬롯 | 사이트 | 업종 | 가져온 것 | 가져오지 **않은** 것 |
|---|---|---|---|---|
| R1 · 구조 | tasteskill.dev | 안티슬롭 디자인 스킬 | 섹션 순서, 설치 명령 3회 노출, **카피 규율**(자기 평가 형용사 0건) | radius 8종 · backdrop-filter 40개 · 타입 12단계 · 스폰서 벽 |
| R2 · 톤 | klim.co.nz | 폰트 파운드리 | 흰 바탕에 검정 밴드를 리듬으로, 설계 근거를 산문으로 파는 방식 | 유료 폰트 |
| R3 · 디테일 | Codrops scroll-driven wipe | — | 스크롤 진입 리빌 | `clip-path` 구현 (게이트 #8 위반 → `transform` 으로 교정) |
| 슬롯 | 사이트 | 업종 | 실측값 | 가져온 **원리** | 가져오지 **않은** 것 |
|---|---|---|---|---|---|
| R1 · 구조 | zed.dev | 코드 에디터 | 컨테이너 **1120px**(68회) ↔ 전체폭 1425px(50회) 교대 · 본문 칼럼 512px · **radius 2px 가 621회** · 모노 524회로 지배 | 폭의 교대, 각진 형태 어휘, 모노를 정체성으로 | 세리프 h1(우리는 한글) · 최대÷본문 3.0배(위계가 약하다) · 섹션 11개 |
| R2 · 톤 | lusion.co | 3D 프로덕션 스튜디오 | 배경 **흰색** · 텍스트 색 **2단계**(검정 135 / 흰 16) · 폰트 **1개**(Aeonik) · 강조색 rgb(0,22,236) **4회** · 캔버스는 radius 15px 액자 안 · **h1 이 36px** | **3D 는 액자 안, 텍스트는 액자 밖.** 3D 가 크면 타이포는 물러난다. 색은 캔버스 안에서만 소리친다 | 흰 배경 자체 · 파랑 |
| R3 · 디테일 | Codrops "Inside HAOQI.DESIGN" | — | 축 5 전용 | DOM 이 구조를 소유하고 WebGL 은 셰이더 uniform 만 만진다. 한 rAF 에서 스크롤을 공유하고 뷰포트 밖에서는 정지 | R3F/Lenis 스택 |
**정본 확인 경고**: `tasteskill.com``design-taste.com` 은 본문 114바이트짜리 파킹 페이지다. HTTP 200 이 생존 신호가 아니다.
**R3 구현 제약**: 셰이더 uniform 만 갱신하므로 `transform`/`opacity` 외의 CSS 를 애니메이션하지 않는다(게이트 #8 안전).
뷰포트 밖 렌더 정지가 필수 조건이다.
**접근 실패 기록** — `iglooinc.com` 은 hugedomains 파킹으로 302, `lasvit.com` 은 403.
`reference-method.md` §3-b 가 경고한 그대로였다. 왕복 세 번에서 끊고 우회했다.
## 한 문장 컨셉
> **이 페이지는 designpaca를 거쳐 만들어졌고, 그 절차와 형태 수치를 한국어 기준으로 전부 공개한다.**
> **검사대 위의 물체.** 액자가 게이트고, 안에 든 것이 검사 중인 디자인이고, 밖의 글자가 판정 기록이다.
세 방향(절차를 판다 / 형태까지 간다 / 한국어로 푼다)이 "이 페이지 자신이 첫 사례"라는 사실 하나로 합쳐진다.
## 프리셋 — `dark-instrument`
## 프리셋 — `swiss-minimal`
초판의 `swiss-minimal` 에서 바꿨다. 페이지가 이미 어둡고 제품이 CLI 다.
**초판에는 이 재질을 정당화하는 프리셋이 아예 없었다** — 그것이 "유리를 발랐는데 유리일 이유가 없다"의 원인이다.
결정 요인은 **멀티링구얼**이었다. `editorial` 의 세리프는 한국어 마루 부리 / 영어 Spectral 로 갈려 두 판의 톤이 벌어진다. 그로테스크는 Pretendard / Switzer 로 양쪽이 같은 성격을 유지한다.
빌린 것: `editorial` 에서 규칙 옆 근거 1문장 어법, `anti-grid` 에서 파이프라인 섹션 한 곳의 의도적 어긋남.
프리셋은 "글래스모피즘은 내비게이션 바 정도까지"라고 반대한다. 사용자의 명시적 지시가 프리셋보다 위이므로
없애지 않고 **의미가 생기는 자리로 옮겼다**: 유리는 **액자에만** 쓴다 — 헤더 · 검사대 프레임 · 명령창.
콘텐츠 카드(브리프, 조판 데모)에서는 뺐다. 유리는 무언가를 담을 때만 의미가 있다.
## 감수한 리스크 — 하나
**이 페이지 자신의 형태 수치를 페이지에 공개한다.** 공개한 순간 검증 가능해지고 하나라도 어기면 주장 전체가 무너진다.
규칙: **결과가 나쁘게 나와도 그대로 싣는다.** 유리한 것만 고르면 리스크가 자기 홍보로 바뀐다.
## C-07 대응 — 번호 없이 순서 표현
파이프라인 6단계를 번호로 매기면 검출률 3위 슬롭 지문(9.4%)에 정면으로 걸린다. 세 층으로 풀었다:
1. **시각** — 세로 스파인 위의 위치가 순서를 만든다 (Beck 1933 튜브맵: "topology trumped topography")
2. **내용** — 같은 브리프 문장 하나가 단계마다 변해간다 (IKEA 조립 설명서. IKEA 의 동기가 언어 독립성이라 멀티링구얼과 동기가 같다)
3. **의미**`<ol role="list">`. 시각적으로 번호 0개, 보조기술에는 "6개 중 n번째". `list-style: none` 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 `role` 로 되돌린다
**히어로에서 거대 타이포를 버리고 힘을 3D 액자로 넘긴다.**
초판 히어로의 `--display` 는 최대 112px 로 화면 높이의 절반을 먹었고, 그 아래 캔버스가 깔려 있었다.
R2 의 h1 은 36px 인데 히어로가 압도적이다 — 힘이 타이포가 아니라 물체에서 나오기 때문이다.
현행 히어로 h1 은 `clamp(var(--step-2), 1.1rem + 2.4vw, var(--step-4))`, 최대 **41.5px** 다.
페이지에서 가장 큰 글자(96px)는 히어로가 아니라 수치 섹션에 있다 — R2 도 그렇다.
## 토큰
`src/styles/tokens.css` 전문. 요약:
`src/styles/tokens.css` 전문. 초판에서 바뀐 것만 적는다.
- 타입 비율 **1.25**, **디스플레이는 스케일 밖에 별도 정의** — 1.25⁴는 2.44배라 "최대÷본문 4배" 를 못 채우고, 단계를 늘려 맞추면 "통제 실패" 에 걸린다. 두 판 모두 정확히 **4.0배**
- 한국어 17px / lh 1.7 / measure 33em(561px) · 영어 16px / lh 1.55 / measure 65ch(640px)
- 강조색 **딥 틸 `#0E6B63`** 하나. 오렌지(tasteskill) · 마젠타(Bun) · 빨강(Klim) · 보라/시안 네온을 전부 피한 자리
- **14개 색 조합 대비를 전부 계산해 검증** — 최저 6.04:1, 최고 16.88:1
- radius **2종**(3px · 6px), 간격 8단계, 모션 4종
- 라이트·다크 두 테마를 동등하게 정의. 다크 밴드는 **히어로와 설치 두 곳만** (게이트 #4 가 요구하는 "규칙으로 정의된 반전")
- **radius 2종**: `--radius-sm: 3px`(요소) · `--radius-frame: 14px`(액자).
근거는 R1 의 2px(621회)과 R2 의 15px. `999px` pill 3곳을 없앴다 — 계기판에 알약은 없다
- **`--glow-2`: `#6E7BFF``#3D7BD6`**. 앞의 값은 R 이 높아 보라-파랑으로 읽힌다.
`antipatterns.md` 의 슬롭 지문이자 `dark-instrument` 가 "어두운 배경일수록 빠지기 쉬운 함정"으로 지목한 색이다
- **유리 색값을 전부 토큰으로**: `--glass-sheen` · `--glass-rim` · `--core-hi`.
`base.css` 에 흩어져 있던 `rgb(255 255 255 / …)` 7곳이 하드 게이트 #1 위반이었다
- **이중 베젤(`.shell`/`.core`) 폐기** → 단일 `.frame`. 껍데기와 속의 radius 가 서로 달라 그것만으로 형태 어휘가 두 종 늘어난다
- **`--step--1: 0.8125rem` 추가**. 게이트 판독기가 스케일 밖 13px 을 쓰고 있었다
- 강조색은 여전히 딥 틸 하나. 라이트/다크 쌍(`--accent` / `--accent-inverse`)은 규칙으로 정의된 반전이라 1종으로 센다
**타입 스케일 실측**: `step--1` ~ `step-4`**6단계**, `--display`(96px)는 스케일 밖, 모노는 본문의 0.9375배 파생.
최대÷본문 = **5.6배**. 초판 페이지는 "5단계"라고 실었는데 실측은 9종이었다 — 아래 8·9번 참조.
## 성능 — 예산과 실측
**예산을 올렸고, 올렸다고 말한다.** 초판 예산은 히어로까지 JS 50KB 였다.
이 페이지는 three.js 를 파는 제품의 데모이므로 그 판단을 검증할 수 있어야 한다.
**예산 숫자 자체를 한 번 고쳤다.** 처음 three.js 예산을 160KB 로 적었는데 실측이 184KB 였다.
three.js 크기는 미리 알 수 있는 값이므로 이건 예산 설정 실패다. 190KB 로 고쳤고, **사후 조정이라는 사실을 남긴다.**
실측을 보고 예산을 맞추는 것은 예산이 아니다 — 다음 실행은 여기서부터 시작해야 한다.
| 항목 | 예산 | 실측 | 판정 |
|---|---|---|---|
| 히어로까지 JS (gzip) | 50KB | **314B** | 통과 (예산의 0.6%) |
| CSS (gzip) | — | 3.0KB | |
| HTML (gzip) | — | 4.4~4.8KB | |
| 폰트 패밀리 (판당) | 2개 | 2개 | 통과 |
| 히어로까지 JS, 초기 (gzip) | 50KB | 복사 버튼 314B + 씬 부트 | 통과 |
| three.js, 지연 로드 (gzip) | 190KB | **184KB** (동적 `import()`, IntersectionObserver 로 화면에 들어올 때만) | 통과 |
| 첫 인터랙션 | 2초 | **미측정** | 배포 후 Lighthouse |
| LCP / CLS | 2.5초 / 0.1 | **미측정** | 배포 후 |
| three.js | 미사용 | 미사용 | — |
**폰트 전략**: 경로가 `/``/en/` 으로 갈려 있으므로 레이아웃을 언어별로 나눠 **자기 폰트만 받는다.** 한국어판 Pretendard Variable(동적 서브셋) + JetBrains Mono, 영어판 Switzer + JetBrains Mono. preload 는 본문 폰트 1개만.
WebGL 게이트: `prefers-reduced-motion` 이면 **아예 시작하지 않는다**. DPR 1.5 클램프.
`hardwareConcurrency <= 4` 면 판을 6장에서 3장으로 줄인다. 탭이 숨거나 화면 밖이면 그리지 않는다.
## 채택한 이펙트와 폴백
| 이펙트 | 구현 | 폴백 |
|---|---|---|
| 복사 버튼 | 인라인 314B | 클립보드 권한 실패 시 텍스트를 선택 상태로 만들어 사용자가 직접 복사 |
| 검사대 3D | 유리판 6장이 깊이 축에 서고 카메라가 스크롤로 통과. 굴절은 렌더 타겟 1장 샘플링(`transmission` 재질보다 훨씬 싸다) | 캔버스 아래 CSS 그라디언트가 **폴백이 아니라 결과물**이다. 액자·판독기·캡션은 그대로 남는다 |
| 게이트 판독기 | 통과한 판이 바뀔 때만 DOM 을 쓴다. 프레임마다 쓰지 않는다 | JS 없으면 `게이트 01 / 06` 정적 표시 |
| 복사 버튼 | 인라인 314B | 클립보드 실패 시 텍스트를 선택 상태로 만든다 |
**스파인 진입 페이드는 넣었다가 뺐다.** `animation-timeline: view()``animation-range: entry 10% cover 22%` 를 걸었는데, **페이지 하단 요소는 `cover 22%` 에 영영 도달하지 못한다.** 스크롤을 끝까지 내려도 6개 정거장 중 뒤 3개가 opacity 0 으로 남았다 — 이 페이지의 본체가 절반 사라진 것이다.
정적 검사로는 안 잡혔다. 320px 실렌더에서 스크롤을 끝까지 돌려보고야 나왔다.
**이펙트를 끄면 보이지만 켜면 사라지는 것은 폴백이 있는 것과 다르다.** 이 페이지에 남은 모션은 호버·포커스 상태 변화뿐이다.
## 5단계에서 실제로 걸린 것
## 5단계 감사 결과
정적 검사와 실렌더를 둘 다 돌렸다. **걸린 것을 전부 적는다 — 유리한 것만 고르면 리스크가 자기 홍보가 된다.**
- 하드 게이트: **12개 전부 통과.** 8개는 정적 검사, 4개(가로 스크롤·버튼 줄바꿈·실렌더 대비·테마 반전)는 **320px 실렌더**로 측정
- 슬롭 지문 grep 9개 패턴: **0건**
- 카운트 규칙: 섹션 7 / eyebrow 3 (상한 3) / 마퀴 0 / h1 1 — 통과
- 실렌더 대비: 본문·링크·버튼 전부 AA 이상. 최저 6.21:1 (다크 밴드 버튼)
1. **하드 게이트 #3 위반** — radius 4종(`sm`/`md`/`shell`/`999px`). 2종으로 정리
2. **하드 게이트 #1 위반**`base.css` 에 토큰 밖 `rgb()` 7곳. 토큰으로 올림
3. **하드 게이트 #7 위반** — 헤더 "English" 버튼 **2.59:1**. `--ink-muted``--ink` 로 올려 통과
4. **카운트 규칙 위반** — 히어로 h1 이 4줄이었다(상한 2줄). 폰트 크기를 낮춰 2줄
5. **셰이더 결함** — 판이 정면으로 서 있어 `dot(n, view) ≈ 1`, **프레넬이 구조적으로 0**이었다.
유리로 읽히게 하는 유일한 신호가 죽어 있었고 그것이 "그냥 흐릿한 사각형"의 원인이다.
법선을 미세하게 굽히고 UV 경계로 테두리 광을 따로 만들어 해결
6. **셰이더 UV 오류**`gl_FragCoord` 를 렌더 타겟 크기(캔버스의 0.6배)로 나누고 있었다. UV 가 1 을 넘어 텍스처가 잘렸다.
`getDrawingBufferSize()` 로 교정
7. **폭 리듬 부재** — 9개 섹션 중 파이프라인이 **1440px 중 625px(43%)** 만 쓰면서 높이는 1637px 로 가장 길었다.
페이지의 본체가 가장 좁았다. 2열로 열어 1113px
8. **페이지가 실은 수치가 틀렸다** — "타입 단계 5단계" 라고 실었는데 실측 **9종**이었다.
이 페이지의 리스크가 "자기 수치를 공개한다" 인데 공개한 값이 틀리면 리스크가 아니라 허위다
9. **`lang` 속성이 타입 스케일을 새게 했다** — 언어 전환 링크에 `lang="en"` 이 붙어 있고(스크린리더가
"English" 를 영어로 읽어야 하므로 옳다) `[lang]` 셀렉터가 스케일을 통째로 재정의해 그 링크만 16px 이 됐다.
접근성 표시가 조용히 디자인 토큰을 바꾸고 있었다. 크기는 헤더에서 확정하고 링크는 상속만 받게 고쳤다
감사 중 걸려서 고친 것 **7건**은 페이지의 "프리플라이트 로그" 섹션에 그대로 실려 있다.
**뒤 3건은 정적 검사로는 안 잡히고 실렌더에서만 나왔다** — 이 사실 자체가 `preflight.md` 의 "프레임워크 프로젝트는 프로덕션 빌드 결과에 돌린다" 규칙을 뒷받침한다.
**측정 스크립트 자체의 오류도 한 번 있었다.** 대비 계산에서 반투명 배경을 불투명으로 취급해
"English 1.15:1" 이라는 틀린 값이 나왔다. 알파를 합성하도록 고친 뒤 실패 0건.
**검사 도구가 틀리면 통과도 실패도 의미가 없다.**
### 최종 판정 (1440px · 320px 실렌더)
- 하드 게이트 12개 **전부 "아니오"**
- 대비: 본문·링크·버튼 전 요소 AA 이상, 실패 **0건**
- 320px: 가로 스크롤 없음(문서 폭 305px), 뷰포트 초과 요소 0, 버튼 줄바꿈 0
- 카운트: 섹션 9 / eyebrow 3(상한 `ceil(9/3)=3`) / 마퀴 0 / 히어로 헤드라인 2줄 / radius 어휘 2종 / 반투명 패널 2개
- 슬롭 지문 grep: 0건
## 의도적으로 하지 않은 것과 그 이유
- **"Anti-Slop" 이라는 명명** — tasteskill 이 쓰는 말이다. 같은 단어를 쓰면 즉시 아류로 읽힌다
- **경쟁 제품의 실측치를 페이지에 싣기** — 익명화는 저격을 예의 바르게 만들 뿐이고, 남의 수치는 반박 가능하다("그 40개는 대부분 nav다"). 논쟁이 시작되면 제품 이야기가 사라진다. **우리 수치만 쓴다**
- **경쟁 제품의 실측치를 페이지에 싣기**남의 수치는 반박 가능하다. 우리 수치만 쓴다
- **GitHub 스타 등 인기 지표** — 우리는 숫자가 0이다. 인기 대신 규율을 증거로 쓴다
- **스폰서·후원자 벽** — R1 은 페이지의 28%를 여기에 쓴다. 섹션 자체를 만들지 않는다
- **다크를 기본값으로** — 브리프에 야간 사용·데이터 시각화 근거가 없다. 두 테마를 동등하게 정의하되 라이트로 시작한다
- **three.js / WebGL** — 정적 사이트에 사진이 없고 인터랙션이 복사 버튼과 언어 전환뿐이다. 필터로도 낼 인상이 없다
- **언어 자동 리다이렉트** — 사용자를 놀라게 한다. `hreflang` 으로 검색엔진에만 알린다
- **국기 아이콘으로 언어 전환** — 언어는 국가가 아니다
- **두 판을 번역으로 만들기** — 각 언어판을 따로 썼다. 한국어판은 "한글 조판 공백을 메운다"가 주장이므로 그 페이지 자체가 증명이어야 한다
- **3D 위에 본문 얹기** — R2 가 준 원리의 핵심이다. 액자 위에 얹는 글자는 판독기 하나뿐이고, 그것도 바탕을 깔았다
- **GLB 모델** — 조사 결과 추상 지오메트리가 이 브리프에서 더 낫다. 모델 하나가 수 MB 이고, 우리가 파는 것은 모델이 아니라 절차다
- **다크를 "멋있어서" 고르기** — 프리셋이 다크 기본값을 슬롭 시그니처 1위로 지목한다. 제품이 터미널이라는 근거로 고른다
- **언어 자동 리다이렉트 / 국기 아이콘** — 사용자를 놀라게 하고, 언어는 국가가 아니다
- **두 판을 번역으로 만들기** — 각 언어판을 따로 썼다
## 다음 실행이 해야 할 일
1. **배포 후 Lighthouse 로 첫 인터랙션·LCP·CLS 를 재고, "이 페이지의 수치" 섹션의 `—` 를 채운다.** 채우기 전까지 그 항목은 비워둔다
1. **배포 후 Lighthouse 로 첫 인터랙션·LCP·CLS 를 재고 "미측정" 을 채운다.** 채우기 전까지 비워둔다
2. 폰트를 CDN 의존에서 자체 호스팅으로 옮길지 판단한다 (현재 jsDelivr · Fontshare · Google Fonts 3곳)
3. OG 이미지가 없다. 공유될 자리라면 만든다
3. OG 이미지가 없다
4. 섹션이 9개, 총 높이 11,197px 이다. 스크롤 보상이 충분한지 재검토할 여지가 있다
---
`2026-08-20` · 초판. 0~6단계 전체 경로로 진행.
`2026-08-20` · 2판. 0~6단계 전체 경로 재주행.

View file

@ -8,9 +8,9 @@ interface Props {
const { command, copyLabel, copiedLabel, note } = Astro.props;
---
{/* 셸 안에 코어. 명령어는 기계의 부품처럼 앉는다 */}
<div class="cmd-shell shell">
<div class="cmd core">
{/* 명령어를 담는 액자. 유리를 쓰는 세 자리 중 하나다 */}
<div class="cmd-shell frame">
<div class="cmd">
<code class="cmd-text">{command}</code>
<button type="button" class="cmd-copy" data-copy={command} data-copied={copiedLabel}>
<span class="cmd-copy-label">{copyLabel}</span>

View file

@ -1,7 +1,5 @@
---
import { Image } from "astro:assets";
import CopyCommand from "./CopyCommand.astro";
import shotCoffee from "../assets/shots/shot-coffee.png";
import type { Content } from "../i18n/content";
interface Props {
@ -11,157 +9,199 @@ interface Props {
const { c, ui } = Astro.props;
---
{/*
구조는 R2(lusion.co) 실측에서 왔다.
3D 를 가장 잘 쓰는 스튜디오가 텍스트를 3D 위에 얹지 않는다.
액자 안은 3D, 액자 밖은 글자. 그래서 대비 문제가 생기지 않고
h1 이 36px 이어도 히어로가 압도적이다 — 힘이 타이포가 아니라 물체에서 나온다.
이전 판은 정반대였다. 캔버스를 배경 전체에 깔고 그 위에 7rem 짜리
헤드라인을 얹었다. 판 여섯 장은 흐린 얼룩이 됐고, 글자는 대비를
얻으려고 글로우를 깔아야 했다. 둘 다 소리치다 둘 다 죽었다.
*/}
<section class="full hero">
{/* 배경. WebGL 이 없으면 아래 그라디언트가 그대로 결과물이다 */}
<div class="hero-bg" aria-hidden="true">
<div class="hero-fallback"></div>
<canvas id="hero-canvas" class="hero-canvas"></canvas>
</div>
<div class="hero-grid">
<div class="hero-copy">
<div class="hero-head">
<h1 class="hero-title">{c.h1}</h1>
<p class="lead hero-lead">{c.lead}</p>
<div class="hero-cta">
<div class="hero-aside">
<p class="lead hero-lead">{c.lead}</p>
<CopyCommand
command={c.install}
copyLabel={ui.copy}
copiedLabel={ui.copied}
note={c.installNote}
/>
</div>
<p class="hero-links">
{c.links.map((l) => <a class="link-grow" href={l.href}>{l.label}</a>)}
</p>
</div>
</div>
{/* 가짜 목업이 아니라 이 스킬로 실제로 만든 페이지다 */}
<figure class="hero-proof par-slow">
<div class="shell">
<div class="core">
<Image
src={shotCoffee}
alt={c.proofAlt}
widths={[520, 900, 1280]}
sizes="(max-width: 960px) 88vw, 44vw"
loading="eager"
fetchpriority="high"
/>
<figure class="hero-stage">
<div class="stage-frame">
{/* WebGL 이 없으면 이 그라디언트가 그대로 결과물이다 */}
<div class="stage-fallback" aria-hidden="true"></div>
<canvas id="hero-canvas" class="stage-canvas" aria-label={c.stage.alt} role="img"></canvas>
{/* 계기판이니 판정을 읽어준다. 3D 가 장식이 아니라 상태 표시가 된다 */}
<p class="stage-readout" aria-hidden="true">
<span class="ro-key">{c.stage.gate}</span>
<span class="ro-val" id="ro-gate">01</span>
<span class="ro-sep">/</span>
<span class="ro-total">06</span>
<span class="ro-state" id="ro-state">{c.stage.pass}</span>
</p>
</div>
</div>
<figcaption>{c.proofCaption}</figcaption>
<figcaption>{c.stage.caption}</figcaption>
</figure>
</div>
</section>
<style>
.hero {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: subgrid;
padding-block: calc(var(--space-8) + var(--space-3)) var(--space-8);
min-height: min(92dvh, 60rem);
align-content: center;
overflow: clip;
grid-column: full;
padding-block: calc(var(--space-6) + var(--space-4)) var(--space-7);
}
.hero-bg {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
/* ---- 액자 밖: 글자 -------------------------------------- */
.hero-head {
max-width: 1180px;
margin-inline: auto;
padding-inline: var(--space-4);
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: var(--space-6);
align-items: start;
margin-bottom: var(--space-5);
}
.hero-fallback,
.hero-canvas {
.hero-title {
/* 7rem 을 버렸다. 액자가 주인공이면 헤드라인은 물러난다.
두 줄을 넘기지 않는 폭이다(카운트 규칙: 히어로 헤드라인 <= 2줄) */
font-size: clamp(var(--step-2), 1.1rem + 2.4vw, var(--step-4));
line-height: 1.12;
letter-spacing: var(--tracking-display);
max-width: 18ch;
margin: 0;
}
[lang="en"] .hero-title { max-width: 20ch; line-height: 1.04; }
.hero-aside { display: grid; gap: var(--space-3); align-content: start; }
.hero-lead {
margin: 0;
max-width: 30em;
color: var(--ink);
opacity: 0.82;
}
.hero-links {
display: flex;
gap: var(--space-4);
margin: 0;
font-size: var(--step-0);
color: var(--ink-muted);
}
/* ---- 액자 안: 물체 -------------------------------------- */
.hero-stage {
margin: 0;
max-width: 1400px;
margin-inline: auto;
padding-inline: var(--space-4);
}
.stage-frame {
position: relative;
aspect-ratio: 16 / 5.5;
border: 1px solid var(--glass-edge);
border-radius: var(--radius-frame);
background: var(--surface-sunken);
box-shadow: var(--glass-hi), var(--shadow-3);
overflow: clip;
isolation: isolate;
}
.stage-fallback,
.stage-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.hero-fallback {
/* 광원이 화면 밖으로 번지되 페이지 폭은 늘리지 않는다 */
transform: scale(1.15);
.stage-fallback {
background:
radial-gradient(46vw 40vw at 22% 18%,
color-mix(in oklab, var(--accent) 40%, transparent), transparent 66%),
radial-gradient(40vw 38vw at 82% 30%,
color-mix(in oklab, var(--glow-2) 32%, transparent), transparent 64%);
filter: blur(28px);
radial-gradient(58% 76% at 26% 22%,
color-mix(in oklab, var(--accent) 34%, transparent), transparent 68%),
radial-gradient(52% 70% at 80% 64%,
color-mix(in oklab, var(--glow-2) 30%, transparent), transparent 66%);
}
.hero-canvas { opacity: 0; transition: opacity 1.2s var(--ease-soft); }
.hero-canvas.is-live { opacity: 1; }
.stage-canvas { opacity: 0; transition: opacity 1.2s var(--ease-soft); }
.stage-canvas.is-live { opacity: 1; }
.hero-grid {
grid-column: full;
max-width: 1180px;
width: 100%;
margin-inline: auto;
padding-inline: var(--space-4);
display: grid;
gap: var(--space-6);
align-items: center;
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
}
.hero-title {
/* 히어로는 이 페이지에서 가장 큰 소리다 */
max-width: 11ch;
}
[lang="en"] .hero-title { max-width: 14ch; }
.hero-lead {
max-width: 26em;
margin-block: var(--space-4) var(--space-5);
color: var(--ink);
opacity: 0.82;
}
.hero-cta { max-width: 32em; }
.hero-links {
.stage-readout {
position: absolute;
left: var(--space-3);
bottom: var(--space-3);
margin: 0;
display: flex;
gap: var(--space-4);
margin-top: var(--space-4);
font-size: var(--step-0);
color: var(--ink-muted);
align-items: baseline;
gap: 0.5ch;
font-family: var(--font-mono);
font-size: var(--step--1);
font-variant-numeric: tabular-nums;
letter-spacing: var(--tracking-label);
color: var(--ink);
/* 캔버스 위에 얹는 유일한 글자다. 판독 가능해야 하므로 바탕을 깐다 */
padding: 0.35em 0.7em;
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--surface-sunken) 82%, transparent);
border: 1px solid var(--line);
}
.ro-key { color: var(--ink-muted); }
.ro-val { color: var(--accent); }
.ro-sep, .ro-total { color: var(--ink-muted); }
.ro-state {
margin-left: 0.8ch;
padding-left: 0.8ch;
border-left: 1px solid var(--line-strong);
color: var(--accent);
}
.hero-proof { margin: 0; }
.hero-proof .shell {
box-shadow: var(--glass-hi), var(--shadow-3);
transform: rotate(-1.1deg);
}
.hero-proof .core { overflow: hidden; line-height: 0; }
.hero-proof :global(img) {
width: 100%;
height: auto;
display: block;
aspect-ratio: 4 / 3;
object-fit: cover;
object-position: top center;
}
figcaption {
margin-top: var(--space-3);
font-size: var(--step-0);
color: var(--ink-muted);
max-width: var(--measure);
}
@media (max-width: 960px) {
.hero-grid { grid-template-columns: 1fr; }
.hero-proof .shell { transform: none; }
.hero { min-height: 0; }
@media (max-width: 900px) {
.hero-head { grid-template-columns: 1fr; gap: var(--space-5); }
.stage-frame { aspect-ratio: 4 / 3; }
}
</style>
<script>
// 유리 판이 뒤의 빛을 실제로 굴절시킨다. 렌더 타겟 한 장으로 만든 굴절이라
// transmission 재질보다 훨씬 싸다. 자세한 이유는 lib/hero-scene.ts 주석에.
// 판 여섯 장이 깊이 축에 서 있고 카메라가 스크롤에 맞춰 통과한다.
// 굴절은 렌더 타겟 한 장으로 만든다 — transmission 재질보다 훨씬 싸다.
import { mountHeroScene } from "../lib/hero-scene";
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
const gateEl = document.getElementById("ro-gate");
const stateEl = document.getElementById("ro-state");
const passWord = stateEl?.textContent ?? "";
if (canvas) {
mountHeroScene({ canvas, accentVar: "--accent", glowVar: "--glow-2" });
let lastGate = -1;
mountHeroScene({
canvas,
accentVar: "--accent",
glowVar: "--glow-2",
// 프레임마다 DOM 을 쓰지 않는다. 값이 바뀔 때만 쓴다.
onGate(index, total) {
if (index === lastGate) return;
lastGate = index;
if (gateEl) gateEl.textContent = String(index + 1).padStart(2, "0");
if (stateEl) stateEl.textContent = index + 1 >= total ? passWord : "···";
},
});
}
</script>

View file

@ -86,7 +86,7 @@ const { c } = Astro.props;
width: 100%;
margin-inline: auto;
padding: var(--space-5) var(--space-4);
border-radius: var(--radius-shell);
border-radius: var(--radius-frame);
overflow: hidden;
}
.stage-fallback,
@ -131,7 +131,7 @@ const { c } = Astro.props;
/* 필터를 먹일 대상. 같은 도형에 서로 다른 재질을 입힌다 */
.swatch {
aspect-ratio: 4 / 3;
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
overflow: hidden;
background:
repeating-linear-gradient(

View file

@ -19,24 +19,27 @@ const { c } = Astro.props;
<h2 id="pipeline-h" class="reveal">{c.h2}</h2>
<p class="lead pipeline-lead reveal">{c.lead}</p>
<div class="brief glass reveal"><div class="brief-core">
<div class="brief reveal"><div class="brief-core">
<span class="brief-label">{c.briefLabel}</span>
<p class="brief-text">{c.brief}</p>
</div></div>
<div class="full spine-wrap">
<ol role="list" class="spine">
{
c.stages.map((s) => (
<li class="stop reveal">
<div class="stop-head">
<h3 class="stop-name">{s.name}</h3>
<p class="stop-does">{s.does}</p>
</div>
<p class:list={["stop-becomes", { mono: s.mono }]}>{s.becomes}</p>
</li>
))
}
</ol>
<p class="after muted reveal">{c.after}</p>
</div>
</section>
<style>
@ -44,6 +47,15 @@ const { c } = Astro.props;
margin-bottom: var(--space-6);
}
/* 스파인은 페이지의 본체다. main 칼럼(561px)에 가두면
페이지에서 가장 긴 섹션이 가장 좁아진다.
R1(zed.dev) 실측: 컨테이너 1120px 과 전체폭이 교대한다. */
.spine-wrap {
max-width: 1180px;
margin-inline: auto;
padding-inline: var(--space-4);
}
.brief { margin-bottom: var(--space-2); }
.brief-core { padding: var(--space-4); }
.brief-label {
@ -80,7 +92,12 @@ const { c } = Astro.props;
.stop {
position: relative;
padding-block: var(--space-4);
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: var(--space-5);
align-items: start;
}
.stop-head { min-width: 0; }
.stop + .stop {
border-top: 1px solid var(--line);
}
@ -105,7 +122,8 @@ const { c } = Astro.props;
}
.stop-does {
color: var(--ink-muted);
margin-bottom: var(--space-3);
margin: 0;
max-width: 34ch;
}
/* 브리프가 이 단계에서 무엇이 됐는가 — 변화가 순서를 만든다 */
@ -131,6 +149,14 @@ const { c } = Astro.props;
.after {
margin-top: var(--space-5);
max-width: var(--measure);
}
@media (max-width: 780px) {
.stop {
grid-template-columns: 1fr;
gap: var(--space-3);
}
}
/* 스크롤 진입 페이드를 넣었다가 뺐다.

View file

@ -65,7 +65,7 @@ const { c } = Astro.props;
top: calc(var(--space-4) + 0.55em);
width: 5px;
height: 5px;
border-radius: 999px;
border-radius: var(--radius-sm);
background: var(--accent);
}

View file

@ -18,12 +18,10 @@ const shots = [shotCoffee, shotSaas];
{
c.items.map((item, i) => (
<figure class:list={["work", "reveal", i === 0 ? "par-slow" : "par-fast"]} style={`--reveal-delay:${i * 90}ms`}>
<div class="shell zoom-in">
<div class="core">
<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>
</div>
<figcaption>
<span class="work-caption">{item.caption}</span>
<span class="work-note muted">{item.note}</span>
@ -50,10 +48,10 @@ const shots = [shotCoffee, shotSaas];
.work:nth-child(2) { margin-top: var(--space-7); }
.work { margin: 0; }
.work .shell { box-shadow: var(--glass-hi), var(--shadow-3); }
.work:nth-child(1) .shell { transform: rotate(-0.5deg); }
.work:nth-child(2) .shell { transform: rotate(0.7deg); }
.work .core { overflow: hidden; line-height: 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;
@ -72,6 +70,6 @@ const shots = [shotCoffee, shotSaas];
@media (max-width: 820px) {
.cascade { grid-template-columns: 1fr; }
.work:nth-child(2) { margin-top: 0; }
.work .shell { transform: none; }
.work .frame { transform: none; }
}
</style>

View file

@ -15,11 +15,11 @@ const { c } = Astro.props;
{/* 주장을 글로 하지 않고 실물로 보여준다. 두 문단의 차이는 word-break 한 줄뿐이다 */}
<div class="demo reveal">
<div class="demo-col glass"><div class="demo-inner">
<div class="demo-col"><div class="demo-inner">
<p class="demo-label muted mono">{c.demoLabels[0]}</p>
<p lang="ko" class="demo-text demo-default">{c.demoText}</p>
</div></div>
<div class="demo-col glass"><div class="demo-inner">
<div class="demo-col"><div class="demo-inner">
<p class="demo-label muted mono">{c.demoLabels[1]}</p>
<p lang="ko" class="demo-text demo-keepall">{c.demoText}</p>
</div></div>

View file

@ -35,8 +35,12 @@ export interface Content {
install: string;
installNote: string;
links: { label: string; href: string }[];
proofAlt: string;
proofCaption: string;
stage: {
alt: string;
caption: string;
gate: string;
pass: string;
};
};
pipeline: {
eyebrow: string;
@ -174,9 +178,12 @@ export const ko: Content = {
{ label: "저장소", href: "https://git.chanpaca.net/yunchan/designpaca" },
{ label: "npm", href: "https://www.npmjs.com/package/designpaca" },
],
proofAlt:
"designpaca 로 만든 커피 구독 랜딩. 헤드라인은 취향은 형용사가 아니라 좌표입니다 이고, 옆에 원두 3종의 수치표가 있다.",
proofCaption: "이 스킬로 만든 페이지. 브리프 한 줄에서 시작했다.",
stage: {
alt: "유리판 여섯 장이 깊이 방향으로 나란히 서 있다. 스크롤하면 카메라가 판을 하나씩 통과하고, 통과한 판은 뒤로 물러난다.",
caption: "판 하나가 단계 하나다. 통과 조건을 채우지 못하면 다음 판으로 가지 않는다.",
gate: "게이트",
pass: "통과",
},
},
pipeline: {
eyebrow: "여섯 단계",
@ -241,12 +248,12 @@ export const ko: Content = {
"문장을 다듬는 것으로 해결되는 것은 문장뿐이다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남는다. 그래서 이 페이지 자신의 형태 수치를 공개한다. 결과가 나쁘게 나와도 그대로 싣는다.",
items: [
{ label: "히어로까지 JS", value: "0.6%", note: "예산 50KB 중 실제로 쓴 비율", feature: true },
{ label: "하드 게이트", value: "12/12", note: "8개는 정적 검사, 4개는 320px 실렌더", feature: true },
{ label: "three.js 추가분", value: "185KB", note: "재료 섹션이 가까워질 때만 받는다. 초기 번들에는 없다" },
{ label: "하드 게이트", value: "12/12", note: "정적 검사와 320px · 1440px 실렌더 양쪽", feature: true },
{ label: "three.js 추가분", value: "184KB", note: "검사대가 화면에 들어올 때만 받는다. 초기 번들에는 없다" },
{ label: "슬롭 지문", value: "0건", note: "9개 패턴 grep" },
{ label: "radius 어휘", value: "2종", note: "10px, 20px. 셸은 파생값" },
{ label: "반투명 패널", value: "5개", note: "29개까지 늘렸다가 걷어냈다. 유리는 정말 떠 있어야 하는 것에만 남겼다" },
{ label: "타입 단계", value: "5단계", note: "디스플레이는 스케일 밖" },
{ label: "radius 어휘", value: "2종", note: "3px 과 14px. 알약 모양을 없앴다" },
{ label: "반투명 패널", value: "2개", note: "29개까지 늘렸다가 걷어냈다. 유리는 무언가를 담는 액자에만 남겼다" },
{ label: "타입 단계", value: "6단계", note: "토큰에 정의된 수. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" },
{ label: "폰트 패밀리", value: "2개", note: "언어판마다 자기 것만 받는다" },
{ label: "강조색", value: "1개", note: "라이트와 다크는 같은 색의 대응값" },
{ label: "첫 인터랙션", value: "미측정", note: "배포 후 Lighthouse 로 잰다" },
@ -301,8 +308,28 @@ export const ko: Content = {
caught: "본문 컬럼이 960px. 우리가 정한 한 줄 길이는 561px",
fixed: "그리드의 본문 폭을 토큰에 묶었다. 스킬 문서의 예시 코드도 같이 고쳤다.",
},
{
caught: "방향을 바꾸면서 이 스킬의 1단계를 건너뛰었다",
fixed:
"재질을 유리로 바꾸라는 요청은 2단계 방향 재결정이고, 그때는 전체 경로로 올려 레퍼런스부터 다시 봐야 한다. 그러지 않고 구현으로 바로 들어갔다. 결과는 이전 방향의 뼈대에 유리를 바른 페이지였고 design.md 는 옛 결정을 그대로 담고 있었다. 0단계부터 다시 돌았다.",
},
{
caught: "유리판이 정면으로 서 있어 프레넬이 구조적으로 0이었다",
fixed:
"평면이 카메라를 정면으로 보면 법선과 시선의 내적이 1이라 가장자리 반사가 계산되지 않는다. 유리로 읽히게 하는 유일한 신호가 죽어 있었고, 그래서 판 여섯 장이 흐린 사각형으로 보였다. 법선을 미세하게 굽히고 테두리 광을 UV 경계에서 따로 만들었다.",
},
{
caught: "페이지의 본체인 파이프라인이 화면의 43%만 쓰고 있었다",
fixed:
"1440px 중 625px. 아홉 섹션에서 가장 좁으면서 높이는 1637px 로 가장 길었다. 단계 설명과 산출물을 두 열로 열어 1113px 로 넓혔다.",
},
{
caught: "대비를 재는 스크립트가 반투명 배경을 불투명으로 계산했다",
fixed:
"\"English 1.15:1\" 이라는 틀린 값이 나왔다. 알파를 아래에서 위로 합성하도록 고치자 실패 0건이었다. 검사 도구가 틀리면 통과도 실패도 의미가 없다.",
},
],
note: "아홉 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 둘이다. 카드가 전형적이라는 말과, 폰트를 제대로 다루지 못한다는 말.",
note: "열세 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 셋이다. 카드가 전형적이라는 말, 폰트를 제대로 다루지 못한다는 말, 그리고 우리 스킬을 진짜로 쓴 게 맞냐는 말.",
},
typography: {
eyebrow: "한글 조판",
@ -373,9 +400,12 @@ export const en: Content = {
{ label: "Repository", href: "https://git.chanpaca.net/yunchan/designpaca" },
{ label: "npm", href: "https://www.npmjs.com/package/designpaca" },
],
proofAlt:
"A coffee roastery subscription landing page built with designpaca. The hero carries a table of acidity and body figures for three beans.",
proofCaption: "Built with this skill, starting from one line of brief.",
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.",
caption: "One pane is one stage. Fail its condition and you do not reach the next.",
gate: "GATE",
pass: "PASS",
},
},
pipeline: {
eyebrow: "Six stages",
@ -440,12 +470,12 @@ export const en: Content = {
"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.",
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: "8 checked statically, 4 on a real 320px render", feature: true },
{ label: "three.js payload", value: "185KB", note: "fetched only as the materials section approaches. Never in the initial bundle" },
{ label: "hard gates", value: "12/12", note: "static checks plus real renders at 320px and 1440px", feature: true },
{ label: "three.js payload", value: "184KB", note: "fetched only when the inspection frame enters view. Never in the initial bundle" },
{ label: "slop fingerprints", value: "0", note: "9 patterns grepped" },
{ label: "radius vocabulary", value: "2", note: "10px and 20px. The shell is derived" },
{ label: "translucent panels", value: "5", note: "grew to 29 before being pulled back. Glass stayed only where something has to float" },
{ label: "type scale steps", value: "5", note: "the display size sits outside the scale" },
{ 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: "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" },
@ -501,8 +531,28 @@ export const en: Content = {
fixed:
"Bound the grid's main column to the token. The skill's own example code was wrong too, so that got fixed as well.",
},
{
caught: "We skipped this skill's own stage 1 when the direction changed",
fixed:
"Switching the material to glass is a stage-2 decision, and that means going back to references first. We jumped straight to implementation instead. The result was glass painted onto the previous direction's skeleton, while design.md still described the old decisions. We re-ran the whole path from stage 0.",
},
{
caught: "The glass panes faced the camera head-on, so Fresnel was structurally zero",
fixed:
"When a flat plane faces the camera, the dot product of normal and view is 1, so no edge reflection is computed. The one signal that makes a surface read as glass was dead, which is why six panes looked like blurry rectangles. We bend the normal slightly and derive the rim light from the UV edge instead.",
},
{
caught: "The pipeline, the spine of this page, used only 43% of the viewport",
fixed:
"625px out of 1440. Narrowest of the nine sections while being the tallest at 1637px. Opening each stage into two columns brought it to 1113px.",
},
{
caught: "The contrast script treated translucent backgrounds as opaque",
fixed:
"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 nine were caught while building this page, and fixed after being caught. Two came from a person rather than a checker: the cards look generic, and the typography is not handled well.",
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",

View file

@ -18,17 +18,22 @@ const FONT_CSS = {
const alt = lang === "ko" ? "/en/" : "/";
// 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크
// 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크.
// data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다.
// 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다.
// MARK_BG = --accent-inverse · MARK_FG = --surface-inverse
const MARK_BG = "#0E6B63";
const MARK_FG = "#F4F2EC";
const FAVICON =
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0E6B63"/>
<path d="M11 7v18" stroke="#FAF9F6" stroke-width="2" stroke-linecap="round"/>
<circle cx="11" cy="10" r="3" fill="#FAF9F6"/>
<circle cx="11" cy="16" r="3" fill="#FAF9F6"/>
<circle cx="11" cy="22" r="3" fill="#FAF9F6"/>
<path d="M18 10h7M18 16h5M18 22h7" stroke="#FAF9F6" stroke-width="2" stroke-linecap="round" opacity=".55"/>
<rect width="32" height="32" rx="3" fill="${MARK_BG}"/>
<path d="M11 7v18" stroke="${MARK_FG}" stroke-width="2" stroke-linecap="round"/>
<circle cx="11" cy="10" r="3" fill="${MARK_FG}"/>
<circle cx="11" cy="16" r="3" fill="${MARK_FG}"/>
<circle cx="11" cy="22" r="3" fill="${MARK_FG}"/>
<path d="M18 10h7M18 16h5M18 22h7" stroke="${MARK_FG}" stroke-width="2" stroke-linecap="round" opacity=".55"/>
</svg>`,
);
---
@ -144,7 +149,12 @@ const FAVICON =
/* 상단에 붙은 풀블리드 바 대신 떠 있는 헤더 */
/* 상단에 붙은 바가 아니라 떠 있는 유리 알약 */
/* 언어 전환 링크에는 lang 속성이 붙는다(스크린리더가 "English" 를
영어로 읽어야 하므로 옳다). 그런데 [lang] 셀렉터가 타입 스케일을
통째로 재정의해서 이 링크만 16px 이 됐다 — 스케일 밖 단계가 하나 생긴다.
크기는 헤더에서 확정하고 링크는 상속만 받는다. */
.site-header {
font-size: var(--step-0);
position: fixed;
top: var(--space-3);
left: 0;
@ -160,7 +170,7 @@ const FAVICON =
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
border-radius: 999px;
border-radius: var(--radius-frame);
}
.wordmark {
display: inline-flex;
@ -176,11 +186,13 @@ const FAVICON =
align-items: center;
min-height: 36px;
padding-inline: var(--space-3);
border-radius: 999px;
border-radius: var(--radius-sm);
font-size: inherit;
background: var(--glass-tint-2);
border: 1px solid var(--glass-edge);
font-size: var(--step-0);
color: var(--ink-muted);
/* muted 로 두면 유리 위에서 2.59:1 이다. 언어 전환은 내비게이션이지
보조 정보가 아니므로 ink 로 올린다(하드 게이트 #7) */
color: var(--ink);
text-decoration: none;
transition: color var(--dur-instant) var(--ease-out),
background var(--dur-instant) var(--ease-out);

View file

@ -32,6 +32,12 @@ export interface HeroSceneOptions {
/** CSS 커스텀 프로퍼티 이름 */
accentVar: string;
glowVar: string;
/**
* .
* R3(Codrops HAOQI) . DOM
* WebGL . DOM .
*/
onGate?: (index: number, total: number) => void;
}
const NOISE = `
@ -64,7 +70,7 @@ export function mountHeroScene(opts: HeroSceneOptions): void {
io.observe(canvas);
}
async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<void> {
async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): Promise<void> {
let THREE: typeof import("three");
try {
THREE = await import("three");
@ -161,7 +167,10 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
uniforms: {
uScene: { value: rt.texture },
uTime: { value: 0 },
uEdge: { value: new THREE.Color("#ffffff") },
// 테두리 광. 흰색을 박지 않고 강조색을 밝게 올려 쓴다 —
// 토큰을 바꾸면 유리 테두리도 같이 따라와야 한다.
uEdge: { value: accent.clone().lerp(new THREE.Color(1, 1, 1), 0.55) },
uResolution: { value: new THREE.Vector2(1, 1) },
},
vertexShader: `
varying vec2 vUv;
@ -183,22 +192,31 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
uniform sampler2D uScene;
uniform float uTime;
uniform vec3 uEdge;
uniform vec2 uResolution;
${NOISE}
void main() {
// 판을 미세하게 굽힌다.
// 완전히 평평한 판이 카메라를 정면으로 보면 dot(n, view) 가 1 이라
// 프레넬이 구조적으로 0 이 된다. 그러면 유리로 읽히게 하는 신호가
// 하나도 남지 않는다 — 이전 판이 흐린 얼룩으로 보였던 이유다.
vec3 n = normalize(vNormalW + vec3((vUv - 0.5) * 0.9, 0.0));
float fres = pow(1.0 - abs(dot(n, normalize(vViewDir))), 2.2);
// 판의 테두리. 평면에서 두께가 보이는 곳은 법선이 아니라 UV 경계다.
vec2 e = min(vUv, 1.0 - vUv);
float edge = 1.0 - smoothstep(0.0, 0.05, min(e.x, e.y));
// 화면 좌표를 굴절시켜 뒤 텍스처를 샘플링한다
vec2 screen = gl_FragCoord.xy / vec2(textureSize(uScene, 0));
vec2 screen = gl_FragCoord.xy / uResolution;
vec2 flow = vec2(
fbm(vUv * 2.4 + uTime * 0.05),
fbm(vUv * 2.4 - uTime * 0.04 + 3.1)
) - 0.5;
vec3 back = texture2D(uScene, screen + flow * 0.07 + vNormalW.xy * 0.035).rgb;
vec3 back = texture2D(uScene, screen + flow * 0.06 + n.xy * 0.05).rgb;
// 가장자리에서 빛이 서는 프레넬
float fres = pow(1.0 - abs(dot(normalize(vNormalW), normalize(vViewDir))), 2.6);
vec3 col = back * 1.06 + uEdge * fres * 0.5;
float alpha = 0.20 + fres * 0.55;
vec3 col = back * 1.04 + uEdge * (fres * 0.32 + edge * 0.85);
float alpha = 0.14 + fres * 0.26 + edge * 0.72;
gl_FragColor = vec4(col, alpha);
}
`,
@ -229,6 +247,9 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
if (!r.width || !r.height) return;
renderer.setSize(r.width, r.height, false);
rt.setSize(Math.round(r.width * dpr * 0.6), Math.round(r.height * dpr * 0.6));
// gl_FragCoord 는 캔버스의 드로잉버퍼 좌표다. 렌더 타겟은 그 0.6배라
// 타겟 크기로 나누면 UV 가 1 을 넘어 텍스처가 잘린다.
renderer.getDrawingBufferSize(paneMat.uniforms["uResolution"]!.value);
camera.aspect = r.width / r.height;
camera.updateProjectionMatrix();
bgUniforms.uAspect.value = r.width / r.height;
@ -262,22 +283,24 @@ async function boot({ canvas, accentVar, glowVar }: HeroSceneOptions): Promise<v
pointer.lerp(pointerTarget, 0.04);
const rect0 = canvas.getBoundingClientRect();
const progress0 = Math.min(Math.max(-rect0.top / (rect0.height * 1.6), 0), 1);
bgUniforms.uProgress.value = progress0;
// 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
// rect 는 프레임당 한 번만 읽는다. 두 번 읽으면 레이아웃을 두 번 강제한다.
const rect = canvas.getBoundingClientRect();
// 액자가 화면 위로 92% 지나갔을 때 마지막 게이트를 통과하게 잡았다.
// 그 뒤로는 액자가 보이지 않으므로 계속 진행시킬 이유가 없다.
const travel = rect.height * 0.92;
const progress = Math.min(Math.max(-rect.top / travel, 0), 1);
bgUniforms.uProgress.value = progress;
bgUniforms.uTime.value = t;
bgUniforms.uPointer.value.copy(pointer);
paneMat.uniforms["uTime"]!.value = t;
// 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
// 이미 도는 렌더 루프 안에서 위치를 한 번 읽을 뿐이라 추가 비용이 없다.
const rect = canvas.getBoundingClientRect();
const travel = rect.height * 1.6;
const progress = Math.min(Math.max(-rect.top / travel, 0), 1);
onGate?.(Math.min(PANE_COUNT - 1, Math.floor(progress * PANE_COUNT)), PANE_COUNT);
// 카메라가 여섯 개의 문을 차례로 통과한다
const depth = (PANE_COUNT - 1) * GATE_GAP;
camera.position.z = 8.5 - progress * (depth + 6);
camera.position.z = 7.2 - progress * (depth + 5);
camera.position.x = (pointer.x - 0.5) * 1.1;
camera.position.y = (pointer.y - 0.5) * -0.5;
camera.lookAt(camera.position.x * 0.3, 0, camera.position.z - 10);

View file

@ -165,7 +165,7 @@ a:hover { color: var(--accent); }
isolation: isolate;
background: var(--glass-tint);
border: 1px solid var(--glass-edge);
border-radius: var(--radius-md);
border-radius: var(--radius-frame);
box-shadow: var(--glass-hi), var(--shadow-2);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
@ -178,13 +178,7 @@ a:hover { color: var(--accent); }
z-index: -1;
border-radius: inherit;
pointer-events: none;
background:
linear-gradient(
180deg,
rgb(255 255 255 / 0.10) 0%,
rgb(255 255 255 / 0.02) 22%,
transparent 55%
);
background: var(--glass-sheen);
}
/* backdrop-filter SVG 필터를 얹는 것은 쓰지 않는다.
Chrome CSS.supports 로는 true 주면서 렌더에서 선언 전체를 버린다
@ -199,21 +193,15 @@ a:hover { color: var(--accent); }
border-radius: inherit;
pointer-events: none;
padding: 1px;
background: conic-gradient(
from 210deg,
rgb(255 255 255 / 0.32),
transparent 22%,
transparent 58%,
rgb(255 255 255 / 0.20) 74%,
transparent 92%
);
background: var(--glass-rim);
/* 아래 black 은 색이 아니라 마스크의 알파다. 팔레트와 무관하다 */
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
linear-gradient(black 0 0) content-box,
linear-gradient(black 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
linear-gradient(black 0 0) content-box,
linear-gradient(black 0 0);
mask-composite: exclude;
}
@ -237,27 +225,20 @@ a:hover { color: var(--accent); }
/* 유리 위에 얹는 두 번째 판. 안쪽으로 갈수록 밝아진다 */
.glass-2 { background: var(--glass-tint-2); }
/* 이중 베젤: 유리판이 트레이 안에 앉는다 */
.shell {
background: var(--glass-tint);
border: 1px solid var(--glass-edge);
border-radius: var(--radius-shell);
padding: var(--shell-pad);
box-shadow: var(--glass-hi), var(--shadow-2);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
}
.core {
/* 액자. 이중 베젤을 버렸다 껍데기와 속의 radius 서로 달라
그것만으로 형태 어휘가 늘어난다(하드 게이트 #3).
그리고 안이 불투명한 이미지면 backdrop-filter 아무것도 하지 않는다.
유리로 읽히게 하는 것은 테두리 광과 위쪽 반사지 흐림이 아니다. */
.frame {
position: relative;
background: var(--surface-sunken);
border-radius: var(--radius-md);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
}
@media (prefers-reduced-transparency: reduce) {
.shell {
background: color-mix(in oklab, var(--surface) 88%, var(--ink));
backdrop-filter: none;
}
border: 1px solid var(--glass-edge);
border-radius: var(--radius-frame);
box-shadow: var(--glass-hi), var(--core-hi), var(--shadow-2);
overflow: clip;
}
/* 이미지를 담는 액자만. 인라인 여백을 지운다 */
.frame > :is(img, picture, canvas) { display: block; width: 100%; height: auto; }
/* ---- 진입 연출 ----------------------------------------------
opacity 콘텐츠를 숨기지 않는다.

View file

@ -1,11 +1,15 @@
/* ============================================================
designpaca 소개 페이지 디자인 토큰
방향 재설정 2026-08-20: Ethereal Glass
프리셋: dark-instrument · 컨셉: 검사대 위의 물체
페이지는 SVG 필터와 three.js 파는 제품의 데모다.
리퀴드 글래스는 기술의 결합 자체이므로 여기서는
장식이 아니라 제품 시연이다. 다크 기반을 고른 것도 같은 이유다.
유리는 어두운 바탕 위에서만 굴절이 읽힌다.
다크를 "멋있어서" 고르지 않았다. 제품이 CLI 이고 사용자가
화면을 터미널 옆에 두고 본다. 프리셋이 요구하는 근거다.
유리는 액자에만 쓴다. 액자 안은 3D, 액자 밖은 글자
R2(lusion.co) 실측에서 가져온 원리다. 둘을 겹치면 3D 얼룩이
되고 글자는 대비를 잃는다. 실제로 그렇게 만들었다.
전체 결정 근거는 design.md.
============================================================ */
:root {
@ -33,11 +37,17 @@
--accent-ink: #04211D;
--accent-inverse: #0E7A6E;
--accent-ink-inverse: #FFFFFF;
/* 셰이더가 쓰는 두 번째 광색. 강조색이 아니라 광원이다 */
--glow-2: #6E7BFF;
/* 셰이더가 쓰는 번째 광색. 강조색이 아니라 광원이다.
보라기를 뺐다 #6E7BFF R 높아 보라-파랑으로 읽히고,
그것이 antipatterns.md 슬롭 지문이자 dark-instrument
"어두운 배경일수록 빠지기 쉬운 함정"으로 지목한 색이다. */
--glow-2: #3D7BD6;
/* ---- 유리 ------------------------------------------------
backdrop-filter 만으로는 흐린 유리다. 굴절은 SVG 만든다. */
유리는 액자에만 쓴다. 헤더 · 검사대 프레임 · 게이트 배지.
콘텐츠 카드에는 쓰지 않는다 유리는 무언가를 담을 때만
의미가 있고, 아무 데나 바르면 그냥 반투명한 상자다.
(R2 lusion.co 실측: 3D 액자에 가두고 텍스트는 액자 밖에 둔다) */
--glass-tint: rgb(255 255 255 / 0.045);
--glass-tint-2: rgb(255 255 255 / 0.085);
--glass-edge: rgb(255 255 255 / 0.14);
@ -47,17 +57,39 @@
--glass-hi:
inset 0 1px 0 rgb(255 255 255 / 0.22),
inset 0 -1px 0 rgb(255 255 255 / 0.05);
/* 표면 반사와 테두리 . 값이 아니라 토큰으로 둬야 밝은 밴드에서
같은 자리를 뒤집을 있다 (하드 게이트 #1) */
--glass-sheen: linear-gradient(
180deg,
rgb(255 255 255 / 0.10) 0%,
rgb(255 255 255 / 0.02) 22%,
transparent 55%
);
--glass-rim: conic-gradient(
from 210deg,
rgb(255 255 255 / 0.32),
transparent 22%,
transparent 58%,
rgb(255 255 255 / 0.20) 74%,
transparent 92%
);
--core-hi: inset 0 1px 0 rgb(255 255 255 / 0.06);
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.5);
--shadow-2: 0 2px 6px rgb(0 0 0 / 0.4), 0 12px 32px rgb(0 0 0 / 0.5);
--shadow-3: 0 6px 16px rgb(0 0 0 / 0.45), 0 32px 80px rgb(0 0 0 / 0.6);
--inset-hi: var(--glass-hi);
/* ---- 형태 ------------------------------------------------ */
--radius-sm: 10px;
--radius-md: 20px;
--shell-pad: 6px;
--radius-shell: calc(var(--radius-md) + var(--shell-pad));
/* ---- 형태 ------------------------------------------------
종류뿐이다. 하드 게이트 #3 3종부터 실패로 본다.
3px 요소. R1 zed.dev 실측에서 border-radius 2px 621회로
압도적이었다. 개발자 도구는 각져야 정밀해 보인다
14px 액자. R2 lusion.co 3D 캔버스를 감싼 값이 15px 이다
pill(999px) 없앴다. 계기판에 알약은 없다. */
--radius-sm: 3px;
--radius-frame: 14px;
/* ---- 간격 ------------------------------------------------ */
--space-1: 0.25rem;
@ -98,12 +130,14 @@
--font-sans: "Pretendard Variable", Pretendard,
"KO Fallback Win", "KO Fallback Mac", system-ui, sans-serif;
--step--1: 0.8125rem;
--step-0: 1.0625rem;
--step-1: 1.328rem;
--step-2: 1.660rem;
--step-3: 2.076rem;
--step-4: 2.595rem;
/* 히어로에 힘을 준다 */
/* 페이지에서 가장 글자. 히어로가 아니라 수치 섹션이 쓴다
히어로의 힘은 타이포가 아니라 검사대 액자에서 나온다 */
--display: clamp(3rem, 1.2rem + 7.2vw, 7rem);
--leading-tight: 1.06;
@ -122,6 +156,7 @@
[lang="en"] {
--font-sans: "Switzer", "EN Fallback", system-ui, -apple-system, sans-serif;
--step--1: 0.8125rem;
--step-0: 1rem;
--step-1: 1.25rem;
--step-2: 1.5625rem;

View file

@ -45,7 +45,7 @@ canvas { display: none !important; }
|---|---|---|
| 키보드 도달 | Tab 만으로 순회 | 모든 인터랙티브 요소에 도달, 순서가 시각 순서와 일치 |
| 포커스 표시 | Tab 하며 눈으로 | 항상 보인다. `outline: none` 만 있고 대체가 없으면 실패 |
| 대비 | 측정 도구 | 본문 4.5:1, 큰 글자·UI 3:1 |
| 대비 | 측정 도구 (아래 주의) | 본문 4.5:1, 큰 글자·UI 3:1 |
| 이미지 대체텍스트 | 소스 확인 | 의미 있는 이미지에 alt, 장식은 `alt=""` |
| 폼 레이블 | 소스 확인 | 모든 입력에 연결된 `<label>`. placeholder 는 레이블이 아니다 |
| 제목 구조 | 개요 확인 | h1 하나, 건너뛰지 않음 |
@ -53,6 +53,29 @@ canvas { display: none !important; }
| 자동재생 | 확인 | 5초 이상 반복되는 애니메이션에는 정지 수단이 있다 |
| 캔버스/WebGL 실패 | JS 끄고 확인 | 콘텐츠가 여전히 보인다 |
> **대비를 스크립트로 잴 때 — 반투명 배경을 불투명으로 계산하지 마라.**
> `getComputedStyle(el).backgroundColor` 로 조상을 거슬러 올라가며 "투명이 아닌 첫 값"을 배경으로 쓰면,
> `rgb(255 255 255 / 0.085)` 같은 유리 틴트를 **순백**으로 취급한다. 흰 글자가 1.15:1 로 나온다.
> 알파가 1 미만인 층은 계속 쌓고, **아래에서 위로 합성**한 뒤에 재라:
>
> ```js
> // 알파 < 1 이면 멈추지 말고 계속 쌓는다
> const stack = []; let e = el;
> while (e && e !== document.documentElement) {
> const p = parse(getComputedStyle(e).backgroundColor);
> if (p && p[3] > 0) { stack.push(p); if (p[3] >= 1) break; }
> e = e.parentElement;
> }
> let [r, g, b] = rootBg;
> for (let i = stack.length - 1; i >= 0; i--) {
> const [sr, sg, sb, sa] = stack[i];
> r = sr*sa + r*(1-sa); g = sg*sa + g*(1-sa); b = sb*sa + b*(1-sa);
> }
> ```
>
> 유리·오버레이·틴트를 쓰는 페이지에서는 이 차이가 **통과와 실패를 뒤집는다.**
> **검사 도구가 틀리면 통과도 실패도 의미가 없다.** 실패가 나오면 값을 고치기 전에 측정을 먼저 의심해라.
**스크린리더를 실제로 켜볼 수 없다면** 최소한 소스의 읽기 순서를 확인해라. `order`·`position: absolute` 로 시각 순서를 바꿨다면 읽기 순서가 어긋난다.
---

View file

@ -20,6 +20,35 @@
---
## 0-A. 배치가 먼저다 — 3D 는 액자 안, 텍스트는 액자 밖
채택을 결정했다면 **어디에 놓을지부터 정해라.** 이 결정이 씬의 품질보다 결과를 크게 바꾼다.
> **기본값: 캔버스를 배경 전면에 깔고 그 위에 본문을 얹지 마라.**
겹치면 두 가지가 동시에 일어난다. 3D 는 텍스트 가독성을 위해 흐리고 어둡게 눌려 얼룩이 되고,
텍스트는 대비를 확보하려고 글로우·스크림·그림자를 달게 된다. **둘 다 소리치다 둘 다 죽는다.**
실측 근거 — `lusion.co`(3D 프로덕션 스튜디오, 2026-08 측정):
| 관측 | 값 |
|---|---|
| 캔버스 배치 | `border-radius: 15px` 액자 **안**. 페이지 배경과 명확히 분리 |
| 페이지 배경 | **흰색**. 텍스트 색 **2단계**(검정 135회 / 흰 16회) |
| 폰트 패밀리 | **1개** |
| 강조색 | 캔버스 **안**에서만. DOM 에는 순청 4회뿐 |
| h1 크기 | **36px** |
3D 를 가장 잘 쓰는 곳이 타이포를 가장 조용하게 쓴다. **힘이 글자가 아니라 물체에서 나오기 때문에**
h1 이 36px 이어도 히어로가 압도적이다.
겹쳐야만 하는 경우(HAOQI.DESIGN 패턴)는 **DOM 이 구조를 소유하고 WebGL 은 셰이더 uniform 만 만지게** 한다.
기본 상태는 DOM 이 그리고, 상호작용 상태에서만 WebGL 이 나타난다. §6 이 그 구현이다.
캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.
---
## 0. 채택 게이트 — "쓰지 않는다"를 먼저 통과시켜라
| 원하는 것 | 먼저 시도 | three가 필요한 순간 |
@ -81,6 +110,12 @@
| **셰이더 색이 틀림** | hex를 `vec3`에 하드코딩 | 셰이더 안 `vec3`는 **linear 값**이다. 색은 JS에서 `THREE.Color`로 넘겨라 |
| **WebGL1 미지원** [r163+] | 구형 기기 백지 | `webgl2` 컨텍스트로 게이팅 (§3) |
| **drei `Environment preset`** | 외부 CDN에서 HDRI를 받는다 | `files="/hdri/studio_1k.hdr"` 자체 호스팅 |
| **정면으로 선 평면의 프레넬** | 유리판이 "흐린 사각형"으로만 보이고 재질이 안 읽힌다 | 평면이 카메라를 정면으로 보면 `dot(normal, view) ≈ 1` 이라 **프레넬이 구조적으로 0**이다. 가장자리에서도 법선이 같아 변하지 않는다. 법선을 UV 로 미세하게 굽히고(`normalize(n + vec3((vUv-0.5)*0.9, 0.0))`), 두께는 **UV 경계**에서 따로 만들어라 |
| **`gl_FragCoord` 를 렌더 타겟 크기로 나눔** | 굴절 텍스처가 잘리거나 늘어난다 | `gl_FragCoord`**드로잉버퍼** 좌표다. 렌더 타겟을 0.6배로 줄여 썼다면 그 크기로 나누면 UV 가 1 을 넘는다. `renderer.getDrawingBufferSize()` 를 uniform 으로 넘겨라 |
**실측 사례**: 위 두 함정을 designpaca 소개 페이지가 동시에 밟았다. 판 여섯 장을 세워놓고
"그냥 흐릿한 사각형"이라는 평가를 받았는데, 원인은 배치도 색도 아니고 **유리로 읽히게 하는 신호가
계산되지 않고 있던 것**이었다. 셰이더는 에러를 내지 않는다 — 조용히 아무것도 안 한다.
---