Compare commits
10 commits
0e03bbafac
...
8ed45008a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ed45008a0 | ||
|
|
8267905c0c | ||
|
|
54ec6fa582 | ||
|
|
51d452e66c | ||
|
|
91bb4ae9dc | ||
|
|
af3a08397e | ||
|
|
1ed3c95542 | ||
|
|
5a18806fb6 | ||
|
|
2bb1da9ce9 | ||
|
|
54b9628879 |
27 changed files with 1040 additions and 135 deletions
62
.env.example
Normal file
62
.env.example
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# designpaca 배포 환경
|
||||
#
|
||||
# 이 파일은 커밋된다. 실제 값은 `.env` 에 넣어라 (.gitignore 에 있다).
|
||||
# cp .env.example .env
|
||||
#
|
||||
# 채워진 값은 실측으로 확인한 것이고, 빈 값은 사람이 발급해야 하는 것이다.
|
||||
|
||||
# ── NAS ──────────────────────────────────────────────────────
|
||||
# Synology DS420+ · Linux 4.4.302+ · Tailscale 로 접근한다.
|
||||
# SSH 키(id_ed25519)로 이미 붙는다. 비밀번호가 필요 없다.
|
||||
NAS_HOST=100.116.83.60
|
||||
NAS_HOST_LAN=192.168.0.39
|
||||
NAS_USER=yunchan
|
||||
|
||||
# ── Forgejo ──────────────────────────────────────────────────
|
||||
# 컨테이너 kd-forgejo (codeberg.org/forgejo/forgejo:11) + postgres:16-alpine
|
||||
# 웹 3000 → Cloudflare 프록시로 https://git.chanpaca.net
|
||||
# git SSH 2222 → 컨테이너 내부 22
|
||||
#
|
||||
# 주의: git.chanpaca.net 은 Cloudflare 뒤라 22/2222 가 막혀 있다.
|
||||
# git push 는 도메인이 아니라 Tailscale 주소로 해야 한다.
|
||||
FORGEJO_URL=https://git.chanpaca.net
|
||||
FORGEJO_SSH_HOST=100.116.83.60
|
||||
FORGEJO_SSH_PORT=2222
|
||||
FORGEJO_CONTAINER=kd-forgejo
|
||||
|
||||
# 등록된 SSH 키 이름: encep-win (yunchan 계정)
|
||||
FORGEJO_OWNER=yunchan
|
||||
FORGEJO_REPO=designpaca
|
||||
|
||||
# 완성되는 remote URL:
|
||||
# ssh://git@100.116.83.60:2222/yunchan/designpaca.git
|
||||
|
||||
# ── 사람이 발급해야 하는 것 ──────────────────────────────────
|
||||
# 아래는 전부 비워둔다. 값을 아는 사람만 채울 수 있다.
|
||||
|
||||
# Forgejo API 토큰. 저장소 생성·시크릿 등록에 쓴다.
|
||||
# git.chanpaca.net → 설정 → 애플리케이션 → 토큰 생성
|
||||
# 권한: repository(write), user(read)
|
||||
FORGEJO_TOKEN=
|
||||
|
||||
# npmjs 배포 토큰 (Automation 타입).
|
||||
# npmjs.com → Access Tokens → Generate → Automation
|
||||
NPM_TOKEN=
|
||||
|
||||
# Forgejo 사설 npm 레지스트리 미러용 토큰.
|
||||
# FORGEJO_TOKEN 과 같은 값을 써도 되지만, 권한을 나누려면 따로 발급한다.
|
||||
FORGEJO_NPM_TOKEN=
|
||||
|
||||
# Forgejo 릴리스 생성용 토큰. 워크플로가 draft 릴리스를 만든다.
|
||||
RELEASE_TOKEN=
|
||||
|
||||
# Cloudflare Pages 배포.
|
||||
# dash.cloudflare.com → My Profile → API Tokens
|
||||
# 권한: Account / Cloudflare Pages / Edit
|
||||
CF_API_TOKEN=
|
||||
CF_ACCOUNT_ID=
|
||||
|
||||
# Cloudflare Pages 프로젝트 이름 (아직 만들지 않았다)
|
||||
CF_PAGES_PROJECT=
|
||||
# 연결할 도메인
|
||||
SITE_DOMAIN=designpaca.chanpaca.net
|
||||
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# 배포되는 텍스트는 어느 OS 에서 체크아웃하든 LF 다.
|
||||
# SKILL.md 의 YAML 프론트매터가 CRLF 에서 파서에 따라 깨진다.
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
|
|
@ -189,6 +189,33 @@ WebGL 게이트: `prefers-reduced-motion` 이면 **아예 시작하지 않는다
|
|||
`ResizeObserver` 로 캔버스를 따라가게 했다. `window.resize` 만 듣던 동안 히어로 높이 변화를
|
||||
놓쳐 배경이 화면의 3분의 2만 덮고 아래가 검게 비었다.
|
||||
|
||||
## 패럴랙스 지도 (실측)
|
||||
|
||||
깊이가 다른 층을 겹쳐 시차를 만든다. **한 층은 반드시 고정이다** —
|
||||
전부 움직이면 기준이 없어 그냥 흔들리는 페이지가 된다.
|
||||
|
||||
| 자리 | 층 | 방식 | 실측 이동 |
|
||||
|---|---|---|---|
|
||||
| 히어로 배경 | 셰이더 흐름 필드 | `scroll(root)` | **+56px** ↓ |
|
||||
| 히어로 글자 | | `scroll(root)` | **−88px** ↑ (반대 방향) |
|
||||
| 파이프라인 | 게이트 씬(three.js) | `sticky` + 카메라 | 6단계를 통과 |
|
||||
| ㅤ└ 단계명 | | — | **0** 고정 |
|
||||
| ㅤ└ 산출물 | | `view()` | ±14px |
|
||||
| 재료 · 설치 · 프리플라이트 | 배경 사진 | `fixed` | **0 — 원경** |
|
||||
| 사례 | 배경 사진 | `drift` 0.4 | 이미지 높이의 28.6% |
|
||||
| 조판 | 종이 | `drift` 0.25 | −122px (근경) |
|
||||
| 재료 타일 | | `view()` | ±26 / ±58px |
|
||||
| 프리플라이트 항목 | | `view()` | 6 → 10px (뒤일수록 늦게) |
|
||||
| **수치** | | **없음** | 의도적 정지 |
|
||||
|
||||
세 가지 깊이가 다 있다 — **원경**(fixed) · **중경**(drift) · **근경**(콘텐츠와 함께).
|
||||
종이를 원경으로 두지 않은 이유는 손에 닿는 거리의 재질이기 때문이다.
|
||||
|
||||
모바일(390px)에서도 `fixed` 가 유지된다. `background-attachment: fixed` 를 안 쓴 덕이다.
|
||||
|
||||
`prefers-reduced-motion` 에서는 게이트 씬이 아예 시작하지 않고, 고정 배경은
|
||||
`position: absolute` 로 흐름에 돌아가며, 모든 `view()` 애니메이션이 꺼진다.
|
||||
|
||||
## 모션 문법 — 섹션마다 다른 말
|
||||
|
||||
같은 패럴랙스를 전부에 걸면 벽지다. 각 섹션의 주장을 움직임으로 옮겼다.
|
||||
|
|
@ -213,14 +240,185 @@ WebGL 게이트: `prefers-reduced-motion` 이면 **아예 시작하지 않는다
|
|||
|
||||
| 레이어 | 무엇 | 어디 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `.bg-grid` | 간격 눈금 격자. 셀이 `--grid-cell = --space-6` | 파이프라인 · 수치 | "모든 값이 토큰에서 나온다"가 배경에서도 성립해야 한다. 계기판의 눈금이기도 하다 |
|
||||
| `.bg-glow` | 광원 블롭 두 개. 섹션마다 `--glow-x` 로 자리를 옮긴다 | 재료 · 설치 | 히어로의 빛이 페이지를 관통해 마지막 CTA 에서 돌아온다 |
|
||||
기준면은 `BgPhoto` 컴포넌트가 만든다(아래 표).
|
||||
|
||||
격자는 가장자리를 마스크로 지운다 — 화면 끝까지 가면 배경이 아니라 **표**가 된다.
|
||||
배경이 가장 적게 움직인다. 앞의 것보다 크게 움직이면 앞으로 튀어나와 보인다.
|
||||
**CSS 격자를 한동안 같이 깔았다가 걷어냈다.** 사진이 들어온 뒤로 배경이 두 겹이 되어
|
||||
서로 경쟁했고, 격자는 배경이라기보다 **표**로 읽혔다. 배경도 한 섹션에 하나다.
|
||||
|
||||
**실측 3층** (스크롤 1000 → 1600): 격자 **+14.9px** · 산출물 **-14px** · 단계명 **0**.
|
||||
한 층은 반드시 고정이다. 전부 움직이면 기준이 없어 그냥 흔들리는 페이지가 된다.
|
||||
한 층은 반드시 고정이다 — 파이프라인의 단계명은 움직이지 않는다.
|
||||
전부 움직이면 기준이 없어 그냥 흔들리는 페이지가 된다.
|
||||
|
||||
### 패럴랙스를 "고정"으로 바꿨다
|
||||
|
||||
세 번 고친 뒤에도 티가 나지 않았다. 원인은 **방식 자체**였다 —
|
||||
느리게 같이 움직이는 것으로는 원경이 안 된다. 속도만 다를 뿐 방향이 같기 때문이다.
|
||||
|
||||
**배경을 뷰포트에 고정한다.** `background-attachment: fixed` 는 iOS Safari 가 throttle 하므로
|
||||
쓰지 않고, 섹션에 `clip-path: inset(0)` 을 걸어 **`position: fixed` 의 컨테이닝 블록**으로 만든다.
|
||||
그러면 배경이 화면에 멈춘 채 그 섹션 안에서만 보인다.
|
||||
|
||||
실측: 스크롤 1000px 구간에서 배경의 `viewportTop` 이 계속 **0**.
|
||||
|
||||
대가가 둘 있고 둘 다 받아들였다.
|
||||
|
||||
- **조상에 `transform`·`filter`·`mask` 를 못 쓴다.** 그것들도 컨테이닝 블록을 만들어
|
||||
고정을 깨뜨린다. 그래서 위아래 페이드 마스크를 포기하고 경계는 clip 으로 딱 자른다
|
||||
- `prefers-reduced-motion` 에서는 `position: absolute` 로 되돌린다
|
||||
|
||||
**전부 고정하지는 않았다.** 원경과 중경을 나눴다.
|
||||
|
||||
| 섹션 | 모드 | 왜 |
|
||||
|---|---|---|
|
||||
| 재료 · 설치 · 프리플라이트 | `fixed` | 원경. 콘텐츠만 그 위를 지나간다 |
|
||||
| 사례 | `drift` 0.4 | 두 결과물이 흐르는 자리라 배경도 같이 흐른다 |
|
||||
| 조판 | `drift` 0.25 | **종이는 손에 닿는 거리다.** 원경으로 두면 거짓말이 된다 |
|
||||
|
||||
### 유리 테두리는 conic 이 아니라 linear 로 돈다
|
||||
|
||||
헤더의 헤어라인이 **변마다 끊겨** 보였다. `conic-gradient` 로 만들었기 때문이다.
|
||||
conic 은 중심에서 각도로 도는데, 1180×54 처럼 납작한 형태에서는 각도가 급변해서
|
||||
긴 변에서 색이 거의 안 변하고 짧은 변에서 한꺼번에 바뀐다. 실제로 아래쪽만 절반 밝았다.
|
||||
|
||||
`linear-gradient(100deg, …)` 로 바꾸면 네 변이 같은 그라디언트를 공유해
|
||||
**테두리를 따라 이어진다.** 마스크로 1px 만 남기는 구조는 그대로다.
|
||||
|
||||
그리고 아주 얕은 글로우를 얹었다 — `0 0 22px -8px` 에 강조색을 42% 섞었다.
|
||||
유리가 차갑게만 보이지 않게 하는 정도이고, 밝은 밴드에서는 흰빛 대신
|
||||
그림자로 뒤집는다(`--glass-rim`·`--glass-bloom` 을 `.band-inverse` 에서 재정의).
|
||||
|
||||
### 테두리 광을 회전시켰다
|
||||
|
||||
정지한 헤어라인 위에 **도는 빛**을 얹었다. `conic` 의 각도를 `@property` 로 돌리면
|
||||
매 프레임 배경을 다시 칠하므로(하드 게이트 #8), **정사각형 원뿔 레이어를 마스크 안에서
|
||||
`rotate` 시킨다.** 움직이는 것은 transform 뿐이다.
|
||||
|
||||
`aspect-ratio: 1` 로 정사각형을 유지해야 회전 중 밝기가 일정하고,
|
||||
`width: 200%` 로 크게 잡아야 1178×52 짜리 헤더에서도 모서리까지 닿는다. 주기 11초.
|
||||
|
||||
### three.js 모바일 대응
|
||||
|
||||
**세로 화면에서 게이트가 통째로 안 보이고 있었다.** 수직 시야는 화면 비율과 무관하지만
|
||||
수평 시야는 aspect 에 비례한다 — 데스크톱(2.5:1)에서 정한 판 폭 15 가
|
||||
세로 화면(0.45:1)의 수평 시야 2.5 에 비하면 **여섯 배**라, 첫 판이 화면을 완전히 덮었다.
|
||||
|
||||
`fit()` 에서 `camera.aspect` 를 갱신할 때 판의 `scale.x` 도 같이 잡는다.
|
||||
|
||||
그리고 저사양 판정을 고쳤다. `hardwareConcurrency <= 4` 만 보면 **요즘 폰이 전부
|
||||
고사양으로 잡힌다**(8코어가 흔하다). `innerWidth < 760` 을 함께 본다 —
|
||||
좁은 화면은 대개 배터리로 돌고 열이 더 난다. DPR 도 1.5 → **1.25**,
|
||||
캔버스 불투명도도 0.7 → **0.45**(세로에서는 화면 전체를 덮으므로).
|
||||
|
||||
### three.js 씬 셋은 서로 다른 것이어야 한다
|
||||
|
||||
게이트 씬을 파이프라인으로 옮기면서 히어로에도 그대로 남겨뒀더니
|
||||
**같은 그림이 페이지에 두 번** 나왔다. 히어로를 다른 것으로 바꿨다.
|
||||
|
||||
| 자리 | 씬 | 무엇 |
|
||||
|---|---|---|
|
||||
| 히어로 | `shader-plane` 흐름 필드 | 노이즈를 두 번 접어 빛이 유리를 지난 결을 만든다. 공간을 만드는 **재질** |
|
||||
| 파이프라인 | `gate-scene` 유리판 여섯 장 | 스크롤하면 통과하고 그 단계가 켜진다. 의미를 가진 **주인공** |
|
||||
| 재료 | `shader-plane` 스테이지 | SVG 필터 타일이 앉는 무대 |
|
||||
|
||||
히어로에는 무거운 씬을 임포트하지 않는다 — `three.md` §0 이 권하는 대로
|
||||
**셰이더 플레인 하나**다. 파일 이름도 `hero-scene.ts` → `gate-scene.ts` 로 고쳤다.
|
||||
이름이 실제 용도와 다르면 다음 사람이 히어로 것인 줄 알고 고친다.
|
||||
|
||||
캔버스 배경 위 본문 대비 실측(스크린샷 픽셀): 최악 **8.46:1**(가장 밝은 중앙 여백),
|
||||
h1 자리 14.83:1.
|
||||
|
||||
> `readPixels` 로 재려다 전부 0 이 나왔다. `preserveDrawingBuffer: false` 면 버퍼가
|
||||
> 비워져 있다. **WebGL 위 대비는 스크린샷 픽셀로 재라.**
|
||||
|
||||
### 게이트 씬을 파이프라인으로 옮겼다
|
||||
|
||||
히어로에서 뺐던 유리판 여섯 장을 여기로 되살렸다. **같은 씬인데 자막이 필요 없어졌다.**
|
||||
|
||||
| | 히어로에 뒀을 때 | 파이프라인으로 옮긴 뒤 |
|
||||
|---|---|---|
|
||||
| 옆에 있는 것 | 헤드라인과 CTA | **여섯 단계의 실제 이름** |
|
||||
| 의미 전달 | `게이트 01 / 06` 판독기 — 자막 | 판을 통과하면 그 단계가 켜진다 |
|
||||
|
||||
캔버스는 `position: sticky; height: 100dvh` 로 화면에 머물고 섹션이 지나간다.
|
||||
**진행도는 섹션 높이가 아니라 (섹션 높이 − 뷰포트 높이)로 잰다.**
|
||||
섹션 높이로 나눴더니 캔버스가 사라진 뒤에도 진행이 남아 6단계 중 **3단계에서 멈췄다.**
|
||||
그리고 기준 요소는 캔버스가 아니라 섹션이다 — sticky 캔버스는 자기 rect 가 변하지 않는다.
|
||||
|
||||
캔버스 위 텍스트 대비 실측 **14.2:1**(캔버스 불투명도 0.7).
|
||||
|
||||
### 패럴랙스가 세 번 실패한 경위
|
||||
|
||||
**1. 이동량이 모자랐다.** 여유를 28% 잡아놓고 `±46px`(총 92px)만 움직였다.
|
||||
스크롤 1800px 구간에서 이동 비율 5% — 아무도 시차를 느끼지 못했다.
|
||||
공식은 **이미지를 컨테이너보다 크게 만들고 그 초과분 전체를 움직이는 것**이다.
|
||||
`depth 0.45` → 이미지 145%, 이동 `translateY(-31.03%)`. 같은 구간에서 **374px**.
|
||||
|
||||
**2. `overflow: hidden` 이 `view()` 타임라인을 죽이고 있었다.**
|
||||
`overflow: hidden` 은 스크롤 컨테이너를 만들고, 그러면 자식의 `view()` 가 문서가 아니라
|
||||
그 상자를 기준으로 잡힌다. 이미지는 상자 안에서 늘 같은 자리라 진행도가 고정된다.
|
||||
**콘솔 에러도 없고 computed 값도 정상이라** 두 스크롤 위치에서 `transform` 을 재보고서야
|
||||
이동 0px 인 것을 알았다. 잘라내는 일은 섹션의 `overflow: clip` 에 맡겼다 —
|
||||
`clip` 은 스크롤 컨테이너를 만들지 않는다.
|
||||
|
||||
**3. `opacity` 로 눌러 이미지를 지웠다.** 어두운 광원 사진을 `opacity: 0.5` 로 깔면
|
||||
검정도 빛도 같이 죽어 **있는지조차 보이지 않는다.** `mix-blend-mode: screen` 으로 바꿨다 —
|
||||
검정은 투명해지고 밝은 곳만 더해지므로 불투명도를 0.85~1.0 으로 유지해도 본문 대비가 안전하다.
|
||||
밝은 종이만 `normal` 이다.
|
||||
|
||||
**생성 프롬프트에도 같은 함정이 있었다.** "본문 뒤에 깔리니 어둡게"를 너무 강하게 넣어서
|
||||
안전한 이미지가 아니라 **없는 이미지**를 만들었다. 대비는 합성 방식으로 풀 문제였다.
|
||||
|
||||
### 배경 사진 넉 장 (gpt-image-2 로 생성)
|
||||
|
||||
CSS 그라디언트 광원만으로는 기준면이 약해서, 굴절과 그레인이 실제로 들어 있는 이미지를 넣었다.
|
||||
Codex CLI 의 내장 `image_gen`(gpt-image-2)을 ChatGPT 구독 인증으로 호출해 만들었다 — 스톡 이미지가 아니다.
|
||||
|
||||
**섹션마다 다른 그림을 쓴다.** 같은 배경을 전부에 깔면 그건 벽지고,
|
||||
모션에 적용한 규칙("섹션마다 다른 말을 해야 한다")이 배경에서만 면제될 이유가 없다.
|
||||
|
||||
| 이미지 | 무엇 | 어디 | 그 섹션의 주장 |
|
||||
|---|---|---|---|
|
||||
| `bg-refraction` | 겹친 유리판을 통과한 빛 | 재료 · 설치(flip) | 표면은 한 겹이 아니다 |
|
||||
| `bg-split` | 한 빔이 둘로 갈라진다 | 사례 | 같은 파이프라인, 다른 결과 |
|
||||
| `bg-align` | 정렬선 여럿 중 **하나가 어긋나 끊겨 있다** | 프리플라이트 | 자기 결과물을 남의 것처럼 본다 |
|
||||
| `bg-paper` | 무광 인쇄용지, 섬유와 데클 엣지 | 조판(밝은 밴드) | 한글이 종이 위에 있어야 한다 |
|
||||
|
||||
**격자를 쓰는 파이프라인·수치에는 사진을 넣지 않았다.** 거기서는 격자가 그 섹션의 주장이다.
|
||||
규칙·히어로도 그대로 뒀다 — 히어로에는 three.js 가 있고, 규칙 목록은 담백해야 한다.
|
||||
|
||||
프롬프트는 **이 페이지의 토큰 값을 그대로** 넣었다:
|
||||
|
||||
- 바탕 `#0B0E12` — 순흑도 네이비도 아니게
|
||||
- 광원 **둘만**: 좌상단 딥 틸 `#4FD6C4`, 우하단 채도 낮은 파랑 `#3D7BD6`.
|
||||
보라·마젠타·바이올렛·무지개·따뜻한 색 **금지** (슬롭 지문)
|
||||
- 프로스티드 글래스 판이 거의 옆에서 보이게 겹쳐, 가장자리만 얇게 빛나고 가운데는 거의 안 보이게
|
||||
- **대비를 아주 낮고 고르게** — "이 이미지는 흰 본문 텍스트 뒤에 깔린다. 활자와 경쟁할 만큼
|
||||
밝은 곳이 있으면 안 된다. 중앙 띠는 조용하고 어둡게, 밝기는 모서리 쪽에"
|
||||
- 미세 필름 그레인, 밴딩 없이
|
||||
- 금지: 사물·로고·글자·숫자·사람·렌즈 플레어·보케·딱딱한 도형·비네트
|
||||
|
||||
`components/BgPhoto.astro` 로 감쌌다. CSS `background-image` 가 아니라 `<Image loading="lazy">` 를
|
||||
절대배치한다 — background-image 로 깔면 **항상 받고 최적화도 안 된다.**
|
||||
같은 이미지를 설치 섹션에서는 `flip` 으로 좌우 반전해 같은 그림으로 읽히지 않게 했다.
|
||||
|
||||
소스 WebP: `refraction` 37KB · `split` 14KB · `align` 11KB · `paper` 112KB
|
||||
(종이만 큰 이유는 섬유 질감이 고주파라 압축이 안 붙기 때문이다)
|
||||
|
||||
빌드 결과 12개 파일 합계 **276KB**. 실제로는 뷰포트에 맞는 크기 하나씩만,
|
||||
그것도 `loading="lazy"` 로 받는다.
|
||||
|
||||
**절대배치 배경이 그리드 안에서 561px 로 잘렸다.** `.section` 은 subgrid 이고
|
||||
`.section > * { grid-column: main; }` 이 걸려 있는데, 절대배치 요소라도 그리드 영역이
|
||||
잡히면 **그 영역이 컨테이닝 블록**이 된다. 그래서 배경이 화면 가운데 561px 띠로만 깔렸고,
|
||||
`overflow: visible` 이라 위아래 14% 여유분이 이웃 섹션 위로 넘쳐 조판 섹션의 종이가
|
||||
프리플라이트 하단에 밝은 띠로 나타났다.
|
||||
`.bg-photo { grid-column: full }` + `.has-bg { overflow: clip }` 으로 고쳤다.
|
||||
**`position: absolute` 는 그리드에서 배치를 면제해주지 않는다.**
|
||||
|
||||
**codex 0.147.0 메모** — `codex-image` 스킬의 `extract_imagegen.py` 는 rollout JSONL 의
|
||||
`image_generation_call` base64 를 찾는데, 0.147.0 은 다시 **파일**로 저장한다
|
||||
(`~/.codex/generated_images/<sid>/exec-*.png`, 이벤트는 `custom_tool_call` + `image_generation_end`).
|
||||
그래서 추출이 fail-loud 로 죽는다 — 이미지는 실제로 생성돼 있다.
|
||||
그리고 `$imagegen` 은 **프롬프트 맨 앞**에 있어야 내장 도구가 발동한다.
|
||||
|
||||
## 그리드의 `wide` 트랙을 좁은 화면에서 닫는다
|
||||
|
||||
|
|
|
|||
BIN
apps/site/src/assets/bg/bg-align.webp
Normal file
BIN
apps/site/src/assets/bg/bg-align.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/site/src/assets/bg/bg-paper.webp
Normal file
BIN
apps/site/src/assets/bg/bg-paper.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
apps/site/src/assets/bg/bg-refraction.webp
Normal file
BIN
apps/site/src/assets/bg/bg-refraction.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
apps/site/src/assets/bg/bg-split.webp
Normal file
BIN
apps/site/src/assets/bg/bg-split.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
138
apps/site/src/components/BgPhoto.astro
Normal file
138
apps/site/src/components/BgPhoto.astro
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
import { Image } from "astro:assets";
|
||||
import refraction from "../assets/bg/bg-refraction.webp";
|
||||
import paper from "../assets/bg/bg-paper.webp";
|
||||
import split from "../assets/bg/bg-split.webp";
|
||||
import align from "../assets/bg/bg-align.webp";
|
||||
|
||||
/**
|
||||
* 배경은 섹션마다 다른 말을 해야 한다. 같은 그림을 전부에 깔면 그건 벽지다.
|
||||
* 넷 다 gpt-image-2 로 만들었고, 프롬프트에 이 페이지의 토큰 값을 그대로 넣었다.
|
||||
*/
|
||||
const SOURCES = { refraction, paper, split, align } as const;
|
||||
|
||||
interface Props {
|
||||
variant?: keyof typeof SOURCES;
|
||||
/**
|
||||
* `fixed` — 뷰포트에 고정. 콘텐츠만 그 위를 지나간다. 무한히 먼 원경.
|
||||
* `drift` — 스크롤보다 느리게 따라온다. 중경. 종이처럼 가까운 재질에 쓴다.
|
||||
*/
|
||||
mode?: "fixed" | "drift";
|
||||
/** drift 전용. 이미지를 컨테이너보다 이 비율만큼 키우고 그 초과분 전체를 움직인다 */
|
||||
depth?: number;
|
||||
opacity?: number;
|
||||
flip?: boolean;
|
||||
}
|
||||
const {
|
||||
variant = "refraction",
|
||||
mode = "fixed",
|
||||
depth = 0.35,
|
||||
opacity = 0.9,
|
||||
flip = false,
|
||||
} = Astro.props;
|
||||
const src = SOURCES[variant];
|
||||
|
||||
// 밝은 종이만 그대로 얹고, 어두운 광원 사진은 더하기 합성한다.
|
||||
const additive = variant !== "paper";
|
||||
|
||||
const scale = 1 + depth;
|
||||
const shift = -(depth / scale) * 100;
|
||||
---
|
||||
|
||||
{/*
|
||||
패럴랙스의 원형은 "배경이 고정되어 원경처럼 보이는 것"이다.
|
||||
느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 같은 방향이다.
|
||||
|
||||
`background-attachment: fixed` 는 쓰지 않는다. iOS Safari 가 GPU 메모리 때문에
|
||||
그것을 throttle 해서 스크롤 중 배경이 튀거나 리셋된다.
|
||||
대신 부모에 `clip-path: inset(0)` 을 걸어 **fixed 의 컨테이닝 블록**으로 만든다.
|
||||
그러면 배경은 뷰포트에 고정되면서 그 섹션 영역에서만 보인다.
|
||||
|
||||
주의: 조상에 `transform`·`filter`·`mask` 가 있으면 그게 컨테이닝 블록이 되어
|
||||
이 기법이 통째로 깨진다. 그래서 위아래 페이드 마스크를 포기했다 —
|
||||
마스크를 걸면 고정이 사라진다. 경계는 clip 으로 딱 자른다.
|
||||
*/}
|
||||
<div
|
||||
class="bg-photo"
|
||||
class:list={[mode, { flip, additive }]}
|
||||
aria-hidden="true"
|
||||
style={`--bg-opacity:${opacity}; --bg-h:${scale * 100}%; --bg-shift:${shift.toFixed(2)}%`}
|
||||
>
|
||||
<Image src={src} alt="" widths={[860, 1400, 1920]} sizes="100vw" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg-photo {
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
grid-column: full;
|
||||
}
|
||||
|
||||
/* ---- 원경: 뷰포트에 고정 ---------------------------------- */
|
||||
.fixed {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
.fixed :global(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
opacity: var(--bg-opacity);
|
||||
}
|
||||
|
||||
/* ---- 중경: 스크롤보다 느리게 ------------------------------ */
|
||||
.drift {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.drift :global(img) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
/* 컨테이너보다 크다. 이 초과분이 곧 움직일 수 있는 거리다 */
|
||||
height: var(--bg-h);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
opacity: var(--bg-opacity);
|
||||
}
|
||||
|
||||
/*
|
||||
어두운 배경 위의 광원 사진은 opacity 로 누르면 안 된다 — 빛까지 같이 죽는다.
|
||||
screen 은 검정을 투명하게 만들고 밝은 곳만 더한다.
|
||||
*/
|
||||
.additive :global(img) { mix-blend-mode: screen; }
|
||||
.flip :global(img) { transform: scaleX(-1); }
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
/* drift 만 움직인다. fixed 는 애초에 안 움직이는 것이 효과다 */
|
||||
.drift :global(img) {
|
||||
animation: bg-parallax linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
}
|
||||
.drift.flip :global(img) { animation-name: bg-parallax-flip; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bg-parallax {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(var(--bg-shift)); }
|
||||
}
|
||||
@keyframes bg-parallax-flip {
|
||||
from { transform: translateY(0) scaleX(-1); }
|
||||
to { transform: translateY(var(--bg-shift)) scaleX(-1); }
|
||||
}
|
||||
|
||||
/* 모션을 줄이는 설정에서는 고정 배경도 부담이 될 수 있다. 흐름에 되돌린다 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fixed { position: absolute; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.additive :global(img) { opacity: calc(var(--bg-opacity) * 0.9); }
|
||||
.bg-photo:not(.additive) :global(img) { opacity: calc(var(--bg-opacity) * 0.7); }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -162,12 +162,70 @@ const { c, ui } = Astro.props;
|
|||
|
||||
|
||||
<script>
|
||||
// 유리판이 느리게 흐르며 공간을 만든다. 배경이므로 스스로 설명하지 않는다.
|
||||
// 굴절은 렌더 타겟 한 장으로 만든다 — transmission 재질보다 훨씬 싸다.
|
||||
import { mountHeroScene } from "../lib/hero-scene";
|
||||
/*
|
||||
히어로 배경은 셰이더 플레인 하나다. 무거운 씬을 임포트하지 않는다 —
|
||||
references/three.md §0 이 권하는 그 구조다.
|
||||
|
||||
한때 여기에도 게이트 씬(유리판 여섯 장)을 깔았는데, 그러면 같은 그림이
|
||||
파이프라인 섹션과 페이지에 두 번 나온다. 게이트 은유는 옆에 여섯 단계가
|
||||
적힌 그 섹션의 것이고, 여기는 공간을 만드는 재질이면 된다.
|
||||
*/
|
||||
import { mountShaderPlane } from "../lib/shader-plane";
|
||||
|
||||
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
|
||||
|
||||
if (canvas) {
|
||||
mountHeroScene({ canvas, accentVar: "--accent", glowVar: "--glow-2" });
|
||||
mountShaderPlane({
|
||||
canvas,
|
||||
pointer: true,
|
||||
speed: 0.5,
|
||||
colors: { uA: "--accent", uB: "--glow-2" },
|
||||
fragment: `
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform float uTime; uniform float uAspect;
|
||||
uniform vec2 uPointer;
|
||||
uniform vec3 uA; uniform vec3 uB;
|
||||
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
|
||||
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
|
||||
}
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0, a = 0.5;
|
||||
for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
|
||||
return v;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 p = vUv; p.x *= uAspect;
|
||||
float t = uTime * 0.06;
|
||||
vec2 m = uPointer; m.x *= uAspect;
|
||||
|
||||
// 흐름 필드. 두 번 접어서 빛이 유리를 지난 것처럼 늘어지게 만든다.
|
||||
vec2 q = vec2(fbm(p * 1.5 + t), fbm(p * 1.5 - t * 0.8 + 4.2));
|
||||
vec2 r = vec2(fbm(p * 2.1 + q * 1.6 + t * 0.5),
|
||||
fbm(p * 2.1 + q * 1.6 - t * 0.4 + 1.7));
|
||||
float f = fbm(p * 1.2 + r * 1.9);
|
||||
|
||||
// 광원 둘. 포인터를 아주 조금만 따라간다.
|
||||
vec2 c1 = mix(vec2(0.30 * uAspect, 0.58), m, 0.14);
|
||||
vec2 c2 = vec2(0.80 * uAspect, 0.34);
|
||||
|
||||
float a = smoothstep(1.00, 0.06, distance(p, c1) + f * 0.42);
|
||||
float b = smoothstep(0.92, 0.05, distance(p, c2) + r.y * 0.38);
|
||||
|
||||
// 결이 지나간 자리에 얇은 선이 선다. 유리 가장자리의 굴절 대신이다.
|
||||
float streak = smoothstep(0.48, 0.52, f) - smoothstep(0.52, 0.58, f);
|
||||
|
||||
vec3 col = uA * a + uB * b * 0.9 + (uA + uB) * 0.5 * streak * 0.16;
|
||||
float alpha = clamp(a * 1.05 + b * 0.95 + streak * 0.2, 0.0, 1.0) * 0.8;
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import CopyCommand from "./CopyCommand.astro";
|
||||
import BgPhoto from "./BgPhoto.astro";
|
||||
import type { Content } from "../i18n/content";
|
||||
|
||||
interface Props { c: Content["install"]; ui: Content["ui"]; }
|
||||
|
|
@ -7,7 +8,8 @@ const { c, ui } = Astro.props;
|
|||
---
|
||||
|
||||
|
||||
<section id="install" class="full install bg-glow" style="--glow-x: 30%" aria-labelledby="install-h">
|
||||
<section id="install" class="full install has-bg" aria-labelledby="install-h">
|
||||
<BgPhoto variant="refraction" mode="fixed" opacity={0.85} flip />
|
||||
<div class="install-grid">
|
||||
<div class="install-head">
|
||||
<h2 id="install-h">{c.h2}</h2>
|
||||
|
|
@ -39,10 +41,25 @@ const { c, ui } = Astro.props;
|
|||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터를 없앴으므로 출처와 라이선스는 여기 한 줄로 남는다.
|
||||
링크 하나 없이 배포하면 사용자가 소스를 찾을 방법이 사라진다. */}
|
||||
<p class="install-colophon muted">
|
||||
<a href={`https://${c.colophon.repo}`}>{c.colophon.repo}</a>
|
||||
<span aria-hidden="true"> · </span>{c.colophon.license}
|
||||
<span aria-hidden="true"> · </span>{c.colophon.made}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.install { padding-block: var(--space-8); }
|
||||
.install-colophon {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--step--1);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.install-grid {
|
||||
max-width: 1180px;
|
||||
margin-inline: auto;
|
||||
|
|
@ -78,6 +95,13 @@ const { c, ui } = Astro.props;
|
|||
dd { margin: 0; font-size: var(--step-0); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.install-colophon {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--step--1);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.install-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
---
|
||||
import BgPhoto from "./BgPhoto.astro";
|
||||
import type { Content } from "../i18n/content";
|
||||
|
||||
interface Props { c: Content["materials"]; }
|
||||
|
|
@ -10,11 +11,13 @@ const { c } = Astro.props;
|
|||
배경은 three.js 셰이더 플레인, 전경은 SVG 필터를 실제로 먹인 타일 3장.
|
||||
둘 다 설명이 아니라 지금 이 화면에서 돌고 있는 것이다.
|
||||
*/}
|
||||
<section class="section materials bg-glow" style="--glow-x: 70%" aria-labelledby="mat-h">
|
||||
<section class="section materials has-bg" aria-labelledby="mat-h">
|
||||
<BgPhoto variant="refraction" mode="fixed" opacity={0.9} />
|
||||
<h2 id="mat-h" class="reveal">{c.h2}</h2>
|
||||
<p class="lead mat-lead reveal">{c.lead}</p>
|
||||
|
||||
<div class="full stage glass par-slow">
|
||||
<span class="rim" aria-hidden="true"></span>
|
||||
<div class="stage-fallback" aria-hidden="true"></div>
|
||||
{/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
|
||||
<canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const rest = c.items.filter((m) => !m.feature);
|
|||
흔들리면 그 값도 흔들리는 것처럼 읽힌다. 계기판의 바늘은 떨지 않는다.
|
||||
움직임이 의미를 만드는 자리가 있으면, 움직이지 않는 것이 의미인 자리도 있다.
|
||||
*/}
|
||||
<section id="metrics" class="section metrics bg-grid" aria-labelledby="metrics-h">
|
||||
<section id="metrics" class="section metrics" aria-labelledby="metrics-h">
|
||||
<div class="head">
|
||||
<p class="eyebrow reveal">{c.eyebrow}</p>
|
||||
<h2 id="metrics-h" class="reveal">{c.h2}</h2>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,15 @@ const { c } = Astro.props;
|
|||
③ 의미 — <ol role="list"> — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다
|
||||
role="list" 는 list-style:none 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 되돌린다
|
||||
*/}
|
||||
<section id="pipeline" class="section bg-grid" aria-labelledby="pipeline-h">
|
||||
<section id="pipeline" class="section has-bg" aria-labelledby="pipeline-h">
|
||||
{/* 히어로에 있던 게이트 씬을 여기로 옮겼다.
|
||||
히어로에서는 그 은유를 판독기가 자막으로 설명해야 했다 — 그림이 스스로
|
||||
말하지 못한다는 뜻이다. 여기서는 옆에 실제 여섯 단계가 적혀 있고,
|
||||
판을 통과할 때 그 단계가 켜진다. 자막이 아니라 콘텐츠가 반응한다. */}
|
||||
<div class="gate-bg" aria-hidden="true">
|
||||
<div class="gate-fallback"></div>
|
||||
<canvas id="pipeline-canvas" class="gate-canvas"></canvas>
|
||||
</div>
|
||||
{/* 제목·리드·브리프·스파인을 한 컨테이너에 담는다.
|
||||
섹션 기본 그리드에 두면 제목은 main 칼럼의 음수 마진으로 x=371,
|
||||
리드는 x=435, 스파인은 x=143 에 서서 정렬선이 셋이 된다. */}
|
||||
|
|
@ -30,8 +38,8 @@ const { c } = Astro.props;
|
|||
|
||||
<ol role="list" class="spine">
|
||||
{
|
||||
c.stages.map((s) => (
|
||||
<li class="stop reveal">
|
||||
c.stages.map((s, i) => (
|
||||
<li class="stop reveal" data-stage={i}>
|
||||
<div class="stop-head">
|
||||
<h3 class="stop-name">{s.name}</h3>
|
||||
<p class="stop-does">{s.does}</p>
|
||||
|
|
@ -54,6 +62,48 @@ const { c } = Astro.props;
|
|||
.brief { max-width: var(--measure); }
|
||||
#pipeline-h { max-width: 24ch; }
|
||||
|
||||
/* ---- 게이트 배경 ------------------------------------------
|
||||
sticky 로 화면에 머물고 음수 마진으로 레이아웃 자리는 차지하지 않는다.
|
||||
섹션이 지나가는 동안 카메라가 판 여섯 장을 통과한다. */
|
||||
.gate-bg {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
margin-bottom: -100dvh;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
grid-column: full;
|
||||
}
|
||||
.gate-fallback,
|
||||
.gate-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.gate-fallback {
|
||||
background:
|
||||
radial-gradient(46% 54% at 28% 34%,
|
||||
color-mix(in oklab, var(--accent) 20%, transparent), transparent 70%),
|
||||
radial-gradient(42% 50% at 76% 68%,
|
||||
color-mix(in oklab, var(--glow-2) 18%, transparent), transparent 68%);
|
||||
}
|
||||
.gate-canvas { opacity: 0; transition: opacity 1s var(--ease-soft); }
|
||||
.gate-canvas.is-live { opacity: 0.7; }
|
||||
/* 세로 화면에서는 캔버스가 화면 전체를 덮는다. 같은 밝기면 본문이 묻힌다 */
|
||||
@media (max-width: 760px) {
|
||||
.gate-canvas.is-live { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* 통과한 단계가 켜진다. 3D 와 텍스트를 잇는 유일한 연결이다 */
|
||||
.stop { transition: opacity var(--dur-quick) var(--ease-out); }
|
||||
.stop.is-active .stop-name { color: var(--accent); }
|
||||
.stop.is-active::before {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* 스파인은 페이지의 본체다. main 칼럼(561px)에 가두면
|
||||
페이지에서 가장 긴 섹션이 가장 좁아진다.
|
||||
R1(zed.dev) 실측: 컨테이너 1120px 과 전체폭이 교대한다. */
|
||||
|
|
@ -173,3 +223,28 @@ const { c } = Astro.props;
|
|||
이 섹션은 페이지의 본체다. 콘텐츠를 걸고 하는 연출은 하지 않는다. */
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 게이트를 통과할 때 해당 단계를 켠다. 값이 바뀔 때만 DOM 을 쓴다.
|
||||
import { mountGateScene } from "../lib/gate-scene";
|
||||
|
||||
const canvas = document.getElementById("pipeline-canvas") as HTMLCanvasElement | null;
|
||||
const section = document.getElementById("pipeline");
|
||||
const stops = [...document.querySelectorAll<HTMLElement>("#pipeline .stop")];
|
||||
|
||||
if (canvas && section && stops.length) {
|
||||
let last = -1;
|
||||
mountGateScene({
|
||||
canvas,
|
||||
accentVar: "--accent",
|
||||
glowVar: "--glow-2",
|
||||
// 캔버스는 sticky 라 rect 가 변하지 않는다. 진행도는 섹션에서 읽는다.
|
||||
progressFrom: section,
|
||||
onGate(index) {
|
||||
if (index === last) return;
|
||||
last = index;
|
||||
stops.forEach((el, i) => el.classList.toggle("is-active", i === index));
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
---
|
||||
import BgPhoto from "./BgPhoto.astro";
|
||||
import type { Content } from "../i18n/content";
|
||||
|
||||
interface Props { c: Content["preflight"]; }
|
||||
|
|
@ -10,7 +11,8 @@ const { c } = Astro.props;
|
|||
걸린 것과 고친 것이 한 줄에서 이어진다. 상자에 담지 않는다.
|
||||
왼쪽 세로선이 로그라는 형식을 만든다.
|
||||
*/}
|
||||
<section class="section preflight" aria-labelledby="pf-h">
|
||||
<section class="section preflight has-bg" aria-labelledby="pf-h">
|
||||
<BgPhoto variant="align" mode="fixed" opacity={1} />
|
||||
<h2 id="pf-h" class="reveal">{c.h2}</h2>
|
||||
<p class="lead pf-lead reveal">{c.lead}</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { Image } from "astro:assets";
|
||||
import shotCoffee from "../assets/shots/shot-coffee.png";
|
||||
import shotSaas from "../assets/shots/shot-saas.png";
|
||||
import BgPhoto from "./BgPhoto.astro";
|
||||
import type { Content } from "../i18n/content";
|
||||
|
||||
interface Props { c: Content["showcase"]; }
|
||||
|
|
@ -10,7 +11,8 @@ const shots = [shotCoffee, shotSaas];
|
|||
---
|
||||
|
||||
{/* 레이아웃 패밀리: Z축 캐스케이드. 두 판이 서로 겹치고 각도가 다르다 */}
|
||||
<section id="showcase" class="section showcase" aria-labelledby="showcase-h">
|
||||
<section id="showcase" class="section showcase has-bg" aria-labelledby="showcase-h">
|
||||
<BgPhoto variant="split" mode="drift" depth={0.4} opacity={0.85} />
|
||||
<h2 id="showcase-h" class="reveal">{c.h2}</h2>
|
||||
<p class="lead showcase-lead reveal">{c.lead}</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
---
|
||||
import BgPhoto from "./BgPhoto.astro";
|
||||
import type { Content } from "../i18n/content";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -7,7 +8,8 @@ interface Props {
|
|||
const { c } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="section band-inverse" aria-labelledby="type-h">
|
||||
<section class="section band-inverse has-bg" aria-labelledby="type-h">
|
||||
<BgPhoto variant="paper" mode="drift" depth={0.25} opacity={1} />
|
||||
<p class="eyebrow reveal">{c.eyebrow}</p>
|
||||
<h2 id="type-h" class="reveal">{c.h2}</h2>
|
||||
<p class="lead type-lead reveal">{c.lead}</p>
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ export interface Content {
|
|||
command: string;
|
||||
targets: { name: string; path: string }[];
|
||||
commands: { cmd: string; what: string }[];
|
||||
colophon: { made: string; repo: string; license: string };
|
||||
};
|
||||
footer: { made: string; repo: string; license: string };
|
||||
nav: { items: { label: string; href: string }[]; menu: string };
|
||||
ui: { copy: string; copied: string };
|
||||
}
|
||||
|
|
@ -377,12 +377,12 @@ export const ko: Content = {
|
|||
{ cmd: "npx designpaca doctor", what: "설치 상태와 드리프트 진단" },
|
||||
{ cmd: "npx designpaca uninstall", what: "매니페스트 기반 정확한 제거" },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
made: "이 페이지는 designpaca 로 만들어졌다.",
|
||||
colophon: {
|
||||
made: "designpaca 로 만들었다",
|
||||
repo: "git.chanpaca.net/yunchan/designpaca",
|
||||
license: "MIT",
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
items: [
|
||||
{ label: "파이프라인", href: "#pipeline" },
|
||||
|
|
@ -612,12 +612,12 @@ export const en: Content = {
|
|||
{ cmd: "npx designpaca doctor", what: "Diagnose install state and drift" },
|
||||
{ cmd: "npx designpaca uninstall", what: "Exact removal, driven by the manifest" },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
made: "This page was built with designpaca.",
|
||||
colophon: {
|
||||
made: "Built with designpaca",
|
||||
repo: "git.chanpaca.net/yunchan/designpaca",
|
||||
license: "MIT",
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
items: [
|
||||
{ label: "Pipeline", href: "#pipeline" },
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const FAVICON =
|
|||
|
||||
<header class="site-header">
|
||||
<div class="header-inner glass">
|
||||
<span class="rim" aria-hidden="true"></span>
|
||||
<a class="wordmark" href={lang === "ko" ? "/" : "/en/"} aria-label="designpaca">
|
||||
<svg class="mark" viewBox="0 0 32 32" width="20" height="20" aria-hidden="true">
|
||||
<path d="M11 7v18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
|
||||
|
|
@ -132,9 +133,6 @@ const FAVICON =
|
|||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="page site-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
|
||||
<script is:inline>
|
||||
// 진입 연출. 한 번 보이면 계속 보인다.
|
||||
|
|
@ -274,9 +272,4 @@ const FAVICON =
|
|||
.nav-mobile { display: none; }
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding-block: var(--space-6);
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,20 @@
|
|||
/**
|
||||
* 히어로 3D 씬 — 파이프라인 게이트.
|
||||
* 게이트 씬 — 유리판 여섯 장이 깊이 축에 서고, 스크롤하면 카메라가 통과한다.
|
||||
*
|
||||
* 유리 판 여섯 장이 깊이 축에 차례로 서 있다. 이 스킬의 여섯 단계다.
|
||||
* 스크롤하면 카메라가 판을 하나씩 통과하고, 지나온 판은 뒤에 남아 겹친다.
|
||||
* 파이프라인 섹션 전용이다. 한때 히어로 배경으로도 썼는데, 그러면 같은 그림이
|
||||
* 페이지에 두 번 나온다. 그리고 히어로에는 옆에서 반응할 콘텐츠가 없어서
|
||||
* 의미를 판독기가 자막으로 설명해야 했다 — 그림이 스스로 말하지 못한다는 뜻이다.
|
||||
* 히어로 배경은 `shader-plane.ts` 의 흐름 필드가 맡는다.
|
||||
*
|
||||
* 형태에 이유가 있다. 이 제품이 파는 것은 순서이고("순서를 바꾸지 마라"가
|
||||
* 절대 규칙이다), 순서를 깊이로 옮기는 것은 은유가 아니라 직역이다.
|
||||
* 판이 여섯인 이유도 여기서 나온다. 아래 Pipeline 섹션의 여섯 단계와 같은 수다.
|
||||
*
|
||||
* 조사 결론(research/three/08-proposal.md): 우리가 파는 것은 무형이라
|
||||
* 구체 오브젝트가 성립하지 않는다. GLB 는 쓰지 않는다. 지오메트리는 코드다.
|
||||
*
|
||||
* 구현 방식과 그 이유:
|
||||
* 배경 그라디언트를 렌더 타겟에 한 번 그리고, 유리 판이 그 텍스처를
|
||||
* UV 를 밀어서 샘플링한다. MeshPhysicalMaterial 의 transmission 은
|
||||
* 매 프레임 씬을 다시 렌더하므로 판 한 장만 있어도 비싸다.
|
||||
* references/three.md 가 그 비용을 경고한다. 렌더 타겟 한 장이면
|
||||
* 같은 인상을 훨씬 싸게 얻는다.
|
||||
*
|
||||
* 예산 규칙은 그대로 지킨다:
|
||||
* 성능 게이트는 references/three.md 그대로다.
|
||||
* - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
|
||||
* - DPR 클램프 1.5
|
||||
* - DPR 클램프 1.5. 저사양이면 판을 여섯에서 셋으로 줄인다
|
||||
* - 화면 밖이거나 탭이 숨으면 그리지 않는다
|
||||
* - 저사양이면 판 수를 줄인다
|
||||
* - WebGL 이 없거나 모션을 줄이는 설정이면 아예 시작하지 않는다.
|
||||
* 그때 보이는 CSS 그라디언트가 폴백이 아니라 결과물이다
|
||||
*/
|
||||
|
||||
export interface HeroSceneOptions {
|
||||
export interface GateSceneOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** CSS 커스텀 프로퍼티 이름 */
|
||||
accentVar: string;
|
||||
|
|
@ -38,6 +25,12 @@ export interface HeroSceneOptions {
|
|||
* WebGL 은 셰이더만 만진다. 매 프레임 DOM 을 쓰면 그 경계가 무너진다.
|
||||
*/
|
||||
onGate?: (index: number, total: number) => void;
|
||||
/**
|
||||
* 스크롤 진행도를 이 요소로 잰다. 기본은 캔버스 자신.
|
||||
* 캔버스를 sticky/fixed 로 화면에 붙여두면 캔버스의 rect 는 변하지 않으므로
|
||||
* 진행도가 고정된다 — 그때는 실제로 지나가는 섹션을 넘겨야 한다.
|
||||
*/
|
||||
progressFrom?: HTMLElement;
|
||||
}
|
||||
|
||||
const NOISE = `
|
||||
|
|
@ -55,7 +48,7 @@ const NOISE = `
|
|||
}
|
||||
`;
|
||||
|
||||
export function mountHeroScene(opts: HeroSceneOptions): void {
|
||||
export function mountGateScene(opts: GateSceneOptions): void {
|
||||
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const { canvas } = opts;
|
||||
|
||||
|
|
@ -70,7 +63,7 @@ export function mountHeroScene(opts: HeroSceneOptions): void {
|
|||
io.observe(canvas);
|
||||
}
|
||||
|
||||
async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): Promise<void> {
|
||||
async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: GateSceneOptions): Promise<void> {
|
||||
let THREE: typeof import("three");
|
||||
try {
|
||||
THREE = await import("three");
|
||||
|
|
@ -91,11 +84,15 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
|
|||
const accent = color(accentVar, "#4FD6C4");
|
||||
const glow = color(glowVar, "#6E7BFF");
|
||||
|
||||
// 저사양이면 판을 줄인다. 코어 수는 거친 지표지만 라이브러리를 더 받지 않는다
|
||||
const lean = (navigator.hardwareConcurrency ?? 4) <= 4;
|
||||
// 저사양 판정.
|
||||
// 코어 수만 보면 요즘 폰(8코어)이 전부 고사양으로 잡힌다. 화면 폭을 같이 본다 —
|
||||
// 좁은 화면은 대개 배터리로 돌아가고, 같은 씬이라도 열이 더 난다.
|
||||
const narrow = innerWidth < 760;
|
||||
const lean = narrow || (navigator.hardwareConcurrency ?? 4) <= 4;
|
||||
const PANE_COUNT = lean ? 3 : 6;
|
||||
|
||||
const dpr = Math.min(devicePixelRatio, 1.5);
|
||||
// 좁은 화면에서는 DPR 도 한 단계 더 내린다. 폰의 DPR 은 3 까지 간다.
|
||||
const dpr = Math.min(devicePixelRatio, narrow ? 1.25 : 1.5);
|
||||
renderer.setPixelRatio(dpr);
|
||||
|
||||
/* ---- 1) 배경을 렌더 타겟에 그린다 ------------------------- */
|
||||
|
|
@ -258,6 +255,20 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
|
|||
const pointer = new THREE.Vector2(0.5, 0.5);
|
||||
const pointerTarget = new THREE.Vector2(0.5, 0.5);
|
||||
|
||||
// 판이 화면을 덮는 데 필요한 최소 폭.
|
||||
// 수직 시야는 화면 비율과 무관하지만 수평 시야는 aspect 에 비례한다.
|
||||
// 데스크톱(2.5:1)에 맞춘 폭 15 를 세로 화면(0.45:1)에 그대로 쓰면
|
||||
// 수평 시야의 여섯 배가 되어 첫 판이 화면을 완전히 덮는다 —
|
||||
// 게이트가 보이지 않고 그냥 밝은 화면이 된다.
|
||||
const GEO_W = 15;
|
||||
const fitPanes = (aspect: number) => {
|
||||
const halfH = Math.tan((camera.fov * Math.PI) / 360);
|
||||
const viewW = 2 * 7.2 * halfH * aspect;
|
||||
const want = Math.max(6.5, viewW * 1.12);
|
||||
const sx = Math.min(1, want / GEO_W);
|
||||
for (const m of panes) m.scale.x = sx;
|
||||
};
|
||||
|
||||
const fit = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return;
|
||||
|
|
@ -269,6 +280,7 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
|
|||
camera.aspect = r.width / r.height;
|
||||
camera.updateProjectionMatrix();
|
||||
bgUniforms.uAspect.value = r.width / r.height;
|
||||
fitPanes(camera.aspect);
|
||||
};
|
||||
fit();
|
||||
// window resize 만 듣던 것을 ResizeObserver 로 바꾼다.
|
||||
|
|
@ -305,10 +317,15 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
|
|||
|
||||
// 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
|
||||
// rect 는 프레임당 한 번만 읽는다. 두 번 읽으면 레이아웃을 두 번 강제한다.
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// 액자가 화면 위로 92% 지나갔을 때 마지막 게이트를 통과하게 잡았다.
|
||||
// 그 뒤로는 액자가 보이지 않으므로 계속 진행시킬 이유가 없다.
|
||||
const travel = rect.height * 0.92;
|
||||
const rect = (progressFrom ?? canvas).getBoundingClientRect();
|
||||
// 진행 거리.
|
||||
// progressFrom 이 있으면 캔버스는 sticky 로 화면에 붙어 있고 섹션이 지나간다.
|
||||
// 이때 캔버스가 실제로 화면에 머무는 거리는 (섹션 높이 - 뷰포트 높이)다.
|
||||
// 섹션 높이를 그대로 쓰면 캔버스가 사라진 뒤에도 진행이 남아 마지막 게이트에
|
||||
// 영영 도달하지 못한다 — 실측에서 6단계 중 3단계에서 멈췄다.
|
||||
const travel = progressFrom
|
||||
? Math.max(rect.height - innerHeight, 1)
|
||||
: rect.height * 0.92;
|
||||
const progress = Math.min(Math.max(-rect.top / travel, 0), 1);
|
||||
|
||||
bgUniforms.uProgress.value = progress;
|
||||
|
|
@ -23,10 +23,4 @@ import { en as c } from "../../i18n/content";
|
|||
<Rules c={c.rules} />
|
||||
<Install c={c.install} ui={c.ui} />
|
||||
|
||||
<Fragment slot="footer">
|
||||
<p>{c.footer.made}</p>
|
||||
<p class="mono">
|
||||
<a href={`https://${c.footer.repo}`}>{c.footer.repo}</a> · {c.footer.license}
|
||||
</p>
|
||||
</Fragment>
|
||||
</Base>
|
||||
|
|
|
|||
|
|
@ -23,10 +23,4 @@ import { ko as c } from "../i18n/content";
|
|||
<Rules c={c.rules} />
|
||||
<Install c={c.install} ui={c.ui} />
|
||||
|
||||
<Fragment slot="footer">
|
||||
<p>{c.footer.made}</p>
|
||||
<p class="mono">
|
||||
<a href={`https://${c.footer.repo}`}>{c.footer.repo}</a> · {c.footer.license}
|
||||
</p>
|
||||
</Fragment>
|
||||
</Base>
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ a:hover { color: var(--accent); }
|
|||
background: var(--glass-tint);
|
||||
border: 1px solid var(--glass-edge);
|
||||
border-radius: var(--radius-frame);
|
||||
box-shadow: var(--glass-hi), var(--shadow-2);
|
||||
box-shadow: var(--glass-hi), var(--glass-bloom), var(--shadow-2);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
|
||||
}
|
||||
|
|
@ -247,6 +247,54 @@ a:hover { color: var(--accent); }
|
|||
(실측: computed backdrop-filter 가 none 이 된다).
|
||||
굴절은 재료 섹션의 SVG 타일이 진짜로 보여준다. 여기서는 두께를 만든다. */
|
||||
|
||||
/* ---- 테두리를 도는 빛 --------------------------------------
|
||||
`conic-gradient` 의 각도를 `@property` 로 애니메이션하면 매 프레임 배경을
|
||||
다시 칠한다 — 컴포지터에서 못 돌고 메인 스레드가 페인트한다(하드 게이트 #8).
|
||||
|
||||
대신 **정사각형 원뿔 레이어를 마스크 안에서 회전**시킨다. 움직이는 것은
|
||||
`rotate` 뿐이라 GPU 에서 돈다. 정사각형이라 회전해도 밝기가 균일하고,
|
||||
요소보다 크게(200%) 잡아 납작한 헤더에서도 모서리까지 닿는다. */
|
||||
.rim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
padding: 1px;
|
||||
-webkit-mask:
|
||||
linear-gradient(black 0 0) content-box,
|
||||
linear-gradient(black 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(black 0 0) content-box,
|
||||
linear-gradient(black 0 0);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
.rim::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 200%;
|
||||
aspect-ratio: 1;
|
||||
translate: -50% -50%;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0 58%,
|
||||
color-mix(in oklab, var(--accent) 70%, white) 74%,
|
||||
transparent 86%
|
||||
);
|
||||
opacity: 0.55;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.rim::before {
|
||||
animation: rim-spin 11s linear infinite;
|
||||
}
|
||||
}
|
||||
@keyframes rim-spin {
|
||||
to { rotate: 360deg; }
|
||||
}
|
||||
|
||||
/* 가장자리를 도는 빛. 유리판 테두리의 굴절을 흉내낸다 */
|
||||
.glass::after {
|
||||
content: "";
|
||||
|
|
@ -419,55 +467,27 @@ a:hover { color: var(--accent); }
|
|||
패럴랙스는 깊이 차가 보여야 성립한다. 배경이 단색이면 앞의 것이
|
||||
몇 px 어긋나 봐야 아무도 못 느낀다 — 기준면이 없기 때문이다.
|
||||
|
||||
그래서 배경을 깐다. 다만 사 온 이미지가 아니라 이 페이지가 주장하는 것,
|
||||
곧 간격 눈금 자체를 격자로 그린다. --grid-cell 은 --space-5 다. */
|
||||
.bg-grid,
|
||||
.bg-glow {
|
||||
기준면은 `BgPhoto` 컴포넌트가 만든다.
|
||||
|
||||
CSS 로 그린 격자를 한동안 같이 깔았는데 걷어냈다. 사진이 들어온 뒤로는
|
||||
배경이 두 겹이 되어 서로 경쟁했고, 격자는 배경이라기보다 표로 읽혔다.
|
||||
배경도 한 섹션에 하나여야 한다. */
|
||||
.has-bg {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.bg-grid::before,
|
||||
.bg-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -12% 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* `clip-path: inset(0)` 는 두 가지를 동시에 한다.
|
||||
1) 배경이 섹션 밖으로 넘치지 않게 자른다
|
||||
2) `position: fixed` 자식의 **컨테이닝 블록**이 된다
|
||||
|
||||
.bg-grid::before {
|
||||
background-image:
|
||||
repeating-linear-gradient(to right,
|
||||
var(--line-strong) 0 1px, transparent 1px var(--grid-cell)),
|
||||
repeating-linear-gradient(to bottom,
|
||||
var(--line-strong) 0 1px, transparent 1px var(--grid-cell));
|
||||
/* 가장자리에서 지운다. 화면 끝까지 격자가 가면 표가 되어버린다 */
|
||||
-webkit-mask-image: radial-gradient(135% 95% at 50% 45%, black 22%, transparent 92%);
|
||||
mask-image: radial-gradient(135% 95% at 50% 45%, black 22%, transparent 92%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
2번이 핵심이다. 그래서 배경이 뷰포트에 고정되면서도 이 섹션 안에서만 보인다 —
|
||||
콘텐츠만 그 위를 지나가고 배경은 원경처럼 멈춰 있다.
|
||||
|
||||
/* 히어로의 두 광원이 페이지를 관통한다. 섹션마다 자리가 다르다 */
|
||||
.bg-glow::before {
|
||||
background:
|
||||
radial-gradient(42% 46% at var(--glow-x, 78%) var(--glow-y, 32%),
|
||||
color-mix(in oklab, var(--accent) 15%, transparent), transparent 70%),
|
||||
radial-gradient(38% 42% at calc(100% - var(--glow-x, 78%)) 78%,
|
||||
color-mix(in oklab, var(--glow-2) 13%, transparent), transparent 68%);
|
||||
}
|
||||
`background-attachment: fixed` 를 쓰지 않은 이유는 iOS Safari 가 GPU 메모리
|
||||
때문에 그것을 throttle 해서 스크롤 중 배경이 튀기 때문이다.
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
/* 배경이 가장 느리다. 콘텐츠보다 적게 움직여야 뒤에 있는 것으로 읽힌다 */
|
||||
.bg-grid::before { --par-from: -34px; --par-to: 34px; }
|
||||
.bg-glow::before { --par-from: -58px; --par-to: 58px; }
|
||||
.bg-grid::before,
|
||||
.bg-glow::before {
|
||||
animation: par linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
}
|
||||
}
|
||||
주의: 여기에 `transform`·`filter`·`mask` 를 추가하면 안 된다. 그것들도
|
||||
컨테이닝 블록을 만들지만, 그 순간 fixed 가 섹션 기준이 되어 고정이 사라진다. */
|
||||
clip-path: inset(0);
|
||||
}
|
||||
|
||||
/* ---- 유틸 -------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -65,14 +65,19 @@
|
|||
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%
|
||||
/* 테두리 광.
|
||||
conic-gradient 를 쓰면 납작한 형태(예: 1180x54 헤더)에서 각도가 급변해
|
||||
변마다 광이 뚝 끊긴다 — 실제로 헤더 아래쪽만 절반 밝았다.
|
||||
선형으로 바꾸면 네 변이 같은 그라디언트를 공유해 테두리를 따라 이어진다. */
|
||||
--glass-rim: linear-gradient(
|
||||
100deg,
|
||||
rgb(255 255 255 / 0.34) 0%,
|
||||
rgb(255 255 255 / 0.10) 26%,
|
||||
rgb(255 255 255 / 0.06) 58%,
|
||||
rgb(255 255 255 / 0.28) 100%
|
||||
);
|
||||
/* 테두리에서 아주 얕게 번지는 빛. 강조색을 실어 유리가 차갑게만 보이지 않게 한다 */
|
||||
--glass-bloom: 0 0 22px -8px color-mix(in oklab, var(--accent) 42%, transparent);
|
||||
--core-hi: inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||
|
||||
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.5);
|
||||
|
|
@ -236,6 +241,15 @@
|
|||
--glass-edge: rgb(20 24 30 / 0.12);
|
||||
--glass-hi: inset 0 1px 0 rgb(255 255 255 / 0.8);
|
||||
--inset-hi: var(--glass-hi);
|
||||
/* 밝은 밴드에서는 테두리 광의 방향이 뒤집힌다. 흰빛 대신 그림자로 이어붙인다 */
|
||||
--glass-rim: linear-gradient(
|
||||
100deg,
|
||||
rgb(20 24 30 / 0.16) 0%,
|
||||
rgb(20 24 30 / 0.05) 26%,
|
||||
rgb(20 24 30 / 0.04) 58%,
|
||||
rgb(20 24 30 / 0.14) 100%
|
||||
);
|
||||
--glass-bloom: 0 0 18px -10px color-mix(in oklab, var(--ink) 30%, transparent);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,26 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|||
const src = path.resolve(here, "../../skill");
|
||||
const dest = path.resolve(here, "../dist/skill");
|
||||
|
||||
// 텍스트는 LF 로 정규화해서 싣는다.
|
||||
// Windows 에서 작업하면 파일이 CRLF 로 저장되고, 그대로 tarball 에 들어가면
|
||||
// SKILL.md 의 YAML 프론트매터를 파서에 따라 못 읽는다.
|
||||
// 배포물은 어느 OS 에서 만들었는지와 무관하게 같아야 한다.
|
||||
const TEXT = new Set([".md", ".txt", ".json", ".yml", ".yaml"]);
|
||||
|
||||
async function copyDir(from, to) {
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
for (const e of await fs.readdir(from, { withFileTypes: true })) {
|
||||
if (e.name === "node_modules" || e.name === "package.json") continue;
|
||||
const s = path.join(from, e.name);
|
||||
const d = path.join(to, e.name);
|
||||
if (e.isDirectory()) await copyDir(s, d);
|
||||
else await fs.copyFile(s, d);
|
||||
if (e.isDirectory()) {
|
||||
await copyDir(s, d);
|
||||
} else if (TEXT.has(path.extname(e.name))) {
|
||||
const text = await fs.readFile(s, "utf8");
|
||||
await fs.writeFile(d, text.replace(/\r\n/g, "\n"), "utf8");
|
||||
} else {
|
||||
await fs.copyFile(s, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -448,6 +448,132 @@ JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 반영한
|
|||
안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고,
|
||||
`design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.
|
||||
|
||||
### 패럴랙스의 원형은 "고정"이다
|
||||
|
||||
**배경이 뷰포트에 멈춰 있고 콘텐츠만 그 위를 지나갈 때 원경이 된다.**
|
||||
느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 방향이 같기 때문이다.
|
||||
"패럴랙스를 넣었는데 티가 안 난다"의 절반은 이것이다.
|
||||
|
||||
**`background-attachment: fixed` 는 쓰지 마라.** iOS Safari 가 GPU 메모리 때문에 그것을
|
||||
throttle 해서 스크롤 중 배경이 튀거나 리셋된다.
|
||||
|
||||
대신 **부모를 `position: fixed` 의 컨테이닝 블록으로 만든다**:
|
||||
|
||||
```css
|
||||
.section { position: relative; clip-path: inset(0); } /* 컨테이닝 블록 + 클립 */
|
||||
.section > .bg { position: fixed; inset: 0; z-index: -1; }
|
||||
.section > .bg img { width: 100%; height: 100%; object-fit: cover; }
|
||||
```
|
||||
|
||||
`clip-path` 는 두 가지를 동시에 한다 — 배경을 섹션 밖으로 못 나가게 자르고,
|
||||
fixed 자식의 기준을 뷰포트가 아니라 **자기 자신**으로 잡아준다.
|
||||
그래서 배경이 화면에 고정되면서도 그 섹션 안에서만 보인다.
|
||||
|
||||
**두 가지 대가가 있다. 알고 써라.**
|
||||
|
||||
1. **조상에 `transform`·`filter`·`mask` 를 추가하면 즉시 깨진다.** 그것들도 컨테이닝 블록을
|
||||
만들기 때문에 fixed 가 섹션 기준이 되어 고정이 사라진다. 위아래 페이드 마스크를 얹고 싶어도
|
||||
못 얹는다 — 경계는 clip 으로 딱 잘리는 것을 받아들여야 한다
|
||||
2. `prefers-reduced-motion` 에서는 `position: absolute` 로 되돌려라. 고정 배경은
|
||||
스크롤 중 시각 부하가 크다
|
||||
|
||||
**전부 고정하지는 마라.** 가까이 있어야 할 재질(종이·질감)까지 원경으로 두면 거짓말이 된다.
|
||||
원경은 `fixed`, 중경은 아래의 비율 이동, 근경은 콘텐츠와 함께.
|
||||
|
||||
### 이동량은 절대값이 아니라 컨테이너 대비 비율이다
|
||||
|
||||
**패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.**
|
||||
|
||||
공식은 하나다. **배경을 컨테이너보다 크게 만들고, 그 초과분 전체를 움직인다.**
|
||||
|
||||
```
|
||||
이미지 높이 = 컨테이너 × (1 + depth)
|
||||
이동량 = 컨테이너 × depth
|
||||
= 이미지 높이의 depth / (1 + depth)
|
||||
```
|
||||
|
||||
`depth: 0.45` 면 이미지는 145%, 이동은 이미지 자기 높이의 **31%**.
|
||||
섹션이 2000px 이면 **900px** 을 움직인다.
|
||||
|
||||
```css
|
||||
.bg img {
|
||||
height: 145%; /* 1 + depth */
|
||||
animation: bg-par linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
}
|
||||
@keyframes bg-par {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-31.03%); } /* depth / (1 + depth) */
|
||||
}
|
||||
```
|
||||
|
||||
`translateY(%)` 는 **자기 크기 기준**이라 컨테이너 높이를 몰라도 정확히 맞는다.
|
||||
|
||||
**실측 사례**: 여유를 28% 잡아놓고 `±46px`(총 92px)만 움직였더니 스크롤 1800px 구간에서
|
||||
이동 비율이 5% 였다. 아무도 시차를 느끼지 못했다. 같은 구간에서 **374px** 로 올리자 보였다.
|
||||
CSS-Tricks 의 예제도 `background-position: bottom 0px → bottom -400px`, 즉 400px 이다.
|
||||
|
||||
> 참고: `background-position` 을 애니메이션하는 방법도 있다(CSS-Tricks). 다만 그러면
|
||||
> `<img loading="lazy">` 의 지연 로딩과 반응형 `srcset` 을 잃는다. 무거운 사진이면 `<img>` 를
|
||||
> 절대배치하고 `transform` 을 쓰는 쪽이 낫다.
|
||||
|
||||
### `overflow: hidden` 이 view() 타임라인을 죽인다
|
||||
|
||||
**이것 하나에 패럴랙스가 통째로 멈춘다. 그리고 조용히 멈춘다.**
|
||||
|
||||
`overflow: hidden` 은 **스크롤 컨테이너를 만든다.** 그러면 그 안에 있는 자식의
|
||||
`animation-timeline: view()` 는 문서가 아니라 **그 상자**를 기준으로 잡힌다.
|
||||
자식은 상자 안에서 위치가 변하지 않으므로 진행도가 고정되고, 애니메이션은
|
||||
어중간한 한 프레임에서 굳는다.
|
||||
|
||||
- 콘솔 에러 없음. `animation-name`·`animation-timeline` computed 값도 전부 정상
|
||||
- `getComputedStyle(el).transform` 을 두 스크롤 위치에서 재봐야 알 수 있다. **실측에서 이동 0px**
|
||||
|
||||
**잘라내야 한다면 `overflow: clip` 을 쓰거나 조상 섹션에서 처리해라.**
|
||||
`clip` 은 스크롤 컨테이너를 만들지 않는다.
|
||||
|
||||
### 어두운 배경 위 광원 이미지는 opacity 로 누르지 마라
|
||||
|
||||
`opacity` 는 이미지 전체를 균일하게 죽인다 — 검정도, 빛도 같이. 그래서 어두운 광원 사진을
|
||||
`opacity: 0.5` 로 깔면 **있는지조차 안 보인다.**
|
||||
|
||||
```css
|
||||
.bg img { mix-blend-mode: screen; opacity: 0.9; }
|
||||
```
|
||||
|
||||
`screen` 은 **검정을 투명하게 만들고 밝은 곳만 더한다.** 그래서 불투명도를 높게 유지해도
|
||||
본문 대비를 해치지 않는다. 밝은 이미지(종이·질감)는 반대로 `normal` 이 맞다.
|
||||
|
||||
**생성형 이미지를 배경으로 쓸 때 특히 조심해라.** 프롬프트에 "본문 뒤에 깔리니 어둡게"를
|
||||
너무 강하게 넣으면 안전한 이미지가 아니라 **없는 이미지**가 나온다.
|
||||
대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라.
|
||||
|
||||
### sticky 캔버스의 진행도는 섹션 높이가 아니다
|
||||
|
||||
캔버스를 `position: sticky; top: 0; height: 100dvh` 로 붙여두고 섹션이 지나가게 하는 패턴에서,
|
||||
진행도를 **섹션 높이로 나누면 안 된다.**
|
||||
|
||||
캔버스가 실제로 화면에 머무는 거리는 **(섹션 높이 − 뷰포트 높이)** 다.
|
||||
섹션 높이를 쓰면 캔버스가 위로 사라진 뒤에도 진행이 남아 **끝에 영영 도달하지 못한다.**
|
||||
|
||||
```js
|
||||
const travel = Math.max(section.getBoundingClientRect().height - innerHeight, 1);
|
||||
const progress = clamp(-section.getBoundingClientRect().top / travel, 0, 1);
|
||||
```
|
||||
|
||||
실측: 섹션 1493px · 뷰포트 900px 에서 섹션 높이로 나눴더니 6단계 중 **3단계에서 멈췄다.**
|
||||
`height - innerHeight` 로 바꾸자 6단계를 다 돌았다.
|
||||
|
||||
그리고 진행도의 기준 요소는 **캔버스가 아니라 섹션**이다. sticky/fixed 캔버스는 rect 가
|
||||
변하지 않으므로 자기 rect 로 재면 진행도가 고정된다.
|
||||
|
||||
### 배경도 한 섹션에 하나다
|
||||
|
||||
CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고,
|
||||
격자는 배경이라기보다 **표**로 읽혔다. 앞에서 "한 층은 고정"이라고 한 것과 같은 이야기다 —
|
||||
층을 늘리는 것과 층이 싸우게 두는 것은 다르다.
|
||||
|
||||
### 패럴랙스에는 기준면이 필요하다
|
||||
|
||||
**배경이 단색이면 패럴랙스는 성립하지 않는다.** 앞의 것이 몇 px 어긋나 봐야
|
||||
|
|
|
|||
|
|
@ -367,3 +367,70 @@ if (reduceEffects()) document.documentElement.classList.add('reduce-effects');
|
|||
- [ ] Safari·모바일 실기기, CPU throttling 4×에서 확인했다
|
||||
|
||||
> 근거: research/svg/01~03 (조사일 2026-08-20)
|
||||
|
||||
---
|
||||
|
||||
## 유리 테두리(헤어라인)를 그릴 때
|
||||
|
||||
1px 테두리에 광을 흘리는 흔한 방법은 그라디언트를 깔고 마스크로 테두리만 남기는 것이다.
|
||||
|
||||
```css
|
||||
.glass::after {
|
||||
content: ""; position: absolute; inset: 0; padding: 1px;
|
||||
border-radius: inherit;
|
||||
background: var(--glass-rim);
|
||||
mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
|
||||
mask-composite: exclude; /* -webkit-mask-composite: xor */
|
||||
}
|
||||
```
|
||||
|
||||
**`conic-gradient` 를 쓰지 마라.** 중심에서 각도로 돌기 때문에, 납작한 형태
|
||||
(내비게이션 바처럼 1180×54)에서는 **각도가 급변해 변마다 광이 끊긴다.**
|
||||
긴 변에서는 색이 거의 안 변하고 짧은 변에서 한꺼번에 바뀐다.
|
||||
|
||||
```css
|
||||
/* 네 변이 같은 그라디언트를 공유한다 — 테두리를 따라 이어진다 */
|
||||
--glass-rim: linear-gradient(100deg,
|
||||
rgb(255 255 255 / 0.34) 0%,
|
||||
rgb(255 255 255 / 0.10) 26%,
|
||||
rgb(255 255 255 / 0.06) 58%,
|
||||
rgb(255 255 255 / 0.28) 100%);
|
||||
```
|
||||
|
||||
정사각형에 가까운 카드라면 conic 도 자연스럽다. **비율을 보고 골라라.**
|
||||
|
||||
글로우를 더한다면 테두리에서 얕게 번지는 정도로 충분하다 —
|
||||
`0 0 22px -8px` 처럼 **음수 spread** 를 써서 형태 밖으로 새지 않게 한다.
|
||||
밝은 배경에서는 흰빛 대신 그림자로 뒤집어야 같은 인상이 난다.
|
||||
|
||||
### 테두리 광을 회전시키려면
|
||||
|
||||
`conic-gradient(from var(--angle), …)` 를 `@property` 로 애니메이션하는 방법이 흔히 보이는데,
|
||||
**그건 매 프레임 배경을 다시 칠한다.** 컴포지터에서 못 돌고 메인 스레드가 페인트한다.
|
||||
`transform`·`opacity` 만 애니메이션하라는 규칙에 걸린다.
|
||||
|
||||
**정사각형 원뿔 레이어를 마스크 안에서 회전시켜라.** 움직이는 것은 `rotate` 뿐이다.
|
||||
|
||||
```css
|
||||
.rim { /* 테두리만 남기는 마스크 */
|
||||
position: absolute; inset: 0; border-radius: inherit;
|
||||
overflow: hidden; padding: 1px;
|
||||
mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
.rim::before { /* 실제로 도는 것 */
|
||||
content: ""; position: absolute; top: 50%; left: 50%;
|
||||
width: 200%; aspect-ratio: 1; /* 정사각형이라 회전해도 밝기가 균일하다 */
|
||||
translate: -50% -50%;
|
||||
background: conic-gradient(from 0deg, transparent 0 58%, var(--glow) 74%, transparent 86%);
|
||||
animation: rim-spin 11s linear infinite;
|
||||
}
|
||||
@keyframes rim-spin { to { rotate: 360deg; } }
|
||||
```
|
||||
|
||||
**두 가지를 지켜라.**
|
||||
|
||||
- `aspect-ratio: 1` — 정사각형이어야 회전 중 밝기가 일정하다. 납작하면 변마다 달라진다
|
||||
- `width: 200%` — 요소보다 크게 잡아야 납작한 형태에서도 모서리까지 닿는다
|
||||
|
||||
`prefers-reduced-motion` 에서는 애니메이션만 빼면 된다. 정지 상태의 광은 그대로 남는다.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,104 @@ h1 이 36px 이어도 히어로가 압도적이다.
|
|||
|
||||
캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.
|
||||
|
||||
### 씬을 두 자리에 재사용하지 마라
|
||||
|
||||
의미를 가진 씬을 만들었다면 그 자리는 하나다. 배경이 허전하다고 같은 씬을 다른 섹션에
|
||||
또 깔면 **같은 그림이 두 번** 나오고, 첫 번째 자리의 의미까지 희석된다.
|
||||
|
||||
한 페이지에 씬이 여럿이면 **역할을 나눠라.**
|
||||
|
||||
| 역할 | 구현 | 판단 기준 |
|
||||
|---|---|---|
|
||||
| 주인공 | 씬(메시·카메라·이동) | 옆의 콘텐츠가 반응한다 |
|
||||
| 재질 | 셰이더 플레인 하나 | 공간만 만든다. 아무것도 반응하지 않는다 |
|
||||
| 무대 | 셰이더 플레인 하나 | 다른 요소가 그 위에 앉는다 |
|
||||
|
||||
**파일 이름을 용도와 맞춰라.** `hero-scene.ts` 를 파이프라인에서 쓰기 시작한 순간
|
||||
다음 사람은 그것을 히어로 것인 줄 알고 고친다. `gate-scene.ts` 로 바꿔라.
|
||||
|
||||
### 지오메트리는 화면 비율에 맞춰 다시 잡아라
|
||||
|
||||
데스크톱에서 정한 크기를 세로 화면에 그대로 쓰면 씬이 통째로 안 보인다.
|
||||
|
||||
수직 시야는 화면 비율과 무관하지만 **수평 시야는 aspect 에 비례한다.**
|
||||
|
||||
```
|
||||
수직 시야 = 2 · d · tan(fov/2)
|
||||
수평 시야 = 수직 시야 × aspect
|
||||
```
|
||||
|
||||
실측 사례: 판 폭 15 를 데스크톱(2.5:1)에서 정했다. 세로 화면(0.45:1)에서 수평 시야는
|
||||
**2.5** 로 줄어드는데 판은 그대로 15 라 **여섯 배**가 됐다. 첫 판이 화면을 완전히 덮어
|
||||
게이트가 보이지 않고 그냥 밝은 화면이 됐다.
|
||||
|
||||
```js
|
||||
const fitPanes = (aspect) => {
|
||||
const halfH = Math.tan((camera.fov * Math.PI) / 360);
|
||||
const viewW = 2 * camDist * halfH * aspect;
|
||||
const want = Math.max(MIN_W, viewW * 1.12); // 살짝만 덮는다
|
||||
for (const m of panes) m.scale.x = Math.min(1, want / GEO_W);
|
||||
};
|
||||
```
|
||||
|
||||
`fit()` 안에서 `camera.aspect` 를 갱신할 때 **같이 부른다.**
|
||||
|
||||
### 저사양 판정에 화면 폭을 넣어라
|
||||
|
||||
```js
|
||||
const narrow = innerWidth < 760;
|
||||
const lean = narrow || (navigator.hardwareConcurrency ?? 4) <= 4;
|
||||
const dpr = Math.min(devicePixelRatio, narrow ? 1.25 : 1.5);
|
||||
```
|
||||
|
||||
**`hardwareConcurrency` 만 보면 요즘 폰이 전부 고사양으로 잡힌다** — 8코어가 흔하다.
|
||||
좁은 화면은 대개 배터리로 돌아가고, 같은 씬이라도 열이 더 난다.
|
||||
폰의 DPR 은 3 까지 가므로 좁은 화면에서는 한 단계 더 내린다.
|
||||
|
||||
**세로 화면에서 캔버스가 화면 전체를 덮는다면 불투명도도 낮춰라.**
|
||||
가로에서 적당하던 밝기가 세로에서는 본문을 묻는다. 실측 교정: `0.7 → 0.45`.
|
||||
|
||||
### WebGL 위의 대비는 `readPixels` 로 못 잰다
|
||||
|
||||
`preserveDrawingBuffer: false`(기본값)면 프레임을 그린 뒤 버퍼가 비워진다.
|
||||
`gl.readPixels` 는 **전부 0 을 돌려주고, 그 0 을 검정으로 착각하면 대비가 통과한 것처럼 보인다.**
|
||||
|
||||
**스크린샷을 찍어 그 픽셀을 재라.** 그리고 글자가 없는 영역만 골라라 —
|
||||
글자를 포함해 재면 최고 밝기가 글자 자신이라 대비 1.00:1 이 나온다.
|
||||
|
||||
```js
|
||||
// 캔버스 배경만 있는 영역에서 가장 밝은 픽셀 = 최악의 경우
|
||||
sharp(shot).extract({ left, top, width, height }).raw()
|
||||
```
|
||||
|
||||
### 씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다
|
||||
|
||||
3D 에 은유를 담았는데 **그 의미를 자막으로 설명하고 있다면, 그림이 스스로 말하지 못한다는 뜻**이다.
|
||||
자막을 다듬을 게 아니라 배치를 의심해라.
|
||||
|
||||
실측 사례 — 여섯 단계 파이프라인을 유리판 여섯 장으로 표현한 씬:
|
||||
|
||||
| | 히어로에 뒀을 때 | 파이프라인 섹션으로 옮긴 뒤 |
|
||||
|---|---|---|
|
||||
| 옆에 있는 것 | 헤드라인과 CTA | **여섯 단계의 실제 이름과 설명** |
|
||||
| 의미 전달 | `게이트 01 / 06` 판독기 — **자막** | 판을 통과하면 그 단계가 켜진다 |
|
||||
| 사용자 반응 | "왜 저 상자가 있는지 모르겠다" | — |
|
||||
|
||||
**같은 씬인데 자리를 옮기니 자막이 필요 없어졌다.** 콘텐츠가 반응하기 때문이다.
|
||||
|
||||
```js
|
||||
mountScene({
|
||||
canvas,
|
||||
progressFrom: section, // sticky 캔버스는 자기 rect 로 진행도를 못 잰다
|
||||
onGate(index) { // 값이 바뀔 때만 DOM 을 쓴다
|
||||
stops.forEach((el, i) => el.classList.toggle("is-active", i === index));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**씬을 넣을 자리를 고를 때 "여기에 두면 무엇이 반응하는가"를 먼저 물어라.**
|
||||
아무것도 반응하지 않으면 그것은 재질이지 주인공이 아니다.
|
||||
|
||||
### 재질일 때 — 배경 전면
|
||||
|
||||
주인공이 아니라 공간을 만드는 표면이다. 규칙이 반대가 된다.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue