레이아웃
표면의 가로축(Container)과 세로축(Page).
import { Container, Page, PageBanner } from '@coldsurfers/surf-ui/layout'셋은 ./layout 진입점에 있습니다 — 배럴(.)에는 없습니다.
Container — 가로축
import { Container } from '@coldsurfers/surf-ui/layout'export default function Example() { return ( <div style={{ width: '100%', background: 'var(--surface-2)' }}> <Container as="header" style={{ paddingBlock: 12, borderBottom: '1px dashed var(--surf-line-divider)' }} > COLDSURF </Container> <Container as="main" style={{ paddingBlock: 12 }}> 헤더 로고와 본문이 같은 세로선에 선다 — 지면 배경은 그 선 밖까지 간다 </Container> </div> )}margin-inline: auto · 최대폭 · gutter 셋이 전부입니다. 지면 배경은 이 선 밖까지 가고,
콘텐츠만 안쪽으로 들어옵니다.
gutter 는 여백 스케일의 space-6, tablet 이상에서 space-16
입니다. 최대폭 1440px 만 스케일 밖 리터럴입니다 — 토큰에 maxWidth 축이 없고, 한 표면의
최대 폭은 레이아웃 결정이라 스케일로 일반화할 게 없습니다. 다른 폭이 필요하면 className
으로 덮습니다(유틸은 ds-utilities 라 컴포넌트를 이깁니다).
이 컴포넌트가 생긴 이유는 재사용이 아니라 드리프트입니다. 소비 앱은 같은 값을 16곳에서 각자 상수로 다시 선언하고 있었습니다 — 주석까지 복붙된 채로. 정렬 축은 하나여야 하는데 사본이 16개였습니다.
as 로 여는 태그 목록이 좁은 것도 같은 이유입니다. gutter 는 정렬축이지 의미가 아니라서
어떤 태그에도 붙지만, ElementType 으로 활짝 열면 <button> 에 gutter 를 붙이는 것까지
타입이 통과합니다. 넓히는 건 minor 지만 좁히는 건 major 입니다.
Prop
Type
그 밖의 HTMLAttributes<HTMLElement> 속성은 루트 엘리먼트로 그대로 통과한다.
Page — 세로축
import { Page } from '@coldsurfers/surf-ui/layout'export default function Example() { return ( // 실제 표면에선 min-height 가 100vh 다 — 미리보기라 style 로 낮춘다. <Page mode="light" name="docs" style={{ minHeight: 220, width: '100%', border: '1px dashed var(--surf-line-divider)' }} > <div style={{ padding: 12, borderBottom: '1px dashed var(--surf-line-divider)' }}> SiteHeader </div> <Page.Content style={{ padding: 12 }}>본문 — 남은 높이를 전부 먹는다</Page.Content> <div style={{ padding: 12, borderTop: '1px dashed var(--surf-line-divider)' }}> SiteFooter </div> </Page> )}min-height: 100vh 세로 스택과 Page.Content 의 flex: 1 은 짝으로만 성립합니다.
한쪽만 있으면 아무 효과가 없습니다. 콘텐츠가 짧은 표면(404 · 해지 완료)에서 푸터가 화면
중간에 떠 있지 않게 하는 것, 그 하나를 위해 둘이 갈라지는 자리를 없앤 컴포넌트입니다.
헤더 · 푸터는 슬롯 래퍼 없이 그냥 자식으로 놓습니다. 그 내용물(라우터 · 세션 · i18n ·
계측)은 앱의 것이고, DS 가 그 자리에 <div> 한 겹을 더 두면 아무것도 안 하는 이름이 공개
계약에 오릅니다.
mode 는 필수다 — 화면마다 ink 인지 light 인지
mode 는 data-surface 로 나가 그 안의 색을 면 값으로 바꿉니다. context 로도 내려가서
Modal · Popover 처럼 document.body 로 나가는 오버레이가 같은 면으로 뜨고, 부품은
useSurfaceMode() 로 tone prop 없이 면을 압니다. ink 페이지면 <body> 도 ink 가 됩니다 —
짧은 페이지 아래 · 오버스크롤 바운스 색이 새지 않습니다.
mode="auto" 는 OS 를 따릅니다 — OS 가 다크면 ink, 아니면 light. 토글은 없습니다. 서버는 OS 를
모르므로 useSurfaceMode() 가 auto 를 돌려주는 자리에서는 색을 JS 로 가르지 말고 CSS
(@media (prefers-color-scheme: dark))로 가릅니다.
배경 · 글자색을 칠하는 건 여전히 앱의 className 입니다 — 표면 정책은 값이 아니라 제품 결정입니다.
name 은 마커일 뿐이다
data-page 로 나가고 DS 는 해석하지 않습니다. 화면별로 바닥색을 덮을 때만 줍니다.
body:has([data-page='tonight']) {
background: var(--paper-warm);
}모드와 나눈 이유: 한 속성이 둘을 겸하면 이름을 다는 순간 면 스코프가 꺼집니다.
Prop
Type
그 밖의 HTMLAttributes<HTMLDivElement> 속성은 루트 엘리먼트로 그대로 통과한다.
왜 둘을 안 합쳤나
축이 직교하기 때문입니다. Container 는 가로(정렬선 · 최대폭), Page 는 세로(뷰포트 높이 ·
면 모드)입니다. 합치면 지면 배경까지 최대폭에서 잘려 넓은 화면 양옆에 다른 색 띠가
생기고, 표면당 하나뿐인 Page 로는 페이지 안 여러 덩이를 같은 선에 세울 수 없습니다.
그래서 Container 는 Page 밖에서도 씁니다 — 헤더 · 푸터도 같은 축으로 서야 로고와 콘텐츠가
정렬됩니다.
PageBanner
새 공연 뜨면 알려드릴게요
스팸 없이.
import { PageBanner } from '@coldsurfers/surf-ui/layout'import { Button } from '@coldsurfers/surf-ui/primitives'export default function Example() { return ( <div style={{ width: '100%' }}> <PageBanner> {/* 밴드 안의 배치는 소비처가 정한다 — DS 가 드는 건 바닥 · 셸 · 타이포뿐이다. */} <div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'center', justifyContent: 'space-between', }} > <div> <PageBanner.Title>새 공연 뜨면 알려드릴게요</PageBanner.Title> <PageBanner.Body>스팸 없이.</PageBanner.Body> </div> <Button>알림 받기</Button> </div> </PageBanner> </div> )}지면을 다 읽은 자리에서 색이 뒤집혀 "본문이 끝났고 여기부터는 요구" 라는 걸 글자 없이 알리는
풀블리드 밴드입니다. 안에서 Container 를 쓰므로 밴드 콘텐츠도 본문과 같은 세로선에 섭니다.
레이아웃은 소비처의 것입니다. DS 가 드는 건 바닥 · 셸 · 타이포 셋뿐이고 그 안의 배치는
children 이 정합니다. align 축을 열지 않은 이유는 실측입니다 — 소비처 둘의 정렬이 서로
다르지만 각 정렬의 실사용이 1곳씩이라 추출할 중복이 없었습니다. Modal 이 placement 를 연
건 5 소비처 중 4곳이 같은 두 줄을 복붙하고 있었기 때문이고, 여기는 그 조건이 성립하지 않습니다.
PageBanner.Title 이 <h2> 인 것도 자리에서 나옵니다 — 밴드는 지면 본문 다음에 서는 한
덩이라 문서 제목(<h1>) 아래 형제가 맞습니다.
여기 없는 것
| 왜 | |
|---|---|
Page.Header · Page.Footer | <div> 한 겹이라 두지 않습니다. chrome 은 Page 의 자식으로 놓습니다 |
Section | 소비처 34곳 중 그대로 맞는 건 3곳이었습니다. 나머지는 DS 가 준 걸 도로 지우는 className 을 답니다 |
Bar.Left · Bar.Right | 소비처가 아직 하나뿐이라 보류입니다. 두 번째가 생기면 엽니다 |
| 헤더 · 푸터 내용물 | 라우터 · 세션 · i18n · 계측을 뭅니다. 앱의 것입니다 |