디자인 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 의 형태 결정
- 판 **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">
<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-icon" aria-hidden="true">
<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);
}
/* 버튼 안에 아이콘이 자기 원을 갖는다 */
/* 높이·패딩·웨이트·전환은 .btn 이 정한다. 여기서는 이 자리에만
필요한 것만 더한다 — 아이콘이 오른쪽 안쪽에 붙어야 해서 그쪽만 좁다 */
.cmd-copy {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
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);
padding-right: var(--space-2);
font-family: inherit;
}
.cmd-copy:hover { filter: brightness(1.08); }
.cmd-copy:active { transform: scale(0.975); }
.cmd-copy-icon {
display: inline-flex;

View file

@ -10,17 +10,28 @@ const { c, ui } = Astro.props;
---
{/*
구조는 R2(lusion.co) 실측에서 왔다.
3D 를 가장 잘 쓰는 스튜디오가 텍스트를 3D 위에 얹지 않는다.
액자 안은 3D, 액자 밖은 글자. 그래서 대비 문제가 생기지 않고
h1 이 36px 이어도 히어로가 압도적이다 — 힘이 타이포가 아니라 물체에서 나온다.
3D 는 배경이다. 액자에 가두지 않는다.
이전 판은 정반대였다. 캔버스를 배경 전체에 깔고 그 위에 7rem 짜리
헤드라인을 얹었다. 판 여섯 장은 흐린 얼룩이 됐고, 글자는 대비를
얻으려고 글로우를 깔아야 했다. 둘 다 소리치다 둘 다 죽었다.
한 번 액자 안에 넣었다가 되돌렸다. lusion.co 에서 가져온 "3D 는 액자 안,
텍스트는 액자 밖"은 3D 자체가 상품일 때의 규칙이다. 그 스튜디오는 3D 를
팔기 때문에 액자가 쇼케이스 프레임으로 정당하다.
배경으로 쓸 때는 규칙이 다르다 — 주인공이 아니라 공간을 만드는 재질이고,
그래서 대비를 낮추고 글자에 자리를 내준다.
처음 배경으로 깔았을 때 실패한 이유는 배치가 아니라 둘이었다.
판이 정면이라 프레넬이 0 이어서 아무것도 안 보였고(three.md §2),
헤드라인이 112px 이라 그나마 보이는 것과도 싸웠다. 둘 다 고쳤다.
*/}
<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>
<div class="hero-aside">
@ -31,71 +42,97 @@ const { c, ui } = Astro.props;
copiedLabel={ui.copied}
note={c.installNote}
/>
</div>
</div>
<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 class="hero-links">
{c.links.map((l) => <a class="link-grow" href={l.href}>{l.label}</a>)}
</p>
</div>
<figcaption>
<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>
</div>
</section>
<style>
.hero {
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;
width: 100%;
margin-inline: auto;
padding-inline: var(--space-4);
padding-inline: var(--gutter);
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: var(--space-5) var(--space-6);
/* 왼쪽 열은 h1 하나(2줄), 오른쪽은 셋이라 높이가 100px 차이 난다.
start 나 end 로 붙이면 그 차이가 한쪽에 몰려 구멍으로 보인다.
가운데로 두면 위아래로 갈려 여백으로 읽힌다. */
align-items: center;
margin-bottom: var(--space-5);
}
.hero-title {
/* 7rem 을 버렸다. 액자가 주인공이면 헤드라인은 물러난다.
두 줄을 넘기지 않는 폭이다(카운트 규칙: 히어로 헤드라인 <= 2줄) */
font-size: clamp(var(--step-2), 1.1rem + 2.4vw, var(--step-4));
font-size: clamp(var(--step-2), 1.1rem + 2.6vw, var(--step-4));
line-height: 1.12;
letter-spacing: var(--tracking-display);
max-width: 18ch;
max-width: 16ch;
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 {
margin: 0;
max-width: 30em;
color: var(--ink);
opacity: 0.82;
opacity: 0.86;
}
.hero-links {
display: flex;
@ -105,116 +142,29 @@ const { c, ui } = Astro.props;
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) {
.hero-head { grid-template-columns: 1fr; gap: var(--space-5); }
/* 4/3 이면 320px 에서 액자가 193px, 화면의 27% 다. 데스크톱이 56% 인데
모바일에서만 3D 가 조연이 될 이유가 없다. 정사각으로 36% 를 준다. */
.stage-frame { aspect-ratio: 1 / 1; }
.hero { min-height: min(94dvh, 46rem); }
.hero-grid { grid-template-columns: 1fr; gap: var(--space-5); }
.hero-title { max-width: 100%; }
/* 세로 화면에서는 글자가 위쪽 절반을 다 쓴다. 가로 방향 스크림으로는
덮이지 않으므로 위에서 아래로 누른다 */
.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>
<script>
// 판 여섯 장이 깊이 축에 서 있고 카메라가 스크롤에 맞춰 통과한다.
// 유리판이 느리게 흐르며 공간을 만든다. 배경이므로 스스로 설명하지 않는다.
// 굴절은 렌더 타겟 한 장으로 만든다 — transmission 재질보다 훨씬 싸다.
import { mountHeroScene } from "../lib/hero-scene";
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
const gateEl = document.getElementById("ro-gate");
const stateEl = document.getElementById("ro-state");
const passWord = stateEl?.textContent ?? "";
if (canvas) {
let lastGate = -1;
mountHeroScene({
canvas,
accentVar: "--accent",
glowVar: "--glow-2",
// 프레임마다 DOM 을 쓰지 않는다. 값이 바뀔 때만 쓴다.
onGate(index, total) {
if (index === lastGate) return;
lastGate = index;
if (gateEl) gateEl.textContent = String(index + 1).padStart(2, "0");
if (stateEl) stateEl.textContent = index + 1 >= total ? passWord : "···";
},
});
mountHeroScene({ canvas, accentVar: "--accent", glowVar: "--glow-2" });
}
</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-head">
<h2 id="install-h">{c.h2}</h2>
@ -46,7 +46,7 @@ const { c, ui } = Astro.props;
.install-grid {
max-width: 1180px;
margin-inline: auto;
padding-inline: var(--space-4);
padding-inline: var(--gutter);
display: grid;
gap: var(--space-6);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@ -63,7 +63,7 @@ const { c, ui } = Astro.props;
padding-block: var(--space-2);
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); }
.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>
<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>
{/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
<canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas>
@ -22,7 +22,7 @@ const { c } = Astro.props;
<ul class="tiles">
{
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>
<h3 class="tile-name">{t.name}</h3>
<p class="tile-how muted">{t.how}</p>

View file

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

View file

@ -14,7 +14,7 @@ const { c } = Astro.props;
③ 의미 — <ol role="list"> — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다
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>
<h2 id="pipeline-h" class="reveal">{c.h2}</h2>
<p class="lead pipeline-lead reveal">{c.lead}</p>
@ -33,7 +33,7 @@ const { c } = Astro.props;
<h3 class="stop-name">{s.name}</h3>
<p class="stop-does">{s.does}</p>
</div>
<p class:list={["stop-becomes", { mono: s.mono }]}>{s.becomes}</p>
<p class:list={["stop-becomes", "par-lag", { mono: s.mono }]}>{s.becomes}</p>
</li>
))
}
@ -53,7 +53,7 @@ const { c } = Astro.props;
.spine-wrap {
max-width: 1180px;
margin-inline: auto;
padding-inline: var(--space-4);
padding-inline: var(--gutter);
}
.brief { margin-bottom: var(--space-2); }

View file

@ -17,7 +17,7 @@ const { c } = Astro.props;
<div class="full log">
{
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="fixed muted">{i.fixed}</p>
</article>
@ -34,7 +34,7 @@ const { c } = Astro.props;
.log {
max-width: 1180px;
margin-inline: auto;
padding-inline: var(--space-4);
padding-inline: var(--gutter);
display: grid;
}
.entry {
@ -69,7 +69,7 @@ const { c } = Astro.props;
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); }
.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 {
max-width: 1180px;
margin: 0 auto;
padding-inline: var(--space-4);
padding-inline: var(--gutter);
}
.rule {
display: grid;
@ -49,7 +49,7 @@ const { c } = Astro.props;
dt {
font-size: var(--step-2);
font-weight: 560;
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-display);
line-height: var(--leading-tight);
}

View file

@ -10,7 +10,7 @@ const shots = [shotCoffee, shotSaas];
---
{/* 레이아웃 패밀리: 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>
<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));
max-width: 1180px;
margin-inline: auto;
padding-inline: var(--space-4);
padding-inline: var(--gutter);
align-items: start;
}
/* 두 번째 판이 아래로 내려가 계단을 만든다 */
@ -65,7 +65,7 @@ const shots = [shotCoffee, shotSaas];
margin-top: var(--space-3);
font-size: var(--step-0);
}
.work-caption { font-weight: 500; }
.work-caption { font-weight: var(--weight-medium); }
@media (max-width: 820px) {
.cascade { grid-template-columns: 1fr; }

View file

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

View file

@ -1,4 +1,5 @@
---
import { ko, en } from "../i18n/content";
import "../styles/fonts.css";
import "../styles/tokens.css";
import "../styles/base.css";
@ -18,6 +19,10 @@ const FONT_CSS = {
const alt = lang === "ko" ? "/en/" : "/";
// 헤더 내비게이션. 본문과 같은 콘텐츠 파일에서 온다 — 라벨을 레이아웃에
// 하드코딩하면 두 언어판이 어긋난다.
const nav = (lang === "ko" ? ko : en).nav;
// 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크.
// data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다.
// 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다.
@ -91,12 +96,35 @@ const FAVICON =
</svg>
<span>designpaca</span>
</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" : "한국어"}
</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>
</header>
@ -160,7 +188,7 @@ const FAVICON =
left: 0;
right: 0;
z-index: 3;
padding-inline: var(--space-4);
padding-inline: var(--gutter);
}
.header-inner {
max-width: 1180px;
@ -176,28 +204,75 @@ const FAVICON =
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-weight: 560;
font-weight: var(--weight-medium);
letter-spacing: -0.01em;
text-decoration: 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;
align-items: center;
min-height: 36px;
min-height: var(--control-h-sm);
padding-inline: var(--space-3);
border-radius: var(--radius-sm);
font-size: inherit;
background: var(--glass-tint-2);
border: 1px solid var(--glass-edge);
/* muted 로 두면 유리 위에서 2.59:1 이다. 언어 전환은 내비게이션이지
보조 정보가 아니므로 ink 로 올린다(하드 게이트 #7) */
color: var(--ink);
color: var(--ink-muted);
text-decoration: none;
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 {
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 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 b = smoothstep(0.66, 0.06, distance(p, c2) + f2 * 0.32);
float a = smoothstep(1.05, 0.05, distance(p, c1) + f1 * 0.34);
float b = smoothstep(0.95, 0.04, distance(p, c2) + f2 * 0.30);
vec3 col = uA * a + uB * b * 0.95;
float alpha = clamp(a * 1.2 + b, 0.0, 1.0) * 0.9;
vec3 col = uA * a + uB * b * 0.9;
float alpha = clamp(a * 1.15 + b, 0.0, 1.0) * 0.78;
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);
// 판의 테두리. 평면에서 두께가 보이는 곳은 법선이 아니라 UV 경계다.
// 배경이므로 경계를 넓게 풀어 선이 아니라 번짐으로 만든다 —
// 좁게 두면 사각형 프레임이 또렷하게 서서 추상 패턴이 아니라 '상자'가 된다.
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;
@ -215,8 +220,10 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
) - 0.5;
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);
}
`,
@ -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 geo = new THREE.PlaneGeometry(15, 6, 1, 1);
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);
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["sway"] = sway;
scene.add(m);
@ -264,7 +271,11 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
bgUniforms.uAspect.value = r.width / r.height;
};
fit();
addEventListener("resize", fit, { passive: true });
// window resize 만 듣던 것을 ResizeObserver 로 바꾼다.
// 히어로 높이는 폰트가 늦게 오거나 콘텐츠가 접힐 때도 바뀌는데
// 그건 resize 이벤트가 아니다 — 그때 캔버스가 옛 크기로 남아
// 배경이 화면의 3분의 2만 덮고 아래가 검게 비었다.
new ResizeObserver(fit).observe(canvas);
addEventListener(
"pointermove",
@ -309,7 +320,8 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
// 카메라가 여섯 개의 문을 차례로 통과한다
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.y = (pointer.y - 0.5) * -0.5;
camera.lookAt(camera.position.x * 0.3, 0, camera.position.z - 10);

View file

@ -72,15 +72,77 @@ body::after {
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 {
display: grid;
grid-template-columns:
[full-start] minmax(var(--space-4), 1fr)
[full-start] minmax(var(--gutter), 1fr)
[wide-start] minmax(0, var(--space-8))
[main-start] minmax(0, var(--measure)) [main-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 > .wide { grid-column: wide; }
@ -105,10 +167,10 @@ h1, h2, h3 {
margin: 0;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-display);
font-weight: 600;
font-weight: var(--weight-strong);
text-wrap: balance;
}
h1 { font-size: var(--display); font-weight: 660; }
h1 { font-size: var(--display); }
h2 { font-size: var(--step-3); }
/* 섹션 제목만 본문 정렬선 왼쪽으로 나간다. 같은 양을 페이지 전체에서 반복한다 */
@ -128,7 +190,7 @@ p:last-child { margin-bottom: 0; }
font-size: var(--step-0);
letter-spacing: var(--tracking-label);
color: var(--accent);
font-weight: 500;
font-weight: var(--weight-medium);
margin: 0 0 var(--space-3);
}
@ -306,7 +368,9 @@ a:hover { color: var(--accent); }
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.par-slow,
.par-fast {
.par-fast,
.par-deep,
.par-lag {
animation: par linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
@ -314,11 +378,41 @@ a:hover { color: var(--accent); }
}
.par-slow { --par-from: 26px; --par-to: -26px; }
.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 {
from { transform: translate3d(0, var(--par-from), 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-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-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 7rem;
--space-8: 10rem;
--space-5: clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem);
--space-6: clamp(2.5rem, 1.6rem + 2.6vw, 4rem);
--space-7: clamp(3.5rem, 1.9rem + 4.6vw, 7rem);
--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;