Compare commits

..

10 commits

Author SHA1 Message Date
Yun Chan
8ed45008a0 배포 환경을 실측해 .env.example 에 남긴다
Some checks failed
ci / build (push) Failing after 5s
나스를 찾았다 — Tailscale 의 yunchan-nas (100.116.83.60 / LAN 192.168.0.39),
Synology DS420+. SSH 키(id_ed25519)로 이미 붙어서 비밀번호가 필요 없다.

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 가 둘 다 막혀 있다.
  오리진 IP 를 감추는 정상 구성이고, 그래서 git push 는 도메인이 아니라
  Tailscale 주소로 해야 한다. remote 를 그렇게 걸었다.

  SSH 키가 이미 Forgejo 에 등록돼 있다(encep-win, yunchan 계정).
  push 에 토큰도 비밀번호도 필요 없다.

배포 전 검증
  전체 빌드 통과 · 테스트 22개 통과
  tarball 114.5KB / 22개 파일, 스킬 19개 포함
  실제 npx 설치 왕복: install 19개 -> doctor 정상 -> 드리프트 감지 ->
  update 가 수정 파일 보존 -> uninstall 후 잔여 0

  배포 직전에 결함 하나를 잡았다. Windows 에서 작업해 스킬이 전부 CRLF 로
  저장돼 있었고, 그대로 tarball 에 들어가면 SKILL.md 의 YAML 프론트매터를
  파서에 따라 못 읽는다. 번들 스크립트에서 LF 로 정규화하고
  .gitattributes 도 넣었다. 배포물은 어느 OS 에서 만들든 같아야 한다.

푸터 제거
  출처가 사라지지 않게 설치 섹션 끝에 한 줄만 남겼다(저장소 · MIT).
2026-08-20 16:58:24 +09:00
Yun Chan
8267905c0c 테두리 광을 회전시키고 three.js 모바일 대응을 고친다
회전 테두리
  conic 의 각도를 @property 로 돌리면 매 프레임 배경을 다시 칠한다 —
  컴포지터에서 못 돌고 메인 스레드가 페인트한다(하드 게이트 8).
  정사각형 원뿔 레이어를 마스크 안에서 rotate 시켰다. transform 만 움직인다.
  aspect-ratio: 1 이라야 회전 중 밝기가 일정하고,
  width: 200% 라야 1178x52 헤더에서 모서리까지 닿는다. 주기 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(세로는 화면 전체를 덮는다).

스킬
  svg-filters.md  테두리 광 회전은 각도가 아니라 정사각형 레이어 rotate
  three.md        지오메트리를 화면 비율에 맞춰 다시 잡아라
                  저사양 판정에 화면 폭을 넣어라
2026-08-20 16:37:49 +09:00
Yun Chan
54ec6fa582 패럴랙스를 마무리한다 — 원경·중경·근경 세 깊이
전 레이어를 실측하고 지도를 design.md 에 남겼다.

  히어로 배경   셰이더 흐름 필드   scroll()   +56px 하강
  히어로 글자                      scroll()   -88px 상승 (반대 방향)
  파이프라인    게이트 씬          sticky     6단계 통과
    단계명                          —         0 고정
    산출물                          view()    ±14px
  재료·설치·프리플라이트  사진      fixed     0 — 원경
  사례          사진               drift 0.4  이미지 높이의 28.6%
  조판          종이               drift 0.25 -122px — 근경
  재료 타일                        view()     ±26 / ±58px
  프리플라이트 항목                view()     6 -> 10px
  수치                             없음       의도적 정지

세 깊이가 다 있다. 종이를 원경으로 두지 않은 것은 손에 닿는 거리의
재질이기 때문이다. 한 층(단계명·수치)은 고정이어야 기준이 생긴다.

모바일 390px 에서도 fixed 가 유지된다 — background-attachment 를 안 쓴 덕이다.
prefers-reduced-motion 에서는 게이트 씬이 시작하지 않고, 고정 배경은
absolute 로 돌아가며, view() 애니메이션이 전부 꺼진다.

최종 검사: 토큰 밖 색상 0 · radius 2종 · 100vh 0 · scroll 리스너 0 ·
하드코딩 웨이트 0 · 가로 스크롤 0(390/1440) · 테스트 22개 통과
2026-08-20 16:16:49 +09:00
Yun Chan
51d452e66c 유리 테두리를 conic 에서 linear 로 바꿔 이어지게 한다
헤더 헤어라인이 변마다 끊겨 보였다. conic-gradient 는 중심에서 각도로
도는데, 1180x54 처럼 납작한 형태에서는 각도가 급변해 긴 변에서 색이
거의 안 변하고 짧은 변에서 한꺼번에 바뀐다. 실제로 아래쪽만 절반 밝았다.

linear-gradient(100deg) 로 바꾸니 네 변이 같은 그라디언트를 공유해
테두리를 따라 이어진다. 마스크로 1px 만 남기는 구조는 그대로다.

얕은 글로우를 더했다 — 0 0 22px -8px 에 강조색 42%.
음수 spread 라 형태 밖으로 새지 않는다.
밝은 밴드에서는 흰빛 대신 그림자로 뒤집는다.

스킬 svg-filters.md 에 "유리 테두리를 그릴 때" 추가.
비율을 보고 conic/linear 를 고르라는 기준과 음수 spread 글로우.
2026-08-20 16:14:24 +09:00
Yun Chan
91bb4ae9dc 히어로와 파이프라인이 같은 씬을 쓰던 것을 나눈다
게이트 씬을 파이프라인으로 옮기면서 히어로에도 남겨뒀더니 같은 그림이
페이지에 두 번 나왔다. 역할이 다르면 씬도 달라야 한다.

  히어로     shader-plane 흐름 필드   노이즈를 두 번 접어 빛의 결을 만든다
                                      공간을 만드는 재질. 아무것도 반응 안 한다
  파이프라인 gate-scene 유리판 여섯   통과하면 그 단계가 켜진다. 주인공
  재료       shader-plane 스테이지    필터 타일이 앉는 무대

히어로에는 무거운 씬을 임포트하지 않는다 — three.md §0 대로 셰이더 플레인 하나다.
lib/hero-scene.ts -> lib/gate-scene.ts 로 리네임했다. 이름이 용도와 다르면
다음 사람이 히어로 것인 줄 알고 고친다.

대비 실측: 캔버스 배경 최악 8.46:1(가장 밝은 중앙 여백), h1 자리 14.83:1.
readPixels 로 재려다 전부 0 이 나왔다 — preserveDrawingBuffer: false 면
버퍼가 비어 있다. 스크린샷 픽셀로 쟀고, 글자 없는 영역만 골랐다.

스킬 three.md
  씬을 두 자리에 재사용하지 마라. 역할(주인공/재질/무대)로 나눠라
  파일 이름을 용도와 맞춰라
  WebGL 위 대비는 readPixels 가 아니라 스크린샷 픽셀로 잰다.
  0 을 검정으로 착각하면 대비가 통과한 것처럼 보인다
2026-08-20 16:11:53 +09:00
Yun Chan
af3a08397e 배경을 뷰포트에 고정하고, 게이트 씬을 파이프라인으로 옮긴다
"스크롤할 때 이미지가 고정되어 원경처럼 보이는 게 패럴랙스의 꽃"이라는
지적이 맞았다. 느리게 같이 움직이는 것으로는 그 인상이 안 나온다 —
속도만 다를 뿐 방향이 같기 때문이다.

고정 배경
  background-attachment: fixed 는 iOS Safari 가 GPU 메모리 때문에
  throttle 해서 스크롤 중 배경이 튄다. 쓰지 않았다.
  섹션에 clip-path: inset(0) 을 걸어 position: fixed 의 컨테이닝 블록으로
  만들었다. 배경이 화면에 멈춘 채 그 섹션 안에서만 보인다.
  실측: 스크롤 1000px 구간에서 viewportTop 이 계속 0.

  대가 둘을 받아들였다
    조상에 transform/filter/mask 를 못 쓴다 — 페이드 마스크를 포기했다
    prefers-reduced-motion 에서는 absolute 로 되돌린다

  전부 고정하지는 않았다. 종이는 손에 닿는 거리라 원경으로 두면 거짓말이다
    재료·설치·프리플라이트  fixed  원경
    사례                    drift  두 결과물이 흐르는 자리
    조판                    drift  종이는 가깝다

게이트 씬을 파이프라인으로
  히어로에서는 의미를 판독기가 자막으로 설명해야 했다. 그림이 스스로
  말하지 못한다는 뜻이다. 옆에 여섯 단계가 적힌 자리로 옮기니 자막이
  필요 없어졌다 — 판을 통과하면 그 단계가 켜진다.

  캔버스는 sticky, 진행도는 섹션에서 읽는다.
  travel 을 섹션 높이로 잡았더니 캔버스가 사라진 뒤에도 진행이 남아
  6단계 중 3단계에서 멈췄다. (섹션 높이 - 뷰포트 높이)로 고쳤다.
  캔버스 위 텍스트 대비 14.2:1.

스킬
  motion.md  패럴랙스의 원형은 고정 · clip-path 기법과 그 대가
             sticky 캔버스의 진행도 계산
  three.md   씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다.
             자막이 필요하면 배치를 의심해라
2026-08-20 16:08:11 +09:00
Yun Chan
1ed3c95542 패럴랙스를 실제로 보이게 고치고 격자를 걷어낸다
"티가 안 난다"는 지적이 맞았다. 세 가지를 동시에 잘못하고 있었다.

1. 이동량이 모자랐다
   여유를 28% 잡아놓고 92px 만 움직였다. 스크롤 1800px 구간에서 5%다.
   공식은 이미지를 컨테이너보다 크게 만들고 초과분 전체를 움직이는 것.
   depth 0.45 -> 높이 145%, translateY(-31.03%). 같은 구간에서 374px.
   translateY(%) 는 자기 크기 기준이라 컨테이너 높이를 몰라도 맞는다.

2. overflow: hidden 이 view() 타임라인을 죽이고 있었다
   overflow: hidden 은 스크롤 컨테이너를 만들고, 그러면 자식의 view() 가
   문서가 아니라 그 상자를 기준으로 잡힌다. 이미지는 상자 안에서 늘 같은
   자리라 진행도가 고정된다. 콘솔 에러도 없고 computed 값도 정상이라
   두 스크롤 위치에서 transform 을 재보고서야 이동 0px 인 것을 알았다.
   잘라내는 일은 섹션의 overflow: clip 에 맡겼다.

3. opacity 로 눌러 이미지를 지웠다
   어두운 광원 사진을 opacity 0.5 로 깔면 검정도 빛도 같이 죽는다.
   mix-blend-mode: screen 으로 바꿨다 — 검정은 투명, 밝은 곳만 더해진다.
   그래서 불투명도 0.85~1.0 을 써도 본문 대비가 안전하다.
   생성 프롬프트도 같은 함정이었다. "어둡게"를 너무 강하게 넣어서
   안전한 이미지가 아니라 없는 이미지를 만들었다.

격자 배경 제거
   사진이 들어온 뒤로 배경이 두 겹이라 서로 경쟁했고 표로 읽혔다.
   배경도 한 섹션에 하나다.

스킬 motion.md 에 넷 다 반영
   이동량 공식 · overflow 함정 · screen 합성 · 배경은 한 겹
2026-08-20 15:57:40 +09:00
Yun Chan
5a18806fb6 배경 레이어가 그리드 안에서 561px 로 잘리던 것을 고친다
절대배치인데도 배경이 화면 가운데 561px 띠로만 깔렸다.
.section 은 subgrid 이고 `.section > * { grid-column: main }` 이 걸려 있는데,
절대배치 요소라도 그리드 영역이 잡히면 그 영역이 컨테이닝 블록이 된다.
position: absolute 는 그리드에서 배치를 면제해주지 않는다.

그리고 overflow: visible 이라 위아래 14% 여유분이 이웃 섹션으로 넘쳤다 —
조판 섹션의 종이가 프리플라이트 하단에 밝은 회색 띠로 나타났다.

  .bg-photo { grid-column: full }
  .has-bg   { overflow: clip }

띠 폭 기준으로 잡았던 투명도도 다시 올렸다
  사례 0.3 -> 0.5 · 프리플라이트 0.55 -> 0.8
  재료 0.5 -> 0.6 · 설치 0.4 -> 0.55

1440px/390px 실렌더: 배경 5개 모두 전체 폭, 가로 스크롤 0.
화면 밖 섹션의 배경은 아직 로드되지 않는다 — lazy 가 의도대로 작동한다.
2026-08-20 15:44:15 +09:00
Yun Chan
2bb1da9ce9 섹션마다 다른 배경 사진을 만들어 붙인다
모션에 적용한 규칙("섹션마다 다른 말을 해야 한다")이 배경에서만 면제될
이유가 없다. 같은 그림을 전부에 깔면 그건 벽지다.

gpt-image-2 로 넉 장. 프롬프트마다 그 섹션의 주장을 그림으로 옮겼다
  bg-refraction  겹친 유리판을 통과한 빛     재료·설치   표면은 한 겹이 아니다
  bg-split       한 빔이 둘로 갈라진다       사례        같은 절차, 다른 결과
  bg-align       정렬선 하나가 끊겨 있다     프리플라이트 자기 결과물을 남의 것처럼 본다
  bg-paper       무광 인쇄용지, 섬유와 데클  조판        한글이 종이 위에 있어야 한다

넣지 않은 곳도 결정이다
  파이프라인·수치  격자가 그 섹션의 주장이다
  히어로          three.js 가 있다
  규칙            목록은 담백해야 한다

프롬프트에는 매번 토큰 값을 그대로 넣었다. 광원은 #4FD6C4 와 #3D7BD6 둘뿐,
보라·마젠타·무지개·프리즘 분광 금지. "흰 본문 뒤에 깔리니 활자와 경쟁할
만큼 밝은 곳이 있으면 안 된다" 를 매번 반복했다.

BgPhoto 를 variant 로 확장. 소스 WebP 37/14/11/112KB,
빌드 12개 합계 276KB. 뷰포트에 맞는 하나씩만 lazy 로 받는다.
2026-08-20 14:58:59 +09:00
Yun Chan
54b9628879 배경 사진을 gpt-image-2 로 만들어 기준면을 강화한다
CSS 그라디언트 광원만으로는 패럴랙스의 기준면이 약했다. 굴절과 그레인이
실제로 들어 있는 이미지를 Codex CLI 내장 image_gen(gpt-image-2)으로 생성했다.
스톡이 아니라 이 페이지의 토큰 값을 프롬프트에 그대로 넣어 만든 것이다.

  바탕 #0B0E12, 광원은 좌상단 딥 틸 #4FD6C4 와 우하단 파랑 #3D7BD6 둘뿐
  보라·마젠타·무지개 금지 — 슬롭 지문이다
  "흰 본문 뒤에 깔리니 활자와 경쟁할 만큼 밝은 곳이 있으면 안 된다.
   중앙은 조용하게, 밝기는 모서리 쪽에"
  사물·글자·사람·플레어·보케·비네트 전부 금지

components/BgPhoto.astro
  CSS background-image 가 아니라 <Image loading="lazy"> 를 절대배치한다.
  background-image 로 깔면 항상 받고 최적화도 안 된다
  설치 섹션은 flip 으로 좌우 반전 — 같은 그림으로 읽히지 않게
  섹션 경계에서 끊기지 않게 위아래를 마스크로 지운다
  패럴랙스는 -46/+46px 로 가장 느리다

빌드 결과 WebP 26KB / 14KB / 7KB. 소스도 PNG 2MB 대신 WebP 37KB.
재료·설치 섹션 대비 실패 0건.
2026-08-20 14:41:36 +09:00
27 changed files with 1040 additions and 135 deletions

62
.env.example Normal file
View 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
View file

@ -0,0 +1,6 @@
# 배포되는 텍스트는 어느 OS 에서 체크아웃하든 LF 다.
# SKILL.md 의 YAML 프론트매터가 CRLF 에서 파서에 따라 깨진다.
* text=auto eol=lf
*.png binary
*.webp binary
*.ico binary

View file

@ -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` 트랙을 좁은 화면에서 닫는다

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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>

View file

@ -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>

View file

@ -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-grid { grid-template-columns: 1fr; }
.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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,11 +377,11 @@ export const ko: Content = {
{ cmd: "npx designpaca doctor", what: "설치 상태와 드리프트 진단" },
{ cmd: "npx designpaca uninstall", what: "매니페스트 기반 정확한 제거" },
],
},
footer: {
made: "이 페이지는 designpaca 로 만들어졌다.",
repo: "git.chanpaca.net/yunchan/designpaca",
license: "MIT",
colophon: {
made: "designpaca 로 만들었다",
repo: "git.chanpaca.net/yunchan/designpaca",
license: "MIT",
},
},
nav: {
items: [
@ -612,11 +612,11 @@ 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.",
repo: "git.chanpaca.net/yunchan/designpaca",
license: "MIT",
colophon: {
made: "Built with designpaca",
repo: "git.chanpaca.net/yunchan/designpaca",
license: "MIT",
},
},
nav: {
items: [

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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);
}
/* ---- 유틸 -------------------------------------------------- */

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -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 어긋나 봐야

View file

@ -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` 에서는 애니메이션만 빼면 된다. 정지 상태의 광은 그대로 남는다.

View file

@ -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));
},
});
```
**씬을 넣을 자리를 고를 때 "여기에 두면 무엇이 반응하는가"를 먼저 물어라.**
아무것도 반응하지 않으면 그것은 재질이지 주인공이 아니다.
### 재질일 때 — 배경 전면
주인공이 아니라 공간을 만드는 표면이다. 규칙이 반대가 된다.