디자인 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

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