All checks were successful
ci / build (push) Successful in 26s
제목과 본문이 붙어 있던 문제를 고친다.
- 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 를 무시 / 눈보다 숫자)
275 lines
9.8 KiB
Text
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>
|