o2o-negosium-original/landing/app/components/ui/section-heading.tsx
Haewon Kam 55e3ab02aa [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>
2026-08-05 16:22:24 +09:00

110 lines
4.9 KiB
TypeScript

import * as React from "react"
import { Typography } from "@/components/ui/typography"
import { cn } from "@/lib/utils"
/*
* 섹션 머리.
*
* eyebrow 는 선택이고, 기본적으로 쓰지 않는다. 작은 색 라벨을 섹션마다 반복하면
* 레퍼런스에서는 영문 대문자라 조판 요소로 기능하지만, 한글은 대문자가 없어서
* 그냥 "작은 글씨"가 되고 일곱 번 반복되면 벽지가 된다.
* 위계는 라벨이 아니라 헤드라인 크기(본문의 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
description?: React.ReactNode
align?: "left" | "center"
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
tone?: "light" | "stage"
/** 제목 조판 티어. display 는 히어로·최종 CTA 전용. */
size?: keyof typeof SIZES
/** 머리 아래 표준 간격을 끈다. 뒤에 컨트롤 행이 붙는 섹션에서만 쓴다. */
gap?: "heading" | "none"
className?: string
/** 제목 크기 미세조정 (예: 최종 CTA 의 축소 디스플레이) */
titleClassName?: string
/** 리드 문단 폭 제한·색 조정 (예: "max-w-2xl"). 세로 간격은 여기서 주지 않는다. */
descriptionClassName?: string
}
function SectionHeading({
eyebrow,
title,
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", 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(descriptionGap, align === "center" && "mx-auto", onStage && "text-on-stage-soft", descriptionClassName)}
>
{description}
</Typography>
)}
</div>
)
}
export { SectionHeading }