[fix] landing: 섹션 세로 리듬 통일 — 간격을 SectionHeading 이 소유

디자인 시스템이 색·활자만 소유하고 간격은 각 섹션 파일에 흩어져 있어,
같은 관계에 값이 여러 개 생겼다. 스크롤할 때 섹션마다 호흡이 달라지던 원인.

  eyebrow → title      12/20px      → 16px
  title → description  16/20/32px   → 20px (display 티어 32px)
  머리 → 본문          48~96px 5종  → 56px / 80px(md)
  컨트롤 → 본문        56/64px      → 48px
  리드 → CTA           56/48px      → 48px

- SectionHeading 이 리듬을 소유하고 HEADING_GAP·CONTROL_GAP·CTA_GAP·
  DISPLAY_LEAD_GAP 를 노출. 섹션 파일에서 mb-24 같은 값을 직접 쓰지 않는다.
- negotiation-demo·final-cta 가 Section/SectionHeading 을 우회해 머리를 직접
  조판하고 있었다 — 아이브로우 간격이 이 둘만 20px 이던 원인. 프리미티브로 환원.
- SectionHeading 이 eyebrow 없이도 빈 span 을 렌더해 유령 여백이 생기던 버그 수정.
- descriptionClassName 의 text-[17px] 중복 선언 제거. lead 변형이 이미 갖고 있는
  모바일 축소 단계(text-[16px] sm:text-[17px])를 지우고 있었다.
- reinforcement: text-balance 가 쉼표를 넘어 끊던 헤드라인을 <br /> 로 명시.

reinforcement 만 gap="none" 인데, 머리가 그리드 셀 안이라 마진이 상쇄되지 않아
items-center 정렬을 밀기 때문. 간격은 그리드 래퍼가 진다.

검증: tsc --noEmit 통과, 프로덕션 빌드 통과. DOM 실측으로 375/868/1440px
전 구간에서 머리→본문·eyebrow→title·컨트롤→본문 값 일치 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-08-05 16:22:24 +09:00
parent bae0da42d2
commit 55e3ab02aa
11 changed files with 149 additions and 74 deletions

View File

@ -3,7 +3,7 @@ import { AnimatePresence, motion } from "motion/react"
import { Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react" import { Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react"
import { Section } from "@/components/ui/section" import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading" import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { useFadeUp } from "@/lib/motion" import { useFadeUp } from "@/lib/motion"
@ -44,18 +44,20 @@ export function Comparison() {
return ( return (
<Section id="comparison"> <Section id="comparison">
<motion.div {...fadeUp} className="mb-12"> <motion.div {...fadeUp}>
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="Before & After" eyebrow="Before & After"
title="협상 방식을 바꾸면, 무엇이 달라질까요" title="협상 방식을 바꾸면, 무엇이 달라질까요"
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다." description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
descriptionClassName="mt-4 text-[17px] max-w-2xl" descriptionClassName="max-w-2xl"
/> />
</motion.div> </motion.div>
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */} {/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법.
<motion.div {...toggleFadeUp} className="flex justify-center mb-14"> 아래 간격(CONTROL_GAP)은 머리 간격보다 좁다. 토글은 자기가 조종하는
카드들과 한 덩어리로 읽혀야지, 제목에 붙으면 무엇을 바꾸는 스위치인지 흐려진다. */}
<motion.div {...toggleFadeUp} className={`flex justify-center ${CONTROL_GAP}`}>
{/* 액티브를 bg-white 로 두면 트랙(bg-fill #F0F2F8) 과 거의 같은 밝기라 {/* 액티브를 bg-white 로 두면 트랙(bg-fill #F0F2F8) 과 거의 같은 밝기라
멀리서 어느 쪽이 켜졌는지 안 보인다. 채움색으로 확실히 갈라준다. */} 멀리서 어느 쪽이 켜졌는지 안 보인다. 채움색으로 확실히 갈라준다. */}
<div className="p-1.5 bg-fill rounded-full inline-flex gap-1.5"> <div className="p-1.5 bg-fill rounded-full inline-flex gap-1.5">

View File

@ -43,7 +43,6 @@ export function Contact() {
<Section id="contact-section" bordered> <Section id="contact-section" bordered>
<SectionHeading <SectionHeading
align="center" align="center"
className="mb-16"
eyebrow="Get in Touch" eyebrow="Get in Touch"
title="도입 문의" title="도입 문의"
description="품목과 협력사 규모를 알려주시면, 예상 절감 구간과 도입 절차를 정리해 드립니다." description="품목과 협력사 규모를 알려주시면, 예상 절감 구간과 도입 절차를 정리해 드립니다."

View File

@ -12,13 +12,12 @@ export function CoreValues() {
return ( return (
<Section id="core-values"> <Section id="core-values">
<motion.div {...fadeUp} className="mb-24"> <motion.div {...fadeUp}>
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="Beyond Price" eyebrow="Beyond Price"
title="가격 그 이상의 이점" title="가격 그 이상의 이점"
description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다." description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다."
descriptionClassName="mt-4 text-[17px]"
/> />
</motion.div> </motion.div>

View File

@ -14,13 +14,12 @@ export function Faq() {
return ( return (
<Section id="faq" width="sm"> <Section id="faq" width="sm">
<motion.div {...fadeUp} className="mb-24"> <motion.div {...fadeUp}>
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="FAQ" eyebrow="FAQ"
title="자주 묻는 질문" title="자주 묻는 질문"
description="도입 검토에서 가장 많이 받는 질문들입니다." description="도입 검토에서 가장 많이 받는 질문들입니다."
descriptionClassName="mt-4 text-[17px]"
/> />
</motion.div> </motion.div>

View File

@ -2,7 +2,8 @@ import { motion } from "motion/react"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography" import { Section } from "@/components/ui/section"
import { CTA_GAP, SectionHeading } from "@/components/ui/section-heading"
import { useFadeUp } from "@/lib/motion" import { useFadeUp } from "@/lib/motion"
/** /**
@ -11,38 +12,44 @@ import { useFadeUp } from "@/lib/motion"
* 이 섹션은 원래 디자인 시스템을 우회하고 있었다: bg-ink(무대 색이 아님), font-extrabold, * 이 섹션은 원래 디자인 시스템을 우회하고 있었다: bg-ink(무대 색이 아님), font-extrabold,
* 알약 배지, blur(120px) 글로우 블롭, 다크 배경 위 라이트 토큰(text-ink-muted). * 알약 배지, blur(120px) 글로우 블롭, 다크 배경 위 라이트 토큰(text-ink-muted).
* 전부 토큰으로 돌렸다. 글로우 블롭은 AI 가 만든 히어로의 대표 장식이라 걷어냈다. * 전부 토큰으로 돌렸다. 글로우 블롭은 AI 가 만든 히어로의 대표 장식이라 걷어냈다.
*
* 섹션 껍데기와 머리도 손으로 짜던 걸 Section/SectionHeading 으로 돌렸다.
* 우회하는 동안 아이브로우 간격이 여기만 20px 이었다(다른 섹션은 12px).
*/ */
export function FinalCTA() { export function FinalCTA() {
const fadeUp = useFadeUp() const fadeUp = useFadeUp()
return ( return (
<section className="stage-bg text-on-stage py-24 md:py-32"> /* width="md"(max-w-4xl) — sm(max-w-3xl)이면 "조용히 새고 있을지 / 모릅니다" 로 끊겨
{/* max-w-3xl 이면 "조용히 새고 있을지 / 모릅니다" 로 끊겨 마지막 줄이 고아가 된다. */} 마지막 줄이 고아가 된다. */
<div className="max-w-4xl mx-auto px-6 text-center"> <Section bg="stage" width="md" containerClassName="text-center">
<motion.div {...fadeUp}> <motion.div {...fadeUp}>
<Typography variant="eyebrow" className="block mb-5 text-primary-on-stage"> <SectionHeading
Start Today align="center"
</Typography> tone="stage"
size="display"
gap="none"
eyebrow="Start Today"
/* 닫는 CTA 라 히어로(74px)보다는 낮추고 섹션 제목(64px)보다는 세운다. */
titleClassName="text-[32px] sm:text-[44px] md:text-[56px]"
title={
<>
<span className="text-white">지금도 구매 예산은</span>
<br />
<span className="text-on-stage-soft/60">조용히 새고 있을지 모릅니다</span>
</>
}
description="협상 테이블에 오르지 못하고 그냥 넘어가던 건들이 있습니다. negotium 에이전트가 정해진 기준 안에서 그 건들을 대신 조율합니다."
descriptionClassName="max-w-xl text-on-stage-soft/75"
/>
<Typography variant="stageDisplay" as="h2" className="text-[32px] sm:text-[44px] md:text-[56px]"> <div className={CTA_GAP}>
<span className="text-white">지금도 구매 예산은</span> <Button href="#contact-section" variant="stage" size="xl" className="group">
<br /> <span>도입 상담 신청</span>
<span className="text-on-stage-soft/60">조용히 새고 있을지 모릅니다</span> <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Typography> </Button>
</div>
<p className="mt-8 text-[16px] sm:text-[17px] text-on-stage-soft/75 max-w-xl mx-auto leading-[1.6] break-keep"> </motion.div>
협상 테이블에 오르지 못하고 그냥 넘어가던 건들이 있습니다. negotium 에이전트가 정해진 기준 안에서 그 건들을 대신 </Section>
조율합니다.
</p>
<div className="mt-12">
<Button href="#contact-section" variant="stage" size="xl" className="group">
<span>도입 상담 신청</span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</div>
</motion.div>
</div>
</section>
) )
} }

View File

@ -2,6 +2,7 @@ import { useRef, useState } from "react"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { CTA_GAP, DISPLAY_LEAD_GAP } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { CLUSTER_ANCHORS, DataFlowCanvas, type DataFlowPhase } from "@/components/ui/data-flow-canvas" import { CLUSTER_ANCHORS, DataFlowCanvas, type DataFlowPhase } from "@/components/ui/data-flow-canvas"
@ -55,12 +56,14 @@ export function HeroDataFlow() {
<span className="text-on-stage-soft/60">동시에 협상합니다.</span> <span className="text-on-stage-soft/60">동시에 협상합니다.</span>
</Typography> </Typography>
<p className="mt-11 text-base sm:text-lg text-on-stage-soft leading-relaxed break-keep max-w-2xl mx-auto"> {/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은
같은 상수를 물려 최종 CTA 와 리듬이 어긋나지 않게 한다. */}
<p className={`${DISPLAY_LEAD_GAP} text-base sm:text-lg text-on-stage-soft leading-relaxed break-keep max-w-2xl mx-auto`}>
품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에 품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에
최저 투찰가로 낙찰까지 판정합니다. 최저 투찰가로 낙찰까지 판정합니다.
</p> </p>
<div className="mt-14 flex flex-col sm:flex-row gap-3 justify-center"> <div className={`${CTA_GAP} flex flex-col sm:flex-row gap-3 justify-center`}>
<Button href="#how-it-works" variant="stage" size="stageRound" className="group"> <Button href="#how-it-works" variant="stage" size="stageRound" className="group">
<span>직접 협상해 보기</span> <span>직접 협상해 보기</span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" /> <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />

View File

@ -4,7 +4,7 @@ import { Check, Monitor, Smartphone, type LucideIcon } from "lucide-react"
import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups" import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups"
import { Section } from "@/components/ui/section" import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading" import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */ /** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
@ -17,15 +17,14 @@ export function HowItWorksDemo() {
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden"> <Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
<SectionHeading <SectionHeading
align="center" align="center"
className="mb-20"
eyebrow="How it Works" eyebrow="How it Works"
title="견적 생성부터 낙찰까지 한눈에 보기" title="견적 생성부터 낙찰까지 한눈에 보기"
description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다." description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다."
descriptionClassName="text-[17px] max-w-2xl" descriptionClassName="max-w-2xl"
/> />
{/* 탭 스위치 */} {/* 탭 스위치 — 비교 섹션 토글과 같은 간격 규칙(본문 쪽에 묶인다) */}
<div className="flex justify-center mb-16"> <div className={`flex justify-center ${CONTROL_GAP}`}>
<div className="p-1 bg-fill rounded-control inline-flex flex-wrap justify-center gap-1"> <div className="p-1 bg-fill rounded-control inline-flex flex-wrap justify-center gap-1">
{DEMO_TABS.map((tab) => ( {DEMO_TABS.map((tab) => (
<button <button

View File

@ -3,6 +3,8 @@ import { motion } from "motion/react"
import { ArrowRight, RotateCcw } from "lucide-react" import { ArrowRight, RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion" import { EASE_OUT_EXPO } from "@/lib/motion"
import { import {
@ -122,20 +124,24 @@ export function NegotiationDemo() {
const sliderMax = role === "seller" ? item.listPrice : item.marketLow const sliderMax = role === "seller" ? item.listPrice : item.marketLow
return ( return (
<section id="how-it-works" className="stage-bg text-on-stage py-24 md:py-32"> <Section id="how-it-works" bg="stage" width="lg">
<div className="max-w-5xl mx-auto px-6"> {/* 머리는 손으로 짜지 않고 SectionHeading 을 쓴다. 예전엔 여기서 직접 조판하느라
<div className="text-center mb-14 md:mb-20"> 아이브로우 간격만 20px(다른 섹션은 12px)이었다 — 프리미티브를 우회하면
<Typography variant="eyebrow" className="block mb-5 text-primary-on-stage"> 같은 관계에 두 번째 값이 생긴다. */}
Try it Yourself {/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
</Typography> 자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */}
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다. <SectionHeading
자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */} align="center"
<Typography variant="title" as="h2"> tone="stage"
<span className="text-white">직접 협상해 보세요.</span> eyebrow="Try it Yourself"
<br /> title={
<span className="text-on-stage-soft/60">에이전트는 정해진 기준을 벗어나지 않습니다.</span> <>
</Typography> <span className="text-white">직접 협상해 보세요.</span>
</div> <br />
<span className="text-on-stage-soft/60">에이전트는 정해진 기준을 벗어나지 않습니다.</span>
</>
}
/>
{/* AnimatePresence mode="wait" 를 쓰지 않는다. 나가는 애니메이션이 끝나야 다음 {/* AnimatePresence mode="wait" 를 쓰지 않는다. 나가는 애니메이션이 끝나야 다음
화면이 마운트돼서 클릭 후 0.8초가 빈다 — 역할 선택처럼 즉시 반응해야 하는 화면이 마운트돼서 클릭 후 0.8초가 빈다 — 역할 선택처럼 즉시 반응해야 하는
@ -335,8 +341,7 @@ export function NegotiationDemo() {
<p className="mt-12 text-center text-[13px] text-on-stage-soft/40"> <p className="mt-12 text-center text-[13px] text-on-stage-soft/40">
실제 판정 규칙(앵커링가·협상 카드 3장·개찰)을 그대로 옮긴 데모입니다. 금액은 예시입니다. 실제 판정 규칙(앵커링가·협상 카드 3장·개찰)을 그대로 옮긴 데모입니다. 금액은 예시입니다.
</p> </p>
</div> </Section>
</section>
) )
} }

View File

@ -2,7 +2,7 @@ import { motion } from "motion/react"
import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react" import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react"
import { Section } from "@/components/ui/section" import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading" import { HEADING_GAP, SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { OrbitRing } from "@/components/ui/orbit-ring" import { OrbitRing } from "@/components/ui/orbit-ring"
import { useFadeUp } from "@/lib/motion" import { useFadeUp } from "@/lib/motion"
@ -15,13 +15,25 @@ export function Reinforcement() {
<Section id="reinforcement" bg="stage" width="lg"> <Section id="reinforcement" bg="stage" width="lg">
{/* 순환 링은 원래 히어로에 있었는데, 루프가 의미상 속하는 자리는 여기다 — {/* 순환 링은 원래 히어로에 있었는데, 루프가 의미상 속하는 자리는 여기다 —
"협상할수록 좋아진다"를 말하는 섹션이고, 히어로는 이미 세로 예산이 포화였다. */} "협상할수록 좋아진다"를 말하는 섹션이고, 히어로는 이미 세로 예산이 포화였다. */}
<motion.div {...fadeUp} className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center mb-20"> {/* 머리가 그리드 셀 안에 들어가 있어서 여기만 gap="none" 이다. 그리드 아이템은
독립 서식 문맥이라 마진이 상쇄되지 않고, 머리에 mb 를 걸면 items-center 의
세로 정렬을 밀어버린다. 섹션 간격은 그리드 래퍼가 대신 진다. */}
<motion.div {...fadeUp} className={`grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center ${HEADING_GAP}`}>
<div className="lg:col-span-7"> <div className="lg:col-span-7">
<SectionHeading <SectionHeading
className="text-center lg:text-left" className="text-center lg:text-left"
gap="none"
tone="stage" tone="stage"
eyebrow="Reinforcement Learning" eyebrow="Reinforcement Learning"
title="협상할수록, 더 좋은 조건으로" /* 쉼표에서 줄을 명시적으로 끊는다. break-keep + text-balance 에 맡기면
두 줄 길이를 맞추려고 "협상할수록, 더 / 좋은 조건으로" 처럼 쉼표를
넘어가서 끊어진다 — 조건절과 결과절이 한 줄에 섞여 의미가 흐려진다. */
title={
<>
협상할수록,
<br />더 좋은 조건으로
</>
}
description={ description={
<> <>
잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과 잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과
@ -29,7 +41,6 @@ export function Reinforcement() {
받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다. 받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
</> </>
} }
descriptionClassName="mt-8"
/> />
</div> </div>
<div className="lg:col-span-5 flex justify-center"> <div className="lg:col-span-5 flex justify-center">

View File

@ -24,7 +24,6 @@ export function ROISimulator() {
<Section id="benchmarks" width="lg" bordered className="border-b border-line"> <Section id="benchmarks" width="lg" bordered className="border-b border-line">
<SectionHeading <SectionHeading
align="center" align="center"
className="mb-24"
eyebrow="ROI Simulation" eyebrow="ROI Simulation"
title={ title={
<> <>
@ -38,7 +37,7 @@ export function ROISimulator() {
연간 구매 예산과 공급사 수만 입력해 보수적인 시뮬레이션 성과를 즉시 시각화해 보세요. 연간 구매 예산과 공급사 수만 입력해 보수적인 시뮬레이션 성과를 즉시 시각화해 보세요.
</> </>
} }
descriptionClassName="mt-4 text-[17px] max-w-2xl" descriptionClassName="max-w-2xl"
/> />
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start">

View File

@ -10,7 +10,45 @@ import { cn } from "@/lib/utils"
* 레퍼런스에서는 영문 대문자라 조판 요소로 기능하지만, 한글은 대문자가 없어서 * 레퍼런스에서는 영문 대문자라 조판 요소로 기능하지만, 한글은 대문자가 없어서
* 그냥 "작은 글씨"가 되고 일곱 번 반복되면 벽지가 된다. * 그냥 "작은 글씨"가 되고 일곱 번 반복되면 벽지가 된다.
* 위계는 라벨이 아니라 헤드라인 크기(본문의 3.5~4배)와 2톤 대비로 만든다. * 위계는 라벨이 아니라 헤드라인 크기(본문의 3.5~4배)와 2톤 대비로 만든다.
*
* ── 세로 리듬 ────────────────────────────────────────────────────────
* 머리 안팎의 간격은 전부 이 파일이 소유한다. 섹션 파일에서 각자 mb-12 / mb-16 /
* mb-20 / mb-24 를 붙이던 걸 걷어낸 결과다. 같은 관계(머리 → 본문)에 값이 다섯 개
* 있으면 스크롤할 때 섹션마다 호흡이 달라지고, 그게 "정돈이 안 됐다"는 인상의
* 실제 원인이었다.
*
* 규칙은 하나다 — 간격이 위계를 만든다. 가까울수록 한 덩어리로 읽힌다.
* 값이 흔들리면 "어디까지가 한 덩어리인가"가 섹션마다 달라져 보인다.
*
* eyebrow → title 16px 라벨은 제목에 붙는다
* title → description 20 / 32px 큰 활자는 더 벌린다. 64px 제목 아래 20px 은 붙어 보인다
* 머리 → 본문 56 / 80px 섹션 안에서 가장 큰 끊김
*
* 섹션 파일에서 이 관계들에 mb-· mt- 유틸리티를 직접 쓰지 않는다. 필요하면 아래 상수를
* import 해서 쓰고, 새 값이 필요하다고 느껴지면 여기에 이름을 붙여서 추가한다.
* 눈대중으로 한 칸씩 조정하기 시작하면 다시 지금 상태로 돌아온다.
*/ */
/** 머리 → 본문. 머리를 직접 조판하는 섹션(무대 데모)도 이 상수를 쓴다. */
export const HEADING_GAP = "mb-14 md:mb-20"
/** 탭·토글 행 → 본문. 머리 간격보다 좁아서 컨트롤이 위가 아니라 본문 쪽에 묶인다. */
export const CONTROL_GAP = "mb-12"
/** 리드 문단 → 버튼 행 (히어로·최종 CTA). */
export const CTA_GAP = "mt-12"
/** display 티어 제목 → 리드 문단. 히어로는 h1 이라 SectionHeading 을 못 쓰고 이 상수를 직접 쓴다. */
export const DISPLAY_LEAD_GAP = "mt-8"
/*
* 제목 조판 티어. 티어가 바뀌면 리드 문단까지의 간격도 같이 움직인다 —
* Typography 주석의 "크기·굵기·조임은 같이 움직인다"와 같은 이유로, 간격도 딸려간다.
*/
const SIZES = {
/** 섹션 표준 h2 (34 / 64px) */
title: { variant: "title", descriptionGap: "mt-5" },
/** 히어로·최종 CTA 급 디스플레이 (38~74px) */
display: { variant: "stageDisplay", descriptionGap: DISPLAY_LEAD_GAP },
} as const
type SectionHeadingProps = { type SectionHeadingProps = {
eyebrow?: string eyebrow?: string
title: React.ReactNode title: React.ReactNode
@ -18,8 +56,14 @@ type SectionHeadingProps = {
align?: "left" | "center" align?: "left" | "center"
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */ /** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
tone?: "light" | "stage" tone?: "light" | "stage"
/** 제목 조판 티어. display 는 히어로·최종 CTA 전용. */
size?: keyof typeof SIZES
/** 머리 아래 표준 간격을 끈다. 뒤에 컨트롤 행이 붙는 섹션에서만 쓴다. */
gap?: "heading" | "none"
className?: string className?: string
/** 리드 문단 폭 제한 등 (예: "max-w-2xl") */ /** 제목 크기 미세조정 (예: 최종 CTA 의 축소 디스플레이) */
titleClassName?: string
/** 리드 문단 폭 제한·색 조정 (예: "max-w-2xl"). 세로 간격은 여기서 주지 않는다. */
descriptionClassName?: string descriptionClassName?: string
} }
@ -29,23 +73,31 @@ function SectionHeading({
description, description,
align = "left", align = "left",
tone = "light", tone = "light",
size = "title",
gap = "heading",
className, className,
titleClassName,
descriptionClassName, descriptionClassName,
}: SectionHeadingProps) { }: SectionHeadingProps) {
const onStage = tone === "stage" const onStage = tone === "stage"
const { variant, descriptionGap } = SIZES[size]
return ( return (
<div className={cn(align === "center" && "text-center", className)}> <div className={cn(align === "center" && "text-center", gap === "heading" && HEADING_GAP, className)}>
<Typography variant="eyebrow" className={cn("mb-3 block", onStage && "text-primary-on-stage")}> {/* eyebrow 가 없을 때 빈 span 을 렌더하면 mb + 30px 행간만큼 유령 여백이 생긴다.
{eyebrow} 지금은 모든 섹션이 eyebrow 를 넘기지만, 하나만 빼도 그 섹션만 제목이 내려앉는다. */}
</Typography> {eyebrow && (
<Typography variant="title" className={cn(onStage && "text-on-stage")}> <Typography variant="eyebrow" className={cn("mb-4 block", onStage && "text-primary-on-stage")}>
{eyebrow}
</Typography>
)}
<Typography variant={variant} as="h2" className={cn(onStage && "text-on-stage", titleClassName)}>
{title} {title}
</Typography> </Typography>
{description && ( {description && (
<Typography <Typography
variant="lead" variant="lead"
className={cn("mt-5", align === "center" && "mx-auto", onStage && "text-on-stage-soft", descriptionClassName)} className={cn(descriptionGap, align === "center" && "mx-auto", onStage && "text-on-stage-soft", descriptionClassName)}
> >
{description} {description}
</Typography> </Typography>