surf-uiby COLDSURF
Foundations

레이아웃

표면의 가로축(Container)과 세로축(Page).

import { Container, Page, PageBanner } from '@coldsurfers/surf-ui/layout'

셋은 ./layout 진입점에 있습니다 — 배럴(.)에는 없습니다.

Container — 가로축

COLDSURF
헤더 로고와 본문이 같은 세로선에 선다 — 지면 배경은 그 선 밖까지 간다
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 — 세로축

SiteHeader
본문 — 남은 높이를 전부 먹는다
SiteFooter
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 · 계측을 뭅니다. 앱의 것입니다

On this page