디자인 SSOT 를 세우고 3D 를 배경 재질로 되돌린다

지적받은 것: 모바일 마진·패딩, 폰트 웨이트, 버튼 스타일 불일치, SSOT,
스크롤바, 모바일 메뉴, 그리고 3D 를 왜 상자에 넣었는지.

3D 의 역할을 잘못 잡았다
  lusion.co 의 "3D 는 액자 안"은 3D 자체가 상품일 때의 규칙이다.
  우리는 절차를 파는데 액자에 넣을 상품이 없어 "저 상자는 뭐냐"가 됐고,
  의미를 게이트 판독기가 자막으로 설명하고 있었다.
  이 씬의 역할은 재질이다. 배경으로 되돌리고 값을 전부 낮췄다 —
  테두리 광 0.85->0.42, 알파 0.72->0.34, UV 경계 0.05->0.17, 이동 60%
  ResizeObserver 로 캔버스를 따라가게 했다. window.resize 만 듣는 동안
  히어로 높이 변화를 놓쳐 배경이 화면의 3분의 2만 덮었다

SSOT
  웨이트 6종(400/500/560/600/620/660) -> 3종. 셋은 토큰에 없던 값이었다
  .btn + btn-solid/btn-quiet. 두 버튼이 높이 36 vs 44, 패딩 16 vs 8,
    테두리 1px vs 0, 웨이트 400 vs 500 이었다
  --header-h. 간격을 clamp 로 바꾸자 관계가 끊겨 h1 이 헤더 뒤로 들어갔다
  --space-5~8 과 --gutter 를 clamp 로. 고정이면 모바일 여백이 화면의 26%
  scrollbar-color 지정

모바일
  좌우 여백 24 -> 32px, 섹션 상하 112 -> 56px
  헤더에 섹션 앵커 4개. 900px 미만은 details 기반 메뉴(JS 0바이트)

패럴랙스는 섹션마다 다른 말을 한다
  히어로   글자 상승 / 배경 하강 — 반대 방향이라야 시차가 보인다
  파이프라인 단계명 고정, 산출물만 지연 — 인과
  재료     타일이 서로 다른 깊이 — 표면은 한 겹이 아니다
  프리플라이트 뒤 항목일수록 늦게 — 로그는 흐른다
  수치     없음. 의도적 정지 — 계기판의 바늘은 떨지 않는다

스킬
  three.md  §0-A 를 역할별로 갈랐다. "액자 안"만 적어둔 것이 잘못된
            일반화였고 내가 거기 그대로 걸렸다. 재질일 때의 값 교정을 실측으로 남김
  tokens.md 웨이트·컨트롤·헤더 높이·반응형 간격 — 토큰이 아예 없던 네 축
  motion.md 섹션별 패럴랙스 배정표. 움직이지 않는 것도 결정이다

390px 실렌더: 가로 스크롤 0, 대비 실패 0, 웨이트 3종, radius 2종. 테스트 22개 통과
This commit is contained in:
Yun Chan 2026-08-20 14:15:30 +09:00
parent 4b853355e3
commit 9c9ab2de7e
18 changed files with 606 additions and 231 deletions

View file

@ -168,6 +168,65 @@ WebGL 게이트: `prefers-reduced-motion` 이면 **아예 시작하지 않는다
- **언어 자동 리다이렉트 / 국기 아이콘** — 사용자를 놀라게 하고, 언어는 국가가 아니다 - **언어 자동 리다이렉트 / 국기 아이콘** — 사용자를 놀라게 하고, 언어는 국가가 아니다
- **두 판을 번역으로 만들기** — 각 언어판을 따로 썼다 - **두 판을 번역으로 만들기** — 각 언어판을 따로 썼다
## 3D 의 역할 — 주인공이 아니라 재질
**한 번 액자 안에 넣었다가 되돌렸다.** R2(lusion.co)의 "3D 는 액자 안"은 **3D 자체가 상품일 때**의
규칙이다. 그 스튜디오는 3D 를 팔기 때문에 액자가 쇼케이스 프레임으로 정당하다.
우리는 절차를 파는데 액자 안에 넣을 상품이 없어서, 보는 사람이 "저 상자는 뭐냐"고 묻게 됐다.
게다가 의미를 `게이트 01 / 06` 판독기가 **자막으로 설명하고 있었다** — 그림이 스스로 말하지
못해서 자막을 단 것이다.
이 씬의 역할은 **재질**이다. 공간을 만드는 표면이지 볼거리가 아니다. 그래서 규칙이 반대다:
| | 주인공(액자) | 재질(배경) — 현행 |
|---|---|---|
| 테두리 광 | 0.85 | **0.42** |
| 알파 | 0.72 | **0.34** |
| UV 경계 | `smoothstep(0, 0.05)` | **`smoothstep(0, 0.17)` + 제곱** |
| 카메라 이동 | 전 구간 | **60%** |
| 텍스트 | 액자 밖 | 위에 앉되 **스크림으로 자리를 눌러줌** |
`ResizeObserver` 로 캔버스를 따라가게 했다. `window.resize` 만 듣던 동안 히어로 높이 변화를
놓쳐 배경이 화면의 3분의 2만 덮고 아래가 검게 비었다.
## 모션 문법 — 섹션마다 다른 말
같은 패럴랙스를 전부에 걸면 벽지다. 각 섹션의 주장을 움직임으로 옮겼다.
| 섹션 | 주장 | 움직임 |
|---|---|---|
| 히어로 | 결정하지 않으면 기본값이 나온다 | 글자 ↑ / 배경 ↓ — **반대 방향**이라야 시차가 보인다 |
| 파이프라인 | 단계를 밟으면 결과가 나온다 | 단계명 **고정**, 산출물만 지연 |
| 사례 | 같은 스킬에서 다른 결과 | 두 판이 다른 속도 |
| 재료 | 표면은 한 겹이 아니다 | 타일이 서로 다른 깊이 |
| 프리플라이트 | 시간순 기록 | 뒤 항목일수록 늦게 |
| **수치** | 공개한 값은 검증 가능하다 | **없음 — 의도적 정지** |
마지막 줄이 결정이다. 숫자가 스크롤에 흔들리면 그 값도 흔들려 보인다.
## 디자인 SSOT
토큰이 없어서 깨져 있던 세 축을 채웠다.
- **웨이트 3종**`400 / 500 / 600`. 이전에는 코드 곳곳에 `500·560·600·620·660` 이 흩어져
**6종**이었고 셋은 토큰에 없는 값이었다. R1 모바일 실측은 `400` 이 832회, 나머지 12회다
- **컨트롤 치수**`--control-h: 44px` · `--control-h-sm: 36px` · `--control-pad-x`.
버튼처럼 생긴 것은 전부 `.btn` + `.btn-solid` / `.btn-quiet` 를 쓴다.
이전에는 두 버튼이 높이 36 vs 44, 패딩 16 vs 8, 테두리 1px vs 0, 웨이트 400 vs 500 이었다
- **`--header-h`** — 떠 있는 헤더의 높이. 간격 토큰을 `clamp()` 로 바꾸는 순간 관계가 끊겨
h1 이 헤더 뒤로 들어갔다. **값이 아니라 관계를 토큰으로 둔다**
- **반응형 간격**`--space-5~8``--gutter``clamp()` 로. 고정이면 모바일에서
섹션 상하 112px 이 그대로 들어가 화면의 26% 를 여백이 먹는다
- **스크롤바**`scrollbar-color` 지정. 기본값이면 어두운 페이지 옆에 OS 색 밝은 막대가 선다
## 모바일
- 좌우 여백 24px → **32px** (R1 모바일 실측값)
- 섹션 상하 112px → **56px**
- 헤더에 섹션 앵커 4개 추가. 900px 미만에서는 `<details>` 기반 메뉴로 접힌다 —
JS 0바이트이고 열림 상태·Esc·포커스 순서·`aria-expanded` 를 브라우저가 맞게 한다
- 390px 실렌더: 가로 스크롤 0, 대비 실패 0, 웨이트 3종, radius 2종
## 히어로 3D 의 형태 결정 ## 히어로 3D 의 형태 결정
- 판 **15 × 6** (액자 비율 2.5:1 과 일치). 첫 판이 화면을 완전히 덮어 이미 첫 게이트 안에 들어와 있다 - 판 **15 × 6** (액자 비율 2.5:1 과 일치). 첫 판이 화면을 완전히 덮어 이미 첫 게이트 안에 들어와 있다

View file

@ -12,7 +12,7 @@ const { command, copyLabel, copiedLabel, note } = Astro.props;
<div class="cmd-shell frame"> <div class="cmd-shell frame">
<div class="cmd"> <div class="cmd">
<code class="cmd-text">{command}</code> <code class="cmd-text">{command}</code>
<button type="button" class="cmd-copy" data-copy={command} data-copied={copiedLabel}> <button type="button" class="btn btn-solid cmd-copy" data-copy={command} data-copied={copiedLabel}>
<span class="cmd-copy-label">{copyLabel}</span> <span class="cmd-copy-label">{copyLabel}</span>
<span class="cmd-copy-icon" aria-hidden="true"> <span class="cmd-copy-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" width="12" height="12" fill="none"> <svg viewBox="0 0 16 16" width="12" height="12" fill="none">
@ -46,29 +46,13 @@ const { command, copyLabel, copiedLabel, note } = Astro.props;
color: var(--ink-muted); color: var(--ink-muted);
} }
/* 버튼 안에 아이콘이 자기 원을 갖는다 */ /* 높이·패딩·웨이트·전환은 .btn 이 정한다. 여기서는 이 자리에만
필요한 것만 더한다 — 아이콘이 오른쪽 안쪽에 붙어야 해서 그쪽만 좁다 */
.cmd-copy { .cmd-copy {
flex: 0 0 auto; flex: 0 0 auto;
display: inline-flex; padding-right: var(--space-2);
align-items: center; font-family: inherit;
gap: var(--space-2);
min-height: 44px;
padding: 0 var(--space-2) 0 var(--space-3);
border: 0;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-ink);
font: inherit;
font-size: var(--step-0);
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition:
transform var(--dur-instant) var(--ease-out),
filter var(--dur-instant) var(--ease-out);
} }
.cmd-copy:hover { filter: brightness(1.08); }
.cmd-copy:active { transform: scale(0.975); }
.cmd-copy-icon { .cmd-copy-icon {
display: inline-flex; display: inline-flex;

View file

@ -10,17 +10,28 @@ const { c, ui } = Astro.props;
--- ---
{/* {/*
구조는 R2(lusion.co) 실측에서 왔다. 3D 는 배경이다. 액자에 가두지 않는다.
3D 를 가장 잘 쓰는 스튜디오가 텍스트를 3D 위에 얹지 않는다.
액자 안은 3D, 액자 밖은 글자. 그래서 대비 문제가 생기지 않고
h1 이 36px 이어도 히어로가 압도적이다 — 힘이 타이포가 아니라 물체에서 나온다.
이전 판은 정반대였다. 캔버스를 배경 전체에 깔고 그 위에 7rem 짜리 한 번 액자 안에 넣었다가 되돌렸다. lusion.co 에서 가져온 "3D 는 액자 안,
헤드라인을 얹었다. 판 여섯 장은 흐린 얼룩이 됐고, 글자는 대비를 텍스트는 액자 밖"은 3D 자체가 상품일 때의 규칙이다. 그 스튜디오는 3D 를
얻으려고 글로우를 깔아야 했다. 둘 다 소리치다 둘 다 죽었다. 팔기 때문에 액자가 쇼케이스 프레임으로 정당하다.
배경으로 쓸 때는 규칙이 다르다 — 주인공이 아니라 공간을 만드는 재질이고,
그래서 대비를 낮추고 글자에 자리를 내준다.
처음 배경으로 깔았을 때 실패한 이유는 배치가 아니라 둘이었다.
판이 정면이라 프레넬이 0 이어서 아무것도 안 보였고(three.md §2),
헤드라인이 112px 이라 그나마 보이는 것과도 싸웠다. 둘 다 고쳤다.
*/} */}
<section class="full hero"> <section class="full hero">
<div class="hero-head"> <div class="hero-bg" aria-hidden="true">
{/* WebGL 이 없으면 이 그라디언트가 폴백이 아니라 결과물이다 */}
<div class="hero-fallback"></div>
<canvas id="hero-canvas" class="hero-canvas"></canvas>
{/* 글자가 앉는 자리만 눌러준다. 배경이 균일하게 밝으면 본문 대비가 무너진다 */}
<div class="hero-scrim"></div>
</div>
<div class="hero-grid">
<h1 class="hero-title">{c.h1}</h1> <h1 class="hero-title">{c.h1}</h1>
<div class="hero-aside"> <div class="hero-aside">
@ -31,71 +42,97 @@ const { c, ui } = Astro.props;
copiedLabel={ui.copied} copiedLabel={ui.copied}
note={c.installNote} note={c.installNote}
/> />
</div> <p class="hero-links">
</div> {c.links.map((l) => <a class="link-grow" href={l.href}>{l.label}</a>)}
<figure class="hero-stage">
<div class="stage-frame">
{/* WebGL 이 없으면 이 그라디언트가 그대로 결과물이다 */}
<div class="stage-fallback" aria-hidden="true"></div>
<canvas id="hero-canvas" class="stage-canvas" aria-label={c.stage.alt} role="img"></canvas>
{/* 계기판이니 판정을 읽어준다. 3D 가 장식이 아니라 상태 표시가 된다 */}
<p class="stage-readout" aria-hidden="true">
<span class="ro-key">{c.stage.gate}</span>
<span class="ro-val" id="ro-gate">01</span>
<span class="ro-sep">/</span>
<span class="ro-total">06</span>
<span class="ro-state" id="ro-state">{c.stage.pass}</span>
</p> </p>
</div> </div>
<figcaption> </div>
<span class="cap-text">{c.stage.caption}</span>
<span class="cap-links">
{c.links.map((l) => <a class="link-grow" href={l.href}>{l.label}</a>)}
</span>
</figcaption>
</figure>
</section> </section>
<style> <style>
.hero { .hero {
grid-column: full; grid-column: full;
padding-block: calc(var(--space-6) + var(--space-4)) var(--space-7); position: relative;
isolation: isolate;
overflow: clip;
min-height: min(88dvh, 52rem);
display: grid;
align-content: center;
padding-block: calc(var(--header-h) + var(--space-6)) var(--space-7);
} }
/* ---- 액자 밖: 글자 -------------------------------------- */ /* ---- 배경 ------------------------------------------------ */
.hero-head { .hero-bg {
position: absolute;
/* 패럴랙스로 내려가는 만큼 위아래에 여유를 둔다.
inset: 0 이면 배경이 내려간 자리에 검은 띠가 생긴다 */
inset: -4rem 0;
z-index: -1;
pointer-events: none;
/* 아래로 갈수록 지운다. black 은 색이 아니라 마스크 알파다.
없으면 히어로 하단에서 배경이 가로선으로
뚝 끊겨 다음 섹션과 두 장의 종이처럼 보인다. */
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 62%, transparent 96%);
mask-image: linear-gradient(to bottom, black 0%, black 62%, transparent 96%);
}
.hero-fallback,
.hero-canvas,
.hero-scrim {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.hero-fallback {
background:
radial-gradient(52% 62% at 24% 26%,
color-mix(in oklab, var(--accent) 30%, transparent), transparent 68%),
radial-gradient(46% 58% at 82% 62%,
color-mix(in oklab, var(--glow-2) 26%, transparent), transparent 66%);
}
.hero-canvas { opacity: 0; transition: opacity 1.2s var(--ease-soft); }
.hero-canvas.is-live { opacity: 1; }
/* 왼쪽 위(글자가 앉는 쪽)를 눌러 대비를 만든다. 오른쪽 아래는 열어둔다 */
.hero-scrim {
background:
linear-gradient(100deg,
color-mix(in oklab, var(--surface) 88%, transparent) 0%,
color-mix(in oklab, var(--surface) 62%, transparent) 42%,
transparent 78%),
linear-gradient(to bottom,
color-mix(in oklab, var(--surface) 72%, transparent) 0%,
transparent 28%);
}
/* ---- 글자 ------------------------------------------------ */
.hero-grid {
max-width: 1180px; max-width: 1180px;
width: 100%;
margin-inline: auto; margin-inline: auto;
padding-inline: var(--space-4); padding-inline: var(--gutter);
display: grid; display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: var(--space-5) var(--space-6); gap: var(--space-5) var(--space-6);
/* 왼쪽 열은 h1 하나(2줄), 오른쪽은 셋이라 높이가 100px 차이 난다.
start 나 end 로 붙이면 그 차이가 한쪽에 몰려 구멍으로 보인다.
가운데로 두면 위아래로 갈려 여백으로 읽힌다. */
align-items: center; align-items: center;
margin-bottom: var(--space-5);
} }
.hero-title { .hero-title {
/* 7rem 을 버렸다. 액자가 주인공이면 헤드라인은 물러난다. font-size: clamp(var(--step-2), 1.1rem + 2.6vw, var(--step-4));
두 줄을 넘기지 않는 폭이다(카운트 규칙: 히어로 헤드라인 <= 2줄) */
font-size: clamp(var(--step-2), 1.1rem + 2.4vw, var(--step-4));
line-height: 1.12; line-height: 1.12;
letter-spacing: var(--tracking-display); letter-spacing: var(--tracking-display);
max-width: 18ch; max-width: 16ch;
margin: 0; margin: 0;
} }
[lang="en"] .hero-title { max-width: 20ch; line-height: 1.04; } [lang="en"] .hero-title { max-width: 18ch; line-height: 1.04; }
.hero-aside { display: grid; gap: var(--space-3); align-content: start; } .hero-aside { display: grid; gap: var(--space-3); align-content: center; }
.hero-lead { .hero-lead {
margin: 0; margin: 0;
max-width: 30em; max-width: 30em;
color: var(--ink); color: var(--ink);
opacity: 0.82; opacity: 0.86;
} }
.hero-links { .hero-links {
display: flex; display: flex;
@ -105,116 +142,29 @@ const { c, ui } = Astro.props;
color: var(--ink-muted); color: var(--ink-muted);
} }
/* ---- 액자 안: 물체 -------------------------------------- */
.hero-stage {
margin: 0;
max-width: 1400px;
margin-inline: auto;
padding-inline: var(--space-4);
}
.stage-frame {
position: relative;
aspect-ratio: 16 / 6;
border: 1px solid var(--glass-edge);
border-radius: var(--radius-frame);
background: var(--surface-sunken);
box-shadow: var(--glass-hi), var(--shadow-3);
overflow: clip;
isolation: isolate;
}
.stage-fallback,
.stage-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.stage-fallback {
background:
radial-gradient(58% 76% at 26% 22%,
color-mix(in oklab, var(--accent) 34%, transparent), transparent 68%),
radial-gradient(52% 70% at 80% 64%,
color-mix(in oklab, var(--glow-2) 30%, transparent), transparent 66%);
}
.stage-canvas { opacity: 0; transition: opacity 1.2s var(--ease-soft); }
.stage-canvas.is-live { opacity: 1; }
.stage-readout {
position: absolute;
left: var(--space-3);
bottom: var(--space-3);
margin: 0;
display: flex;
align-items: baseline;
gap: 0.5ch;
font-family: var(--font-mono);
font-size: var(--step--1);
font-variant-numeric: tabular-nums;
letter-spacing: var(--tracking-label);
color: var(--ink);
/* 캔버스 위에 얹는 유일한 글자다. 판독 가능해야 하므로 바탕을 깐다 */
padding: 0.35em 0.7em;
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--surface-sunken) 82%, transparent);
border: 1px solid var(--line);
}
.ro-key { color: var(--ink-muted); }
.ro-val { color: var(--accent); }
.ro-sep, .ro-total { color: var(--ink-muted); }
.ro-state {
margin-left: 0.8ch;
padding-left: 0.8ch;
border-left: 1px solid var(--line-strong);
color: var(--accent);
}
figcaption {
margin-top: var(--space-2);
font-size: var(--step-0);
color: var(--ink-muted);
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
}
.cap-text { max-width: var(--measure); }
.cap-links { display: flex; gap: var(--space-4); flex: none; }
@media (max-width: 900px) { @media (max-width: 900px) {
.hero-head { grid-template-columns: 1fr; gap: var(--space-5); } .hero { min-height: min(94dvh, 46rem); }
/* 4/3 이면 320px 에서 액자가 193px, 화면의 27% 다. 데스크톱이 56% 인데 .hero-grid { grid-template-columns: 1fr; gap: var(--space-5); }
모바일에서만 3D 가 조연이 될 이유가 없다. 정사각으로 36% 를 준다. */ .hero-title { max-width: 100%; }
.stage-frame { aspect-ratio: 1 / 1; } /* 세로 화면에서는 글자가 위쪽 절반을 다 쓴다. 가로 방향 스크림으로는
덮이지 않으므로 위에서 아래로 누른다 */
.hero-scrim {
background: linear-gradient(to bottom,
color-mix(in oklab, var(--surface) 86%, transparent) 0%,
color-mix(in oklab, var(--surface) 58%, transparent) 52%,
transparent 86%);
}
} }
</style> </style>
<script> <script>
// 판 여섯 장이 깊이 축에 서 있고 카메라가 스크롤에 맞춰 통과한다. // 유리판이 느리게 흐르며 공간을 만든다. 배경이므로 스스로 설명하지 않는다.
// 굴절은 렌더 타겟 한 장으로 만든다 — transmission 재질보다 훨씬 싸다. // 굴절은 렌더 타겟 한 장으로 만든다 — transmission 재질보다 훨씬 싸다.
import { mountHeroScene } from "../lib/hero-scene"; import { mountHeroScene } from "../lib/hero-scene";
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null; const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
const gateEl = document.getElementById("ro-gate");
const stateEl = document.getElementById("ro-state");
const passWord = stateEl?.textContent ?? "";
if (canvas) { if (canvas) {
let lastGate = -1; mountHeroScene({ canvas, accentVar: "--accent", glowVar: "--glow-2" });
mountHeroScene({
canvas,
accentVar: "--accent",
glowVar: "--glow-2",
// 프레임마다 DOM 을 쓰지 않는다. 값이 바뀔 때만 쓴다.
onGate(index, total) {
if (index === lastGate) return;
lastGate = index;
if (gateEl) gateEl.textContent = String(index + 1).padStart(2, "0");
if (stateEl) stateEl.textContent = index + 1 >= total ? passWord : "···";
},
});
} }
</script> </script>

View file

@ -7,7 +7,7 @@ const { c, ui } = Astro.props;
--- ---
<section class="full install" aria-labelledby="install-h"> <section id="install" class="full install" aria-labelledby="install-h">
<div class="install-grid"> <div class="install-grid">
<div class="install-head"> <div class="install-head">
<h2 id="install-h">{c.h2}</h2> <h2 id="install-h">{c.h2}</h2>
@ -46,7 +46,7 @@ const { c, ui } = Astro.props;
.install-grid { .install-grid {
max-width: 1180px; max-width: 1180px;
margin-inline: auto; margin-inline: auto;
padding-inline: var(--space-4); padding-inline: var(--gutter);
display: grid; display: grid;
gap: var(--space-6); gap: var(--space-6);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@ -63,7 +63,7 @@ const { c, ui } = Astro.props;
padding-block: var(--space-2); padding-block: var(--space-2);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.t-name { font-weight: 500; } .t-name { font-weight: var(--weight-medium); }
.t-path { font-size: var(--mono-size); } .t-path { font-size: var(--mono-size); }
.cmds { margin: 0; display: grid; gap: var(--space-3); } .cmds { margin: 0; display: grid; gap: var(--space-3); }

View file

@ -14,7 +14,7 @@ const { c } = Astro.props;
<h2 id="mat-h" class="reveal">{c.h2}</h2> <h2 id="mat-h" class="reveal">{c.h2}</h2>
<p class="lead mat-lead reveal">{c.lead}</p> <p class="lead mat-lead reveal">{c.lead}</p>
<div class="full stage glass"> <div class="full stage glass par-slow">
<div class="stage-fallback" aria-hidden="true"></div> <div class="stage-fallback" aria-hidden="true"></div>
{/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */} {/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
<canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas> <canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas>
@ -22,7 +22,7 @@ const { c } = Astro.props;
<ul class="tiles"> <ul class="tiles">
{ {
c.tiles.map((t, i) => ( c.tiles.map((t, i) => (
<li class="tile reveal" style={`--reveal-delay:${i * 80}ms`}> <li class:list={["tile", "reveal", i % 2 === 0 ? "par-slow" : "par-fast"]} style={`--reveal-delay:${i * 80}ms`}>
<div class={`swatch swatch-${t.id}`} aria-hidden="true"></div> <div class={`swatch swatch-${t.id}`} aria-hidden="true"></div>
<h3 class="tile-name">{t.name}</h3> <h3 class="tile-name">{t.name}</h3>
<p class="tile-how muted">{t.how}</p> <p class="tile-how muted">{t.how}</p>

View file

@ -12,7 +12,14 @@ const rest = c.items.filter((m) => !m.feature);
카드에 담지 않는다. 숫자가 크기와 정렬선으로 스스로 위계를 만든다. 카드에 담지 않는다. 숫자가 크기와 정렬선으로 스스로 위계를 만든다.
카드는 elevation 이 진짜 위계를 나를 때만 쓴다는 규칙을 여기서 지킨다. 카드는 elevation 이 진짜 위계를 나를 때만 쓴다는 규칙을 여기서 지킨다.
*/} */}
<section class="section metrics" aria-labelledby="metrics-h"> {/*
이 섹션에는 패럴랙스를 넣지 않는다. 빠뜨린 것이 아니라 결정이다.
여기가 이 페이지의 수치를 공개하는 자리다. 숫자가 스크롤에 따라
흔들리면 그 값도 흔들리는 것처럼 읽힌다. 계기판의 바늘은 떨지 않는다.
움직임이 의미를 만드는 자리가 있으면, 움직이지 않는 것이 의미인 자리도 있다.
*/}
<section id="metrics" class="section metrics" aria-labelledby="metrics-h">
<div class="head"> <div class="head">
<p class="eyebrow reveal">{c.eyebrow}</p> <p class="eyebrow reveal">{c.eyebrow}</p>
<h2 id="metrics-h" class="reveal">{c.h2}</h2> <h2 id="metrics-h" class="reveal">{c.h2}</h2>
@ -52,7 +59,7 @@ const rest = c.items.filter((m) => !m.feature);
.figures { .figures {
max-width: 1180px; max-width: 1180px;
margin-inline: auto; margin-inline: auto;
padding-inline: var(--space-4); padding-inline: var(--gutter);
display: grid; display: grid;
/* 비대칭. 큰 수치 둘이 왼쪽을 차지하고 나머지는 오른쪽 여백에 흐른다 */ /* 비대칭. 큰 수치 둘이 왼쪽을 차지하고 나머지는 오른쪽 여백에 흐른다 */
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
@ -69,11 +76,11 @@ const rest = c.items.filter((m) => !m.feature);
.fig-value { .fig-value {
font-size: clamp(3.4rem, 2rem + 5vw, 6rem); font-size: clamp(3.4rem, 2rem + 5vw, 6rem);
line-height: 0.92; line-height: 0.92;
font-weight: 620; font-weight: var(--weight-strong);
letter-spacing: -0.04em; letter-spacing: -0.04em;
color: var(--accent); color: var(--accent);
} }
.fig-label { font-size: var(--step-1); font-weight: 500; margin-top: var(--space-2); } .fig-label { font-size: var(--step-1); font-weight: var(--weight-medium); margin-top: var(--space-2); }
.fig-note { font-size: var(--step-0); max-width: 22em; } .fig-note { font-size: var(--step-0); max-width: 22em; }
/* 나머지 수치는 오른쪽 열에서 두 칸을 세로로 이어 쓴다 */ /* 나머지 수치는 오른쪽 열에서 두 칸을 세로로 이어 쓴다 */
@ -93,7 +100,7 @@ const rest = c.items.filter((m) => !m.feature);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.row:last-child { border-bottom: 1px solid var(--line); } .row:last-child { border-bottom: 1px solid var(--line); }
dt { font-weight: 500; } dt { font-weight: var(--weight-medium); }
dd { margin: 0; } dd { margin: 0; }
dd.nums { font-size: var(--step-1); color: var(--accent); justify-self: end; } dd.nums { font-size: var(--step-1); color: var(--accent); justify-self: end; }
.row-note { .row-note {

View file

@ -14,7 +14,7 @@ const { c } = Astro.props;
③ 의미 — <ol role="list"> — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다 ③ 의미 — <ol role="list"> — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다
role="list" 는 list-style:none 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 되돌린다 role="list" 는 list-style:none 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 되돌린다
*/} */}
<section class="section" aria-labelledby="pipeline-h"> <section id="pipeline" class="section" aria-labelledby="pipeline-h">
<p class="eyebrow reveal">{c.eyebrow}</p> <p class="eyebrow reveal">{c.eyebrow}</p>
<h2 id="pipeline-h" class="reveal">{c.h2}</h2> <h2 id="pipeline-h" class="reveal">{c.h2}</h2>
<p class="lead pipeline-lead reveal">{c.lead}</p> <p class="lead pipeline-lead reveal">{c.lead}</p>
@ -33,7 +33,7 @@ const { c } = Astro.props;
<h3 class="stop-name">{s.name}</h3> <h3 class="stop-name">{s.name}</h3>
<p class="stop-does">{s.does}</p> <p class="stop-does">{s.does}</p>
</div> </div>
<p class:list={["stop-becomes", { mono: s.mono }]}>{s.becomes}</p> <p class:list={["stop-becomes", "par-lag", { mono: s.mono }]}>{s.becomes}</p>
</li> </li>
)) ))
} }
@ -53,7 +53,7 @@ const { c } = Astro.props;
.spine-wrap { .spine-wrap {
max-width: 1180px; max-width: 1180px;
margin-inline: auto; margin-inline: auto;
padding-inline: var(--space-4); padding-inline: var(--gutter);
} }
.brief { margin-bottom: var(--space-2); } .brief { margin-bottom: var(--space-2); }

View file

@ -17,7 +17,7 @@ const { c } = Astro.props;
<div class="full log"> <div class="full log">
{ {
c.items.map((i, idx) => ( c.items.map((i, idx) => (
<article class="entry reveal" style={`--reveal-delay:${idx * 45}ms`}> <article class="entry reveal par-lag" style={`--reveal-delay:${idx * 45}ms; --par-lag:${6 + idx * 2}px`}>
<p class="caught">{i.caught}</p> <p class="caught">{i.caught}</p>
<p class="fixed muted">{i.fixed}</p> <p class="fixed muted">{i.fixed}</p>
</article> </article>
@ -34,7 +34,7 @@ const { c } = Astro.props;
.log { .log {
max-width: 1180px; max-width: 1180px;
margin-inline: auto; margin-inline: auto;
padding-inline: var(--space-4); padding-inline: var(--gutter);
display: grid; display: grid;
} }
.entry { .entry {
@ -69,7 +69,7 @@ const { c } = Astro.props;
background: var(--accent); background: var(--accent);
} }
.caught { margin: 0; font-weight: 500; } .caught { margin: 0; font-weight: var(--weight-medium); }
.fixed { margin: 0; font-size: var(--step-0); } .fixed { margin: 0; font-size: var(--step-0); }
.pf-note { margin-top: var(--space-6); font-size: var(--step-0); max-width: 34em; } .pf-note { margin-top: var(--space-6); font-size: var(--step-0); max-width: 34em; }

View file

@ -32,7 +32,7 @@ const { c } = Astro.props;
.rule-list { .rule-list {
max-width: 1180px; max-width: 1180px;
margin: 0 auto; margin: 0 auto;
padding-inline: var(--space-4); padding-inline: var(--gutter);
} }
.rule { .rule {
display: grid; display: grid;
@ -49,7 +49,7 @@ const { c } = Astro.props;
dt { dt {
font-size: var(--step-2); font-size: var(--step-2);
font-weight: 560; font-weight: var(--weight-medium);
letter-spacing: var(--tracking-display); letter-spacing: var(--tracking-display);
line-height: var(--leading-tight); line-height: var(--leading-tight);
} }

View file

@ -10,7 +10,7 @@ const shots = [shotCoffee, shotSaas];
--- ---
{/* 레이아웃 패밀리: Z축 캐스케이드. 두 판이 서로 겹치고 각도가 다르다 */} {/* 레이아웃 패밀리: Z축 캐스케이드. 두 판이 서로 겹치고 각도가 다르다 */}
<section class="section showcase" aria-labelledby="showcase-h"> <section id="showcase" class="section showcase" aria-labelledby="showcase-h">
<h2 id="showcase-h" class="reveal">{c.h2}</h2> <h2 id="showcase-h" class="reveal">{c.h2}</h2>
<p class="lead showcase-lead reveal">{c.lead}</p> <p class="lead showcase-lead reveal">{c.lead}</p>
@ -41,7 +41,7 @@ const shots = [shotCoffee, shotSaas];
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: 1180px; max-width: 1180px;
margin-inline: auto; margin-inline: auto;
padding-inline: var(--space-4); padding-inline: var(--gutter);
align-items: start; align-items: start;
} }
/* 두 번째 판이 아래로 내려가 계단을 만든다 */ /* 두 번째 판이 아래로 내려가 계단을 만든다 */
@ -65,7 +65,7 @@ const shots = [shotCoffee, shotSaas];
margin-top: var(--space-3); margin-top: var(--space-3);
font-size: var(--step-0); font-size: var(--step-0);
} }
.work-caption { font-weight: 500; } .work-caption { font-weight: var(--weight-medium); }
@media (max-width: 820px) { @media (max-width: 820px) {
.cascade { grid-template-columns: 1fr; } .cascade { grid-template-columns: 1fr; }

View file

@ -88,6 +88,7 @@ export interface Content {
commands: { cmd: string; what: string }[]; commands: { cmd: string; what: string }[];
}; };
footer: { 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 }; ui: { copy: string; copied: string };
} }
@ -382,6 +383,15 @@ export const ko: Content = {
repo: "git.chanpaca.net/yunchan/designpaca", repo: "git.chanpaca.net/yunchan/designpaca",
license: "MIT", license: "MIT",
}, },
nav: {
items: [
{ label: "파이프라인", href: "#pipeline" },
{ label: "사례", href: "#showcase" },
{ label: "수치", href: "#metrics" },
{ label: "설치", href: "#install" },
],
menu: "메뉴",
},
ui: { copy: "복사", copied: "복사됨" }, ui: { copy: "복사", copied: "복사됨" },
}; };
@ -608,5 +618,14 @@ export const en: Content = {
repo: "git.chanpaca.net/yunchan/designpaca", repo: "git.chanpaca.net/yunchan/designpaca",
license: "MIT", license: "MIT",
}, },
nav: {
items: [
{ label: "Pipeline", href: "#pipeline" },
{ label: "Work", href: "#showcase" },
{ label: "Numbers", href: "#metrics" },
{ label: "Install", href: "#install" },
],
menu: "Menu",
},
ui: { copy: "Copy", copied: "Copied" }, ui: { copy: "Copy", copied: "Copied" },
}; };

View file

@ -1,4 +1,5 @@
--- ---
import { ko, en } from "../i18n/content";
import "../styles/fonts.css"; import "../styles/fonts.css";
import "../styles/tokens.css"; import "../styles/tokens.css";
import "../styles/base.css"; import "../styles/base.css";
@ -18,6 +19,10 @@ const FONT_CSS = {
const alt = lang === "ko" ? "/en/" : "/"; const alt = lang === "ko" ? "/en/" : "/";
// 헤더 내비게이션. 본문과 같은 콘텐츠 파일에서 온다 — 라벨을 레이아웃에
// 하드코딩하면 두 언어판이 어긋난다.
const nav = (lang === "ko" ? ko : en).nav;
// 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크. // 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크.
// data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다. // data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다.
// 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다. // 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다.
@ -91,12 +96,35 @@ const FAVICON =
</svg> </svg>
<span>designpaca</span> <span>designpaca</span>
</a> </a>
<nav aria-label={lang === "ko" ? "언어" : "Language"}>
<nav class="nav-main" aria-label={lang === "ko" ? "섹션" : "Sections"}>
{nav.items.map((i) => <a href={i.href}>{i.label}</a>)}
</nav>
<div class="header-end">
{/* 언어는 국가가 아니다. 국기 아이콘을 쓰지 않는다 */} {/* 언어는 국가가 아니다. 국기 아이콘을 쓰지 않는다 */}
<a href={alt} hreflang={lang === "ko" ? "en" : "ko"} lang={lang === "ko" ? "en" : "ko"}> <a
class="btn btn-quiet btn-sm"
href={alt}
hreflang={lang === "ko" ? "en" : "ko"}
lang={lang === "ko" ? "en" : "ko"}
>
{lang === "ko" ? "English" : "한국어"} {lang === "ko" ? "English" : "한국어"}
</a> </a>
</nav>
{/* 모바일 메뉴. details 라 JS 가 0바이트이고, 키보드와
스크린리더 대응이 브라우저 기본으로 온다 */}
<details class="nav-mobile">
<summary class="btn btn-quiet btn-sm" aria-label={nav.menu}>
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
<path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</summary>
<div class="nav-sheet glass">
{nav.items.map((i) => <a href={i.href}>{i.label}</a>)}
</div>
</details>
</div>
</div> </div>
</header> </header>
@ -160,7 +188,7 @@ const FAVICON =
left: 0; left: 0;
right: 0; right: 0;
z-index: 3; z-index: 3;
padding-inline: var(--space-4); padding-inline: var(--gutter);
} }
.header-inner { .header-inner {
max-width: 1180px; max-width: 1180px;
@ -176,28 +204,75 @@ const FAVICON =
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
font-weight: 560; font-weight: var(--weight-medium);
letter-spacing: -0.01em; letter-spacing: -0.01em;
text-decoration: none; text-decoration: none;
} }
.mark { color: var(--accent); flex: none; } .mark { color: var(--accent); flex: none; }
.site-header nav a { /* ---- 섹션 내비 (데스크톱) --------------------------------- */
.nav-main {
display: none;
gap: var(--space-1);
margin-inline: auto;
}
.nav-main a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 36px; min-height: var(--control-h-sm);
padding-inline: var(--space-3); padding-inline: var(--space-3);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: inherit; color: var(--ink-muted);
background: var(--glass-tint-2);
border: 1px solid var(--glass-edge);
/* muted 로 두면 유리 위에서 2.59:1 이다. 언어 전환은 내비게이션이지
보조 정보가 아니므로 ink 로 올린다(하드 게이트 #7) */
color: var(--ink);
text-decoration: none; text-decoration: none;
transition: color var(--dur-instant) var(--ease-out), transition: color var(--dur-instant) var(--ease-out),
background var(--dur-instant) var(--ease-out); background-color var(--dur-instant) var(--ease-out);
}
.nav-main a:hover { color: var(--ink); background: var(--glass-tint); }
.header-end {
display: flex;
align-items: center;
gap: var(--space-2);
flex: none;
}
/* ---- 모바일 메뉴 ------------------------------------------
details 를 쓴 이유는 JS 0바이트 때문만이 아니다. 열림 상태,
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다. */
.nav-mobile { position: relative; }
.nav-mobile > summary {
list-style: none;
padding-inline: 0;
width: var(--control-h-sm);
}
.nav-mobile > summary::-webkit-details-marker { display: none; }
.nav-mobile > summary::marker { content: ""; }
.nav-sheet {
position: absolute;
top: calc(100% + var(--space-2));
right: 0;
min-width: 11rem;
padding: var(--space-2);
display: grid;
gap: 2px;
z-index: 4;
}
.nav-sheet a {
display: flex;
align-items: center;
min-height: var(--control-h);
padding-inline: var(--space-3);
border-radius: var(--radius-sm);
color: var(--ink);
text-decoration: none;
}
.nav-sheet a:hover { background: var(--glass-tint-2); }
@media (min-width: 900px) {
.nav-main { display: flex; }
.nav-mobile { display: none; }
} }
.site-header nav a:hover { color: var(--ink); background: var(--glass-tint); }
.site-footer { .site-footer {
padding-block: var(--space-6); padding-block: var(--space-6);

View file

@ -139,15 +139,17 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
float f1 = fbm(p * 1.8 + vec2(t, -t * 0.6)); float f1 = fbm(p * 1.8 + vec2(t, -t * 0.6));
float f2 = fbm(p * 2.3 - vec2(t * 0.8, t * 1.1) + f1 * 0.55); float f2 = fbm(p * 2.3 - vec2(t * 0.8, t * 1.1) + f1 * 0.55);
// 통과할수록 빛이 뒤로 물러난다 // 통과할수록 빛이 뒤로 물러난다.
vec2 c1 = mix(vec2(0.30 * uAspect, 0.44), m, 0.20) + vec2(0.0, uProgress * 0.18); // 중심을 화면 세로 중앙에 두고 반경을 넓게 잡는다 — 좁으면
vec2 c2 = vec2(0.82 * uAspect, 0.28) - vec2(uProgress * 0.12, 0.0); // 히어로 위아래가 검게 남아 배경이 '떠 있는 덩어리'로 보인다.
vec2 c1 = mix(vec2(0.34 * uAspect, 0.52), m, 0.20) + vec2(0.0, uProgress * 0.14);
vec2 c2 = vec2(0.78 * uAspect, 0.44) - vec2(uProgress * 0.10, 0.0);
float a = smoothstep(0.72, 0.08, distance(p, c1) + f1 * 0.36); float a = smoothstep(1.05, 0.05, distance(p, c1) + f1 * 0.34);
float b = smoothstep(0.66, 0.06, distance(p, c2) + f2 * 0.32); float b = smoothstep(0.95, 0.04, distance(p, c2) + f2 * 0.30);
vec3 col = uA * a + uB * b * 0.95; vec3 col = uA * a + uB * b * 0.9;
float alpha = clamp(a * 1.2 + b, 0.0, 1.0) * 0.9; float alpha = clamp(a * 1.15 + b, 0.0, 1.0) * 0.78;
gl_FragColor = vec4(col, alpha); gl_FragColor = vec4(col, alpha);
} }
`, `,
@ -204,8 +206,11 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
float fres = pow(1.0 - abs(dot(n, normalize(vViewDir))), 2.2); float fres = pow(1.0 - abs(dot(n, normalize(vViewDir))), 2.2);
// 판의 테두리. 평면에서 두께가 보이는 곳은 법선이 아니라 UV 경계다. // 판의 테두리. 평면에서 두께가 보이는 곳은 법선이 아니라 UV 경계다.
// 배경이므로 경계를 넓게 풀어 선이 아니라 번짐으로 만든다 —
// 좁게 두면 사각형 프레임이 또렷하게 서서 추상 패턴이 아니라 '상자'가 된다.
vec2 e = min(vUv, 1.0 - vUv); vec2 e = min(vUv, 1.0 - vUv);
float edge = 1.0 - smoothstep(0.0, 0.05, min(e.x, e.y)); float edge = 1.0 - smoothstep(0.0, 0.17, min(e.x, e.y));
edge *= edge;
// 화면 좌표를 굴절시켜 뒤 텍스처를 샘플링한다 // 화면 좌표를 굴절시켜 뒤 텍스처를 샘플링한다
vec2 screen = gl_FragCoord.xy / uResolution; vec2 screen = gl_FragCoord.xy / uResolution;
@ -215,8 +220,10 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
) - 0.5; ) - 0.5;
vec3 back = texture2D(uScene, screen + flow * 0.06 + n.xy * 0.05).rgb; vec3 back = texture2D(uScene, screen + flow * 0.06 + n.xy * 0.05).rgb;
vec3 col = back * 1.04 + uEdge * (fres * 0.32 + edge * 0.85); // 배경이다. 테두리 광을 절반으로 낮춘다 — 액자 안에서 주인공일 때의
float alpha = 0.14 + fres * 0.26 + edge * 0.72; // 값을 그대로 두면 글자 뒤에서 형광 막대처럼 튄다.
vec3 col = back * 1.03 + uEdge * (fres * 0.20 + edge * 0.30);
float alpha = 0.12 + fres * 0.18 + edge * 0.32;
gl_FragColor = vec4(col, alpha); gl_FragColor = vec4(col, alpha);
} }
`, `,
@ -230,7 +237,7 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
// 지나가는 것으로 보인다 — 실제로 그렇게 보였다. // 지나가는 것으로 보인다 — 실제로 그렇게 보였다.
// 판을 액자 비율에 맞춰 첫 판이 화면을 덮게 하면, 가까워질수록 좌우가 // 판을 액자 비율에 맞춰 첫 판이 화면을 덮게 하면, 가까워질수록 좌우가
// 화면 밖으로 빠지고 뒤 판들이 원근으로 남아 터널이 된다. // 화면 밖으로 빠지고 뒤 판들이 원근으로 남아 터널이 된다.
const GATE_GAP = 6.4; const GATE_GAP = 4.6;
const panes: import("three").Mesh[] = []; const panes: import("three").Mesh[] = [];
const geo = new THREE.PlaneGeometry(15, 6, 1, 1); const geo = new THREE.PlaneGeometry(15, 6, 1, 1);
for (let i = 0; i < PANE_COUNT; i++) { for (let i = 0; i < PANE_COUNT; i++) {
@ -240,7 +247,7 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
// '쌓아둔 사각형'이 된다. 중심축이 살아야 원근이 터널로 읽힌다. // '쌓아둔 사각형'이 된다. 중심축이 살아야 원근이 터널로 읽힌다.
const sway = (i % 2 === 0 ? 1 : -1) * (0.16 + i * 0.05); const sway = (i % 2 === 0 ? 1 : -1) * (0.16 + i * 0.05);
m.position.set(sway, Math.sin(i * 1.3) * 0.32, -i * GATE_GAP); m.position.set(sway, Math.sin(i * 1.3) * 0.32, -i * GATE_GAP);
m.rotation.set(0, sway * 0.05, sway * 0.018); m.rotation.set(sway * 0.03, sway * 0.11, sway * 0.05);
m.userData["index"] = i; m.userData["index"] = i;
m.userData["sway"] = sway; m.userData["sway"] = sway;
scene.add(m); scene.add(m);
@ -264,7 +271,11 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
bgUniforms.uAspect.value = r.width / r.height; bgUniforms.uAspect.value = r.width / r.height;
}; };
fit(); fit();
addEventListener("resize", fit, { passive: true }); // window resize 만 듣던 것을 ResizeObserver 로 바꾼다.
// 히어로 높이는 폰트가 늦게 오거나 콘텐츠가 접힐 때도 바뀌는데
// 그건 resize 이벤트가 아니다 — 그때 캔버스가 옛 크기로 남아
// 배경이 화면의 3분의 2만 덮고 아래가 검게 비었다.
new ResizeObserver(fit).observe(canvas);
addEventListener( addEventListener(
"pointermove", "pointermove",
@ -309,7 +320,8 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
// 카메라가 여섯 개의 문을 차례로 통과한다 // 카메라가 여섯 개의 문을 차례로 통과한다
const depth = (PANE_COUNT - 1) * GATE_GAP; const depth = (PANE_COUNT - 1) * GATE_GAP;
camera.position.z = 7.2 - progress * (depth + 5); // 배경은 천천히 흐른다. 전 구간을 다 통과시키면 시선을 끌어 글자와 싸운다.
camera.position.z = 7.2 - progress * depth * 0.6;
camera.position.x = (pointer.x - 0.5) * 1.1; camera.position.x = (pointer.x - 0.5) * 1.1;
camera.position.y = (pointer.y - 0.5) * -0.5; camera.position.y = (pointer.y - 0.5) * -0.5;
camera.lookAt(camera.position.x * 0.3, 0, camera.position.z - 10); camera.lookAt(camera.position.x * 0.3, 0, camera.position.z - 10);

View file

@ -72,15 +72,77 @@ body::after {
mix-blend-mode: overlay; mix-blend-mode: overlay;
} }
/* ---- 스크롤 ------------------------------------------------
기본 스크롤바는 OS 색이라 어두운 페이지 옆에 밝은 막대가 선다.
표준 속성으로 먼저 칠하고, 아직 지원이 없는 곳은 -webkit- 으로 받는다. */
html {
scrollbar-color: var(--line-strong) transparent;
scrollbar-width: thin;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: var(--radius-sm);
border: 2px solid var(--surface);
}
::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); }
/* 고정 헤더 높이만큼 앵커 목적지를 내린다. 없으면 점프한 섹션의
제목이 헤더 뒤로 들어간다. */
:target,
section[id] { scroll-margin-top: calc(var(--header-h) + var(--space-4)); }
/* ---- 컨트롤 ------------------------------------------------
버튼처럼 생긴 것은 전부 .btn 쓴다. 높이·패딩·웨이트를
컴포넌트마다 다시 정하면 그때부터 SSOT 아니다. */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-h);
padding-inline: var(--control-pad-x);
border-radius: var(--radius-sm);
font-size: var(--step-0);
font-weight: var(--weight-medium);
line-height: 1;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
border: 1px solid transparent;
transition:
background-color var(--dur-instant) var(--ease-out),
color var(--dur-instant) var(--ease-out),
transform var(--dur-instant) var(--ease-out);
}
.btn:active { transform: translateY(1px); }
.btn-sm { min-height: var(--control-h-sm); }
/* 채운 버튼 — 페이지에 하나뿐인 주 행동에만 */
.btn-solid {
background: var(--accent);
color: var(--accent-ink);
}
.btn-solid:hover { background: color-mix(in oklab, var(--accent) 86%, var(--ink)); color: var(--accent-ink); }
/* 테두리 버튼 — 나머지 전부 */
.btn-quiet {
background: var(--glass-tint-2);
border-color: var(--glass-edge);
color: var(--ink);
}
.btn-quiet:hover { background: var(--glass-tint); color: var(--ink); }
/* ---- 그리드 ------------------------------------------------ */ /* ---- 그리드 ------------------------------------------------ */
.page { .page {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
[full-start] minmax(var(--space-4), 1fr) [full-start] minmax(var(--gutter), 1fr)
[wide-start] minmax(0, var(--space-8)) [wide-start] minmax(0, var(--space-8))
[main-start] minmax(0, var(--measure)) [main-end] [main-start] minmax(0, var(--measure)) [main-end]
minmax(0, var(--space-8)) [wide-end] minmax(0, var(--space-8)) [wide-end]
minmax(var(--space-4), 1fr) [full-end]; minmax(var(--gutter), 1fr) [full-end];
} }
.page > * { grid-column: main; } .page > * { grid-column: main; }
.page > .wide { grid-column: wide; } .page > .wide { grid-column: wide; }
@ -105,10 +167,10 @@ h1, h2, h3 {
margin: 0; margin: 0;
line-height: var(--leading-tight); line-height: var(--leading-tight);
letter-spacing: var(--tracking-display); letter-spacing: var(--tracking-display);
font-weight: 600; font-weight: var(--weight-strong);
text-wrap: balance; text-wrap: balance;
} }
h1 { font-size: var(--display); font-weight: 660; } h1 { font-size: var(--display); }
h2 { font-size: var(--step-3); } h2 { font-size: var(--step-3); }
/* 섹션 제목만 본문 정렬선 왼쪽으로 나간다. 같은 양을 페이지 전체에서 반복한다 */ /* 섹션 제목만 본문 정렬선 왼쪽으로 나간다. 같은 양을 페이지 전체에서 반복한다 */
@ -128,7 +190,7 @@ p:last-child { margin-bottom: 0; }
font-size: var(--step-0); font-size: var(--step-0);
letter-spacing: var(--tracking-label); letter-spacing: var(--tracking-label);
color: var(--accent); color: var(--accent);
font-weight: 500; font-weight: var(--weight-medium);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
} }
@ -306,7 +368,9 @@ a:hover { color: var(--accent); }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) { @supports (animation-timeline: view()) {
.par-slow, .par-slow,
.par-fast { .par-fast,
.par-deep,
.par-lag {
animation: par linear both; animation: par linear both;
animation-timeline: view(); animation-timeline: view();
animation-range: cover 0% cover 100%; animation-range: cover 0% cover 100%;
@ -314,11 +378,41 @@ a:hover { color: var(--accent); }
} }
.par-slow { --par-from: 26px; --par-to: -26px; } .par-slow { --par-from: 26px; --par-to: -26px; }
.par-fast { --par-from: 58px; --par-to: -58px; } .par-fast { --par-from: 58px; --par-to: -58px; }
.par-deep { --par-from: 92px; --par-to: -92px; }
/* 짝이 되는 요소보다 조금 늦게 따라온다.
옆의 것은 고정이고 이것만 움직여야 '저것 때문에 이것'으로 읽힌다. */
.par-lag { --par-from: var(--par-lag, 14px); --par-to: calc(var(--par-lag, 14px) * -1); }
@keyframes par { @keyframes par {
from { transform: translate3d(0, var(--par-from), 0); } from { transform: translate3d(0, var(--par-from), 0); }
to { transform: translate3d(0, var(--par-to), 0); } to { transform: translate3d(0, var(--par-to), 0); }
} }
} }
/* 히어로는 페이지 위라 view() entry 구간이 이미 지나 있다.
그래서 문서 스크롤을 직접 타임라인으로 쓴다 시작점이 확정된다.
글자는 올라가고 배경은 내려간다. 서로 반대로 움직여야 시차가 보인다.
같은 방향으로 속도만 다르게 하면 그냥 느린 스크롤로 읽힌다. */
@supports (animation-timeline: scroll()) {
.hero-grid,
.hero-bg {
animation: linear both;
animation-timeline: scroll(root block);
animation-range: 0 92vh;
will-change: transform;
}
.hero-grid { animation-name: hero-rise; }
.hero-bg { animation-name: hero-sink; }
@keyframes hero-rise {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(0, -5.5rem, 0); }
}
@keyframes hero-sink {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(0, 3.5rem, 0); }
}
}
} }
/* ---- 유틸 -------------------------------------------------- */ /* ---- 유틸 -------------------------------------------------- */

View file

@ -91,15 +91,48 @@
--radius-sm: 3px; --radius-sm: 3px;
--radius-frame: 14px; --radius-frame: 14px;
/* ---- 간격 ------------------------------------------------ */ /* ---- 웨이트 ----------------------------------------------
개뿐이다. 코드 곳곳에 500·560·600·620·660 흩어져 있었고
그것이 페이지의 SSOT 깨진 가장 자리였다.
R1(zed.dev) 모바일 실측: font-weight 400 832회, 나머지 전부 합쳐 12회다.
위계를 웨이트로 만들지 않는다 크기와 색으로 만든다.
가변폰트는 660 같은 중간값도 그려주지만, 표준 (400/500/600) 벗어나면
힌팅이 흐려지고 폴백 폰트에서는 아예 다른 굵기로 떨어진다. */
--weight-body: 400;
--weight-medium: 500;
--weight-strong: 600;
/* ---- 컨트롤 ----------------------------------------------
버튼처럼 생긴 것은 전부 값을 쓴다. 예외를 만들지 마라.
R1 실측: Download / Clone 버튼이 h36·pad-x10·r4·w400·14px
픽셀 단위까지 같았다. 우리는 높이도 패딩도 웨이트도 제각각이었다. */
--control-h: 2.75rem; /* 44px — 터치 타깃 */
--control-h-sm: 2.25rem; /* 36px — 헤더 등 조밀한 자리 */
--control-pad-x: var(--space-3);
/* ---- 간격 ------------------------------------------------
작은 값은 고정, 값만 화면에 반응한다.
고정으로 두면 모바일에서 섹션 상하 112px 그대로 들어가
화면 높이의 26% 여백이 먹는다. 실제로 그랬다. */
--space-1: 0.25rem; --space-1: 0.25rem;
--space-2: 0.5rem; --space-2: 0.5rem;
--space-3: 1rem; --space-3: 1rem;
--space-4: 1.5rem; --space-4: 1.5rem;
--space-5: 2.5rem; --space-5: clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem);
--space-6: 4rem; --space-6: clamp(2.5rem, 1.6rem + 2.6vw, 4rem);
--space-7: 7rem; --space-7: clamp(3.5rem, 1.9rem + 4.6vw, 7rem);
--space-8: 10rem; --space-8: clamp(5rem, 2.6rem + 6.8vw, 10rem);
/* 좌우 여백. R1(zed.dev) 모바일 실측이 32px 이었다 우리는 24px 였다.
모바일 구간 32px 깔고 넓은 화면에서만 40px 까지 연다. */
--gutter: clamp(2rem, 1.4rem + 1.8vw, 2.5rem);
/* 헤더가 차지하는 높이. 있는 헤더라 문서 흐름에서 빠져 있고,
그래서 히어로 상단 여백과 앵커 목적지가 값을 알아야 한다.
간격 토큰을 clamp 바꾸는 순간 관계가 끊겨 h1 헤더 뒤로
들어갔다 값이 아니라 관계를 토큰으로 둬야 하는 이유다. */
--header-h: calc(var(--space-3) + var(--space-2) * 2 + var(--control-h-sm));
/* ---- 모션 ------------------------------------------------ */ /* ---- 모션 ------------------------------------------------ */
--dur-instant: 120ms; --dur-instant: 120ms;

View file

@ -428,6 +428,58 @@ JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 반영한
--- ---
## 패럴랙스는 섹션마다 다른 말을 해야 한다
같은 패럴랙스를 모든 섹션에 똑같이 걸면 그건 문법이 아니라 벽지다.
**그 섹션이 주장하는 것이 무엇인지 먼저 말하고, 그 주장을 움직임으로 옮겨라.**
실측 사례 — designpaca 소개 페이지의 섹션별 배정:
| 섹션 | 주장 | 움직임 | 왜 |
|---|---|---|---|
| 히어로 | "결정하지 않으면 기본값이 나온다" | 글자 ↑ / 배경 ↓ (**반대 방향**) | 결정이 떠오르고 기본값이 가라앉는다. 같은 방향이면 그냥 느린 스크롤로 읽힌다 |
| 파이프라인 | 단계를 밟으면 결과가 나온다 | 단계명 **고정**, 산출물만 지연 | 옆의 것이 고정이어야 "저것 때문에 이것"이 보인다. 둘 다 움직이면 인과가 사라진다 |
| 사례 | 서로 다른 두 결과물 | 두 판이 다른 속도로 어긋남 | 같은 스킬에서 다른 것이 나온다 |
| 재료 | 표면은 한 겹이 아니다 | 타일이 서로 다른 깊이 | 주장 자체가 레이어다 |
| 프리플라이트 | 시간순 기록 | 뒤 항목일수록 더 늦게 따라옴 | 로그는 흐른다 |
| 수치 | 공개한 값은 검증 가능하다 | **없음** | 숫자가 흔들리면 값도 흔들려 보인다. 계기판의 바늘은 떨지 않는다 |
**마지막 줄이 가장 중요하다.** 움직이지 않는 것도 결정이다.
안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고,
`design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.
### 구현은 scroll-driven animation 으로
```css
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.par-slow, .par-fast, .par-lag {
animation: par linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
@keyframes par {
from { transform: translate3d(0, var(--par-from), 0); }
to { transform: translate3d(0, var(--par-to), 0); }
}
}
}
```
**두 가지를 지켜라.**
1. **`transform` 만 애니메이션한다.** `opacity` 를 걸면 범위 끝에 도달하지 못한 요소가
영영 투명하게 남는다 — 지원 안 되는 브라우저에서는 제자리에 그대로 있을 뿐이지만,
`opacity` 는 콘텐츠를 지운다. 실측에서 6단계 중 3개가 그렇게 사라졌다.
2. **페이지 최상단 요소는 `view()` 가 아니라 `scroll()` 을 써라.** 히어로는 처음부터 보이므로
`entry` 구간이 이미 지나 있어 시작점이 확정되지 않는다.
`animation-timeline: scroll(root block); animation-range: 0 92vh;`
새 클래스를 만들 때 **애니메이션 셀렉터에 추가하는 것을 잊지 마라.** 변수만 정의하면
아무 일도 일어나지 않고, 조용히 실패해서 검출도 안 된다.
---
## 4. JS 스택 판단 — 언제 끌어오고 언제 안 끌어오나 ## 4. JS 스택 판단 — 언제 끌어오고 언제 안 끌어오나
``` ```

View file

@ -20,11 +20,23 @@
--- ---
## 0-A. 배치가 먼저다 — 3D 는 액자 안, 텍스트는 액자 밖 ## 0-A. 배치가 먼저다 — 이 씬은 주인공인가 재질인가
채택을 결정했다면 **어디에 놓을지부터 정해라.** 이 결정이 씬의 품질보다 결과를 크게 바꾼다. 채택을 결정했다면 **어디에 놓을지부터 정해라.** 이 결정이 씬의 품질보다 결과를 크게 바꾼다.
그리고 배치는 하나가 아니다. **먼저 이 씬의 역할을 정해야 한다.**
> **기본값: 캔버스를 배경 전면에 깔고 그 위에 본문을 얹지 마라.** | 역할 | 배치 | 언제 |
|---|---|---|
| **주인공** | 액자 안. 텍스트는 액자 밖 | 3D 자체가 상품일 때 — 포트폴리오, 프로덕트 뷰어, 3D 스튜디오 |
| **재질** | 배경 전면. 텍스트가 그 위에 앉는다 | 공간감이 목적일 때 — 랜딩, 브랜드 사이트 |
**둘을 섞지 마라.** 재질로 쓸 씬을 액자에 가두면 "저 상자는 뭐냐"는 질문을 받는다.
액자는 안에 든 것이 볼 가치가 있다고 선언하는 장치라서, 그 안에 배경 무늬가 들어 있으면
선언과 내용이 어긋난다. 실측에서 그렇게 만들었다가 되돌렸다.
### 주인공일 때 — 액자 안
> **텍스트를 3D 위에 얹지 마라.**
겹치면 두 가지가 동시에 일어난다. 3D 는 텍스트 가독성을 위해 흐리고 어둡게 눌려 얼룩이 되고, 겹치면 두 가지가 동시에 일어난다. 3D 는 텍스트 가독성을 위해 흐리고 어둡게 눌려 얼룩이 되고,
텍스트는 대비를 확보하려고 글로우·스크림·그림자를 달게 된다. **둘 다 소리치다 둘 다 죽는다.** 텍스트는 대비를 확보하려고 글로우·스크림·그림자를 달게 된다. **둘 다 소리치다 둘 다 죽는다.**
@ -47,6 +59,22 @@ h1 이 36px 이어도 히어로가 압도적이다.
캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라. 캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.
### 재질일 때 — 배경 전면
주인공이 아니라 공간을 만드는 표면이다. 규칙이 반대가 된다.
- **대비를 낮춘다.** 주인공일 때의 값을 그대로 쓰면 글자 뒤에서 형광 막대처럼 튄다.
실측 교정: 테두리 광 계수 `0.85 → 0.42`, 알파 `0.72 → 0.34`
- **경계를 푼다.** 평면의 UV 테두리를 좁게 잡으면 사각형 프레임이 또렷하게 서서
추상 패턴이 아니라 '상자'로 읽힌다. `smoothstep(0.0, 0.05, ...)``0.17` 로 넓히고 제곱해 번지게 한다
- **느리게 움직인다.** 스크롤 전 구간을 다 통과시키면 시선을 끌어 글자와 싸운다. 이동량을 60% 로 줄였다
- **글자 자리에 스크림을 깐다.** 배경이 균일하게 밝으면 본문 대비가 무너진다.
전면을 덮지 말고 **글자가 앉는 쪽만** 눌러라 — 반대쪽을 열어둬야 배경이 살아 있다
- **아래를 마스크로 지운다.** 섹션 경계에서 배경이 가로선으로 끊기면 두 장의 종이처럼 보인다.
`mask-image: linear-gradient(to bottom, black 62%, transparent 96%)`
- **캔버스 크기는 `ResizeObserver` 로 따라간다.** `window.resize` 만 들으면 폰트가 늦게 오거나
콘텐츠가 접힐 때 캔버스가 옛 크기로 남아 배경이 화면 일부만 덮는다. 그건 resize 이벤트가 아니다
--- ---
## 0. 채택 게이트 — "쓰지 않는다"를 먼저 통과시켜라 ## 0. 채택 게이트 — "쓰지 않는다"를 먼저 통과시켜라

View file

@ -165,6 +165,68 @@
--- ---
## 웨이트와 컨트롤 — 자주 빠지는 두 축
타입 스케일·색·간격은 대부분 토큰으로 잡는다. **웨이트와 컨트롤 치수는 거의 안 잡는다.**
그래서 SSOT 가 거기서 먼저 깨진다.
### 웨이트는 세 개다
```css
--weight-body: 400;
--weight-medium: 500;
--weight-strong: 600;
```
컴포넌트마다 `font-weight: 560` 같은 값을 직접 쓰기 시작하면 페이지에 웨이트가 여섯 종이 된다.
실측 사례: designpaca 소개 페이지가 `400 / 500 / 560 / 600 / 620 / 660` 을 동시에 쓰고 있었고,
그중 셋은 토큰에 없는 값이었다.
가변폰트는 660 같은 중간값도 그려준다. 그래서 더 위험하다 — **표준 축(400/500/600/700)을
벗어나면 힌팅이 흐려지고, 폴백 폰트에서는 아예 다른 굵기로 떨어진다.**
**웨이트로 위계를 만들려 들지 마라.** R1 실측(zed.dev 모바일)에서 `font-weight: 400` 이 832회,
나머지 전부 합쳐 12회였다. 위계는 크기와 색이 만든다.
### 버튼처럼 생긴 것은 전부 같은 치수를 쓴다
```css
--control-h: 2.75rem; /* 44px — 터치 타깃 */
--control-h-sm: 2.25rem; /* 36px — 헤더 등 조밀한 자리 */
--control-pad-x: var(--space-3);
```
컴포넌트마다 높이와 패딩을 다시 정하면 그때부터 SSOT 가 아니다.
실측 사례: 같은 페이지의 두 버튼이 높이 36 vs 44, 패딩 16 vs 8, 테두리 1px vs 0,
웨이트 400 vs 500 이었다. 규칙이 없으니 전부 달랐다.
반대로 R1 의 두 버튼은 `h36 · pad-x10 · r4 · w400 · 14px` 로 **픽셀 단위까지 같았다.**
### 떠 있는 헤더는 높이를 토큰으로 내놔야 한다
```css
--header-h: calc(var(--space-3) + var(--space-2) * 2 + var(--control-h-sm));
```
`position: fixed` 헤더는 문서 흐름에서 빠져 있어서, 첫 섹션의 상단 여백과 앵커 목적지
(`scroll-margin-top`)가 그 높이를 알아야 한다. 값을 각자 손으로 맞춰두면
**간격 토큰을 `clamp()` 로 바꾸는 순간 관계가 끊어져 제목이 헤더 뒤로 들어간다.**
값이 아니라 관계를 토큰으로 둬라.
### 큰 간격은 화면에 반응해야 한다
작은 값(4~24px)은 고정, 큰 값만 `clamp()`.
```css
--space-6: clamp(2.5rem, 1.6rem + 2.6vw, 4rem);
--space-7: clamp(3.5rem, 1.9rem + 4.6vw, 7rem);
--gutter: clamp(2rem, 1.4rem + 1.8vw, 2.5rem);
```
고정으로 두면 모바일에서 섹션 상하 여백 112px 이 그대로 들어가 **화면 높이의 26% 를 여백이 먹는다.**
좌우 여백도 마찬가지다 — R1 모바일 실측은 32px 이었다.
---
## 5. 성능 예산 ## 5. 성능 예산
**여기서 정한다. 구현 후에 재면 이미 늦었다.** **여기서 정한다. 구현 후에 재면 이미 늦었다.**