designpaca/apps/site/src/components/Pipeline.astro
Yun Chan f7f811ab42
All checks were successful
ci / build (push) Successful in 26s
fix(site): 제목-본문 간격을 전역 하나로 통일하고 스파인 리듬을 토큰화
제목과 본문이 붙어 있던 문제를 고친다.

- h2{margin:0} + p{margin-top:0} 이라 제목 다음 간격이 0px 이었다.
  섹션마다 각자 margin-top 을 붙여 어떤 곳은 0, 어떤 곳은 24px 로 갈려 있었다.
  base.css 에서 한 번만 정하고 각 섹션의 margin-top 오버라이드를 걷어냈다.
- :where() 로 감싼 첫 시도는 특이도 0 이라 p 규칙에 져서 무효였다.
  특이도를 갖추고 p 뒤로 옮겼다.
- 제목을 .head 로 감싼 수치 섹션은 인접 형제가 끊겨 혼자 0px 이었다.
  .head 도 규칙에 포함했다.

파이프라인 정거장 간격이 224px 로 벌어져 목록이 흩어져 보이던 문제도 고친다.

- 읽기 간격과 sticky 스크롤 구간을 같은 값으로 맞추려던 것이 원인이다.
  간격은 --stop-pad(space-5)로 되돌리고, 스크롤 구간은 .after 뒤 여백으로 옮겼다.
  게이트당 237px 확보 — 과민 반응 기준(200px) 위다.
- 마커 원의 top 이 space-4, 패딩이 space-6 로 따로 적혀 있어 원이 제목보다
  24px 위에 떠 있었다. 셋 다 --stop-pad 를 보게 했다.

3D 판 위 본문 대비는 실측 결과 최저 9.83:1 로 AAA 를 넘어 손대지 않았다.

스킬 내재화:
- tokens.md  제목 다음 간격 SSOT, :where() 특이도 함정, 리듬은 지역 토큰으로
- motion.md  읽기 간격과 스크롤 구간은 같은 손잡이가 아니다
- antipatterns.md  재는 도구가 거짓말하는 세 방식
  (reveal 중 측정 / sharp stats 가 extract 를 무시 / 눈보다 숫자)
2026-08-20 22:29:29 +09:00

275 lines
9.8 KiB
Text

---
import type { Content } from "../i18n/content";
interface Props {
c: Content["pipeline"];
}
const { c } = Astro.props;
---
{/*
순서를 번호 없이 표현하는 세 층:
① 시각 — 스파인 위의 위치가 순서를 만든다 (Beck 튜브맵)
② 내용 — 같은 브리프가 단계마다 변해간다 (IKEA 조립 설명서)
③ 의미 — <ol role="list"> — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다
role="list" 는 list-style:none 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 되돌린다
*/}
<section id="pipeline" class="section has-bg" aria-labelledby="pipeline-h">
{/* 히어로에 있던 게이트 씬을 여기로 옮겼다.
히어로에서는 그 은유를 판독기가 자막으로 설명해야 했다 — 그림이 스스로
말하지 못한다는 뜻이다. 여기서는 옆에 실제 여섯 단계가 적혀 있고,
판을 통과할 때 그 단계가 켜진다. 자막이 아니라 콘텐츠가 반응한다. */}
<div class="gate-bg" aria-hidden="true">
<div class="gate-fallback"></div>
<canvas id="pipeline-canvas" class="gate-canvas"></canvas>
</div>
{/* 제목·리드·브리프·스파인을 한 컨테이너에 담는다.
섹션 기본 그리드에 두면 제목은 main 칼럼의 음수 마진으로 x=371,
리드는 x=435, 스파인은 x=143 에 서서 정렬선이 셋이 된다. */}
<div class="full spine-wrap">
<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>
<div class="brief reveal"><div class="brief-core">
<span class="brief-label">{c.briefLabel}</span>
<p class="brief-text">{c.brief}</p>
</div></div>
<ol role="list" class="spine">
{
c.stages.map((s, i) => (
<li class="stop reveal" data-stage={i}>
<div class="stop-head">
<h3 class="stop-name">{s.name}</h3>
<p class="stop-does">{s.does}</p>
</div>
<p class:list={["stop-becomes", "par-lag", { mono: s.mono }]}>{s.becomes}</p>
</li>
))
}
</ol>
<p class="after muted reveal">{c.after}</p>
</div>
</section>
<style>
/* 컨테이너는 넓어도 읽는 것은 measure 안에 둔다 */
.pipeline-lead {
margin-bottom: var(--space-5);
max-width: var(--measure);
}
.brief { max-width: var(--measure); }
#pipeline-h { max-width: 24ch; }
/* ---- 게이트 배경 ------------------------------------------
sticky 로 화면에 머물고 음수 마진으로 레이아웃 자리는 차지하지 않는다.
섹션이 지나가는 동안 카메라가 판 여섯 장을 통과한다. */
.gate-bg {
position: sticky;
top: 0;
height: 100dvh;
/* 섹션 패딩(112px) 아래에서 시작하면 히어로 배경이 끝난 자리와
이 캔버스 사이에 200px 짜리 검은 띠가 생긴다. 패딩만큼 끌어올린다. */
margin-top: calc(var(--space-7) * -1);
margin-bottom: -100dvh;
z-index: -1;
pointer-events: none;
grid-column: full;
}
.gate-fallback,
.gate-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.gate-fallback {
background:
radial-gradient(46% 54% at 28% 34%,
color-mix(in oklab, var(--accent) 20%, transparent), transparent 70%),
radial-gradient(42% 50% at 76% 68%,
color-mix(in oklab, var(--glow-2) 18%, transparent), transparent 68%);
}
/* 캔버스가 섹션 패딩 위까지 덮으면 이웃 섹션과 딱 붙어 보인다.
위아래를 지워 여백을 되돌린다. */
.gate-bg {
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 6%, black 88%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 6%, black 88%, transparent 100%);
}
.gate-canvas { opacity: 0; transition: opacity 1s var(--ease-soft); }
.gate-canvas.is-live { opacity: 0.7; }
/* 세로 화면에서는 캔버스가 화면 전체를 덮는다. 같은 밝기면 본문이 묻힌다 */
@media (max-width: 760px) {
.gate-canvas.is-live { opacity: 0.45; }
}
/* 통과한 단계가 켜진다. 3D 와 텍스트를 잇는 유일한 연결이다 */
.stop { transition: opacity var(--dur-quick) var(--ease-out); }
.stop.is-active .stop-name { color: var(--accent); }
.stop.is-active::before {
background: var(--accent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 22%, transparent);
}
/* 스파인은 페이지의 본체다. main 칼럼(561px)에 가두면
페이지에서 가장 긴 섹션이 가장 좁아진다.
R1(zed.dev) 실측: 컨테이너 1120px 과 전체폭이 교대한다. */
.spine-wrap {
max-width: 1180px;
margin-inline: auto;
padding-inline: var(--gutter);
}
.brief { margin-bottom: var(--space-2); }
.brief-core { padding: var(--space-4); }
.brief-label {
display: block;
font-size: var(--step-0);
letter-spacing: var(--tracking-label);
color: var(--ink-muted);
margin-bottom: var(--space-2);
}
.brief-text {
margin: 0;
font-size: var(--step-1);
}
/* ---- 스파인 ---------------------------------------------
선은 장식이 아니라 순서 그 자체다. 정거장의 위치가 번호를 대신한다.
---------------------------------------------------------- */
.spine {
/* 정거장의 세로 리듬은 여기 한 곳에서 나온다.
전에는 패딩이 space-6, 마커 위치가 space-4 로 각자 적혀 있어서
원이 제목보다 24px 위에 떠 있었다. 값을 각자 쓰면 이렇게 어긋난다. */
--stop-pad: var(--space-5);
list-style: none;
margin: 0;
padding: 0 0 0 var(--space-5);
position: relative;
}
.spine::before {
content: "";
position: absolute;
left: 5px;
top: var(--stop-pad);
bottom: var(--stop-pad);
width: 1px;
background: var(--line-strong);
}
.stop {
position: relative;
/* 정거장 간격이 곧 3D 게이트의 스크롤 구간이다.
space-4(24px)로는 섹션이 1493px 밖에 안 돼 sticky 이동 거리가 593px 뿐이었다.
섹션이 화면에 보이는 구간(2393px)의 4분의 1이라, 조금만 스크롤해도
여섯 게이트를 다 지나쳐 버렸다.
space-7 까지 올렸더니 이번엔 정거장 사이가 224px 로 벌어져
목록이 흩어져 보였다 — 여섯 개가 한 목록으로 안 읽힌다.
읽기 간격과 스크롤 구간은 같은 손잡이가 아니다.
간격은 읽기에 맞추고, 스크롤 구간은 스파인 뒤 여백(.after)에서 번다. */
padding-block: var(--stop-pad);
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: var(--space-5);
align-items: start;
}
.stop-head { min-width: 0; }
.stop + .stop {
border-top: 1px solid var(--line);
}
/* 정거장 표시 — 채워진 원. 숫자가 아니다 */
.stop::before {
content: "";
position: absolute;
left: calc(var(--space-5) * -1);
top: calc(var(--stop-pad) + 0.55em);
width: 11px;
height: 11px;
border-radius: var(--radius-sm);
background: var(--surface);
border: 3px solid var(--accent);
box-sizing: border-box;
}
.stop-name {
font-size: var(--step-1);
margin-bottom: var(--space-2);
}
.stop-does {
color: var(--ink-muted);
margin: 0;
max-width: 34ch;
}
/* 브리프가 이 단계에서 무엇이 됐는가 — 변화가 순서를 만든다 */
.stop-becomes {
margin: 0;
padding-left: var(--space-3);
border-left: 2px solid var(--accent);
}
@media (prefers-reduced-motion: no-preference) {
.stop::before {
transition: transform var(--dur-quick) var(--ease-out);
}
.stop:hover::before { transform: scale(1.5); }
.stop-becomes { transition: border-color var(--dur-quick) var(--ease-out); }
.stop:hover .stop-becomes { border-color: var(--ink); }
}
.stop-becomes.mono {
font-family: var(--font-mono);
font-size: var(--mono-size);
overflow-wrap: anywhere;
}
.after {
margin-top: var(--space-7);
max-width: var(--measure);
/* 여기가 스크롤 구간을 버는 자리다. 정거장 간격을 벌리는 대신
마지막 정거장 뒤에 여운을 둔다 — 스파인이 끝난 뒤라 빈 공간으로 읽히지 않는다 */
padding-bottom: 62dvh;
}
@media (max-width: 780px) {
.stop {
grid-template-columns: 1fr;
gap: var(--space-3);
}
}
/* 스크롤 진입 페이드를 넣었다가 뺐다.
`animation-range: entry 10% cover 22%` 로는 페이지 하단 요소가 cover 22% 에
영영 도달하지 못해, 6개 정거장 중 뒤 3개가 opacity 0 으로 잠겼다.
이펙트를 끄면 보이지만 켜면 사라지는 것은 폴백이 있는 것과 다르다.
이 섹션은 페이지의 본체다. 콘텐츠를 걸고 하는 연출은 하지 않는다. */
</style>
<script>
// 게이트를 통과할 때 해당 단계를 켠다. 값이 바뀔 때만 DOM 을 쓴다.
import { mountGateScene } from "../lib/gate-scene";
const canvas = document.getElementById("pipeline-canvas") as HTMLCanvasElement | null;
const section = document.getElementById("pipeline");
const stops = [...document.querySelectorAll<HTMLElement>("#pipeline .stop")];
if (canvas && section && stops.length) {
let last = -1;
mountGateScene({
canvas,
accentVar: "--accent",
glowVar: "--glow-2",
// 캔버스는 sticky 라 rect 가 변하지 않는다. 진행도는 섹션에서 읽는다.
progressFrom: section,
onGate(index) {
if (index === last) return;
last = index;
stops.forEach((el, i) => el.classList.toggle("is-active", i === index));
},
});
}
</script>