[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 { 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 { useFadeUp } from "@/lib/motion"
@ -44,18 +44,20 @@ export function Comparison() {
return (
<Section id="comparison">
<motion.div {...fadeUp} className="mb-12">
<motion.div {...fadeUp}>
<SectionHeading
align="center"
eyebrow="Before & After"
title="협상 방식을 바꾸면, 무엇이 달라질까요"
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
descriptionClassName="mt-4 text-[17px] max-w-2xl"
descriptionClassName="max-w-2xl"
/>
</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) 과 거의 같은 밝기라
멀리서 어느 쪽이 켜졌는지 안 보인다. 채움색으로 확실히 갈라준다. */}
<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>
<SectionHeading
align="center"
className="mb-16"
eyebrow="Get in Touch"
title="도입 문의"
description="품목과 협력사 규모를 알려주시면, 예상 절감 구간과 도입 절차를 정리해 드립니다."

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { useRef, useState } from "react"
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { CTA_GAP, DISPLAY_LEAD_GAP } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
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>
</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>
<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">
<span>직접 협상해 보기</span>
<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 { 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"
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
@ -17,15 +17,14 @@ export function HowItWorksDemo() {
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
<SectionHeading
align="center"
className="mb-20"
eyebrow="How it Works"
title="견적 생성부터 낙찰까지 한눈에 보기"
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">
{DEMO_TABS.map((tab) => (
<button

View File

@ -3,6 +3,8 @@ import { motion } from "motion/react"
import { ArrowRight, RotateCcw } from "lucide-react"
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 { EASE_OUT_EXPO } from "@/lib/motion"
import {
@ -122,20 +124,24 @@ export function NegotiationDemo() {
const sliderMax = role === "seller" ? item.listPrice : item.marketLow
return (
<section id="how-it-works" className="stage-bg text-on-stage py-24 md:py-32">
<div className="max-w-5xl mx-auto px-6">
<div className="text-center mb-14 md:mb-20">
<Typography variant="eyebrow" className="block mb-5 text-primary-on-stage">
Try it Yourself
</Typography>
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */}
<Typography variant="title" as="h2">
<span className="text-white">직접 협상해 보세요.</span>
<br />
<span className="text-on-stage-soft/60">에이전트는 정해진 기준을 벗어나지 않습니다.</span>
</Typography>
</div>
<Section id="how-it-works" bg="stage" width="lg">
{/* 머리는 손으로 짜지 않고 SectionHeading 을 쓴다. 예전엔 여기서 직접 조판하느라
아이브로우 간격만 20px(다른 섹션은 12px)이었다 — 프리미티브를 우회하면
같은 관계에 두 번째 값이 생긴다. */}
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */}
<SectionHeading
align="center"
tone="stage"
eyebrow="Try it Yourself"
title={
<>
<span className="text-white">직접 협상해 보세요.</span>
<br />
<span className="text-on-stage-soft/60">에이전트는 정해진 기준을 벗어나지 않습니다.</span>
</>
}
/>
{/* AnimatePresence mode="wait" 를 쓰지 않는다. 나가는 애니메이션이 끝나야 다음
화면이 마운트돼서 클릭 후 0.8초가 빈다 — 역할 선택처럼 즉시 반응해야 하는
@ -335,8 +341,7 @@ export function NegotiationDemo() {
<p className="mt-12 text-center text-[13px] text-on-stage-soft/40">
실제 판정 규칙(앵커링가·협상 카드 3장·개찰)을 그대로 옮긴 데모입니다. 금액은 예시입니다.
</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 { 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 { OrbitRing } from "@/components/ui/orbit-ring"
import { useFadeUp } from "@/lib/motion"
@ -15,13 +15,25 @@ export function Reinforcement() {
<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">
<SectionHeading
className="text-center lg:text-left"
gap="none"
tone="stage"
eyebrow="Reinforcement Learning"
title="협상할수록, 더 좋은 조건으로"
/* 쉼표에서 줄을 명시적으로 끊는다. break-keep + text-balance 에 맡기면
두 줄 길이를 맞추려고 "협상할수록, 더 / 좋은 조건으로" 처럼 쉼표를
넘어가서 끊어진다 — 조건절과 결과절이 한 줄에 섞여 의미가 흐려진다. */
title={
<>
협상할수록,
<br />더 좋은 조건으로
</>
}
description={
<>
잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과
@ -29,7 +41,6 @@ export function Reinforcement() {
받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
</>
}
descriptionClassName="mt-8"
/>
</div>
<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">
<SectionHeading
align="center"
className="mb-24"
eyebrow="ROI Simulation"
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">

View File

@ -10,7 +10,45 @@ import { cn } from "@/lib/utils"
* 레퍼런스에서는 영문 대문자라 조판 요소로 기능하지만, 한글은 대문자가 없어서
* 그냥 "작은 글씨"가 되고 일곱 번 반복되면 벽지가 된다.
* 위계는 라벨이 아니라 헤드라인 크기(본문의 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 = {
eyebrow?: string
title: React.ReactNode
@ -18,8 +56,14 @@ type SectionHeadingProps = {
align?: "left" | "center"
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
tone?: "light" | "stage"
/** 제목 조판 티어. display 는 히어로·최종 CTA 전용. */
size?: keyof typeof SIZES
/** 머리 아래 표준 간격을 끈다. 뒤에 컨트롤 행이 붙는 섹션에서만 쓴다. */
gap?: "heading" | "none"
className?: string
/** 리드 문단 폭 제한 등 (예: "max-w-2xl") */
/** 제목 크기 미세조정 (예: 최종 CTA 의 축소 디스플레이) */
titleClassName?: string
/** 리드 문단 폭 제한·색 조정 (예: "max-w-2xl"). 세로 간격은 여기서 주지 않는다. */
descriptionClassName?: string
}
@ -29,23 +73,31 @@ function SectionHeading({
description,
align = "left",
tone = "light",
size = "title",
gap = "heading",
className,
titleClassName,
descriptionClassName,
}: SectionHeadingProps) {
const onStage = tone === "stage"
const { variant, descriptionGap } = SIZES[size]
return (
<div className={cn(align === "center" && "text-center", className)}>
<Typography variant="eyebrow" className={cn("mb-3 block", onStage && "text-primary-on-stage")}>
{eyebrow}
</Typography>
<Typography variant="title" className={cn(onStage && "text-on-stage")}>
<div className={cn(align === "center" && "text-center", gap === "heading" && HEADING_GAP, className)}>
{/* eyebrow 가 없을 때 빈 span 을 렌더하면 mb + 30px 행간만큼 유령 여백이 생긴다.
지금은 모든 섹션이 eyebrow 를 넘기지만, 하나만 빼도 그 섹션만 제목이 내려앉는다. */}
{eyebrow && (
<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}
</Typography>
{description && (
<Typography
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}
</Typography>