디자인 시스템이 색·활자만 소유하고 간격은 각 섹션 파일에 흩어져 있어, 같은 관계에 값이 여러 개 생겼다. 스크롤할 때 섹션마다 호흡이 달라지던 원인. 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>
110 lines
4.9 KiB
TypeScript
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 }
|