디자인 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:
parent
4b853355e3
commit
9c9ab2de7e
18 changed files with 606 additions and 231 deletions
|
|
@ -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 과 일치). 첫 판이 화면을 완전히 덮어 이미 첫 게이트 안에 들어와 있다
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 유틸 -------------------------------------------------- */
|
/* ---- 유틸 -------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 스택 판단 — 언제 끌어오고 언제 안 끌어오나
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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. 채택 게이트 — "쓰지 않는다"를 먼저 통과시켜라
|
||||||
|
|
|
||||||
|
|
@ -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. 성능 예산
|
||||||
|
|
||||||
**여기서 정한다. 구현 후에 재면 이미 늦었다.**
|
**여기서 정한다. 구현 후에 재면 이미 늦었다.**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue