사이드바와 대화 영역을
flex 두 칸으로 나눈다
72줄 · 8.8 KB다. 색과 여백 값을 전부 옮기는 대신, 레이아웃을 실제로 결정하는 규칙과 좁은 화면 대응만 소스에서 그대로 발췌해 읽는다.
이 파일을 만든 이유와 이 문서의 범위
index.css가 문서 전체의 바닥을 깔았다면, 이 파일은 App.tsx가 붙인 클래스 이름 하나하나에 모습을 준다. .app-shell·.sidebar·.composer처럼 JSX의 className과 1:1로 대응한다.
이 문서는 전체 코드를 복제하지 않는다. 67개 클래스 규칙 중 대부분은 색·여백·모서리 값이며, 그대로 옮기면 무엇이 중요한지 묻힌다. 대신 레이아웃을 결정하는 규칙만 소스에서 자동으로 가져와 해설한다. 나머지 값은 fe/src/App.css를 직접 여는 편이 정확하다.
화면 구조와 클래스 이름
app-shell flex 가로 2칸
├── sidebar 고정 278px · 세로 flex
│ ├── sidebar-top brand-mark sidebar-close
│ ├── new-chat shortcut
│ ├── sidebar-nav
│ ├── history ← 여기만 스크롤
│ └── sidebar-footer ← margin-top:auto 로 바닥에
└── chat-main 남는 폭 전부 · 세로 flex
├── topbar 고정 66px
├── conversation ← 여기만 스크롤
│ ├── welcome / message-list
│ └── message-pair user-message assistant-message
└── composer-wrap ← position:absolute 로 아래 고정
클래스 이름이 화면 구조를 그대로 따라간다. .message-pair는 “사용자 말풍선과 모델 답변을 한 쌍으로 묶는다”는 뜻이며, 이 이름만으로 App.tsx의 어느 JSX인지 찾을 수 있다.
레이아웃을 결정하는 여섯 규칙
.app-shell { min-height: 100svh; display: flex; background: var(--paper); }
.sidebar { width: 278px; flex: 0 0 278px; min-height: 100svh; padding: 14px 12px; background: var(--sidebar); border-right: 1px solid var(--line); display: flex; flex-direction: column; transition: transform .24s ease; z-index: 20; }
.chat-main { min-width: 0; min-height: 100svh; flex: 1; display: flex; flex-direction: column; position: relative; background: #fff; }
.topbar { height: 66px; flex: 0 0 66px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid rgba(232,231,226,.78); }
.conversation { flex: 1; min-height: 0; overflow-y: auto; display: flex; justify-content: center; padding: 36px 24px 190px; }
.composer-wrap { position: absolute; z-index: 5; left: 50%; bottom: 0; width: min(820px,calc(100% - 36px)); transform: translateX(-50%); padding-bottom: 12px; background: linear-gradient(0deg,#fff 75%,rgba(255,255,255,0)); }
.app-shell — flex 가로 배치
display: flex이므로 자식인 .sidebar와 .chat-main이 가로로 나란히 놓인다. grid가 아니라 flex를 쓴 이유는 칸이 둘뿐이고 하나는 고정, 하나는 나머지 전부라는 단순한 관계이기 때문이다.
min-height: 100svh의 svh는 모바일 주소창을 제외한 높이다. 예전 100vh는 iOS Safari에서 화면 아래가 잘리는 문제로 유명하다. iPad에서 쓰려는 이 프로젝트에 직접 관계된 선택이다.
.sidebar — flex: 0 0 278px
세 값은 차례로 grow · shrink · basis다. 0 0 278px은 “늘어나지도 줄어들지도 말고 정확히 278px”을 뜻한다. width만 주면 공간이 부족할 때 flex가 줄여 버리므로 flex-shrink: 0이 필요하다.
transition: transform .24s ease는 좁은 화면에서 사이드바가 미끄러지는 애니메이션을 미리 정의해 둔 것이다. 넓은 화면에서는 transform이 바뀌지 않으므로 아무 일도 일어나지 않는다.
.chat-main — min-width: 0이 핵심
flex 항목의 최소 크기는 기본이 auto이고, 이는 “내용보다 작아지지 않는다”는 뜻이다. 긴 메시지가 하나 들어오면 이 칸이 늘어나 화면 밖으로 삐져나간다. min-width: 0을 명시해야 안쪽에서 줄바꿈과 스크롤이 생긴다. flex를 쓸 때 가장 자주 만나는 함정이다.
position: relative는 아래 .composer-wrap의 기준점이 되기 위한 것이다. 이것이 없으면 입력창이 화면 전체를 기준으로 붙어 사이드바 위까지 덮는다.
.topbar — flex: 0 0 66px
세로 flex 안에서 높이가 고정된다. height와 flex-basis를 함께 준 것은 브라우저 간 계산 차이를 줄이기 위한 관용적 표기다.
.conversation — flex: 1 + min-height: 0 + overflow-y: auto
이 세 값이 함께 있어야 여기서만 스크롤이 일어난다. flex: 1이 남는 높이를 전부 가져가고, min-height: 0이 세로 방향의 같은 함정을 막고, overflow-y: auto가 넘칠 때 스크롤바를 만든다. 셋 중 하나만 빠져도 문서 전체가 늘어난다.
padding: 36px 24px 190px의 아래쪽 190px이 중요하다. 입력창이 position: absolute로 떠 있어 자리를 차지하지 않으므로, 마지막 메시지가 그 아래 가려지지 않도록 미리 비워 둔 공간이다. 입력창 높이를 바꾸면 이 값도 함께 조정해야 한다.
.composer-wrap — 떠 있는 입력창
position: absolute; left: 50%; bottom: 0; transform: translateX(-50%)는 가로 가운데 정렬의 관용구다. left: 50%가 왼쪽 모서리를 중앙에 두고, translateX(-50%)가 자기 폭의 절반만큼 되돌린다.
width: min(820px, calc(100% - 36px))는 “최대 820px, 다만 화면이 좁으면 좌우 18px씩 여백”을 뜻한다. background: linear-gradient(0deg, #fff 75%, rgba(255,255,255,0))는 위쪽이 투명한 흰 그라데이션으로, 스크롤되는 메시지가 입력창 뒤로 자연스럽게 사라지게 만든다.
말풍선 — 여기서는 grid를 쓴다
.message-list { width: min(760px,100%); display: grid; gap: 38px; }
.message-pair { display: grid; gap: 27px; }
.user-message { justify-self: end; max-width: 78%; padding: 12px 16px; border-radius: 18px 18px 5px 18px; background: #f0f0ed; font-size: 14px; line-height: 1.55; white-space: pre-wrap; }
.assistant-message { display: grid; grid-template-columns: 30px 1fr; gap: 13px; align-items: start; font-size: 14px; line-height: 1.65; }
.user-message { justify-self: end; }
부모 .message-pair가 grid이므로 justify-self로 오른쪽 정렬한다. flex였다면 margin-left: auto를 썼을 것이다. 사용자 말풍선은 오른쪽, 모델 답변은 왼쪽이라는 채팅 관례를 만든다.
white-space: pre-wrap — App.tsx와 직접 이어진다
이 한 줄이 Shift+Enter로 넣은 줄바꿈을 화면에 보이게 한다. HTML은 기본적으로 연속된 공백과 줄바꿈을 하나의 공백으로 합치므로, 이것이 없으면 여러 줄로 쓴 메시지가 한 줄로 붙어 나온다. App.tsx의 handleKeyDown이 만든 줄바꿈이 여기서 살아난다.
.assistant-message { grid-template-columns: 30px 1fr; }
아바타 자리 30px과 본문 자리로 나눈다. align-items: start가 있어 본문이 길어져도 아바타는 위에 붙어 있다. 여기서는 두 칸의 관계가 명확하므로 grid가 flex보다 읽기 쉽다.
.message-list { width: min(760px, 100%); }
넓은 화면에서도 한 줄이 760px을 넘지 않게 한다. 글줄이 너무 길면 다음 줄 시작점을 찾기 어려워 읽기가 나빠지기 때문이다.
좁은 화면 대응 — 세 개의 미디어 쿼리
이 파일에는 미디어 쿼리가 셋 있다. 압축된 한 줄이라 아래에 소스 그대로 싣고 핵심만 짚는다.
@media(max-width:840px){.sidebar{position:fixed;inset:0 auto 0 0;width:286px;transform:translateX(-102%);box-shadow:14px 0 40px rgba(0,0,0,.12)}.sidebar--open{transform:translateX(0)}.sidebar-close,.mobile-menu{display:grid}.sidebar-scrim{display:block;position:fixed;inset:0;z-index:15;background:rgba(24,24,22,.26);backdrop-filter:blur(2px)}.topbar{padding:0 15px}.model-select{margin-right:auto}.status-pill{display:none}.conversation{padding-inline:18px}}
사이드바가 화면 밖으로 나간다
position: fixed; inset: 0 auto 0 0; transform: translateX(-102%)로 왼쪽 밖에 대기시킨다. -100%가 아니라 -102%인 것은 그림자(box-shadow)까지 확실히 숨기기 위해서다.
.sidebar--open { transform: translateX(0) }가 다시 들여온다. App.tsx의 sidebarOpen state는 클래스 이름만 바꾸고, 움직이는 방식과 속도(.sidebar의 transition)는 CSS가 정한다. JavaScript가 위치를 계산하지 않으므로 애니메이션이 부드럽다.
숨어 있던 버튼이 나타난다
.sidebar-close, .mobile-menu { display: grid }. 두 버튼은 넓은 화면에서 display: none이었다. 같은 JSX가 두 레이아웃을 모두 담당하고, 무엇을 보일지는 CSS가 고른다. React에서 조건부로 렌더링하지 않아도 되는 부분이다.
.sidebar-scrim — 뒤를 덮는 층
position: fixed; inset: 0; z-index: 15로 화면 전체를 덮는다. .sidebar의 z-index: 20보다 낮아 사이드바는 그 위에 남는다. backdrop-filter: blur(2px)가 뒤 내용을 흐린다. App.tsx에서 이 요소는 버튼이라 클릭하면 사이드바가 닫힌다.
@media(max-width:620px){.topbar{height:60px;flex-basis:60px}.model-caption{display:none}.conversation{padding:24px 14px 162px}.welcome-symbol{width:48px;height:48px;margin-bottom:18px}.welcome h1{font-size:34px}.suggestions{grid-template-columns:1fr;margin-top:28px;gap:8px}.suggestions button{min-height:67px;padding:12px 13px;align-items:center}.suggestions small{display:none}.composer-wrap{width:calc(100% - 20px);padding-bottom:max(8px,env(safe-area-inset-bottom))}.composer{border-radius:19px;padding:10px 11px 8px}.composer-hint{display:none}.disclaimer{font-size:9px}.user-message{max-width:88%}}
제안 카드가 한 줄로
.suggestions { grid-template-columns: 1fr }. 넓은 화면의 repeat(3, 1fr)에서 한 칸으로 바뀌고 .suggestions small은 숨는다.
env(safe-area-inset-bottom) — iPhone·iPad 대응
padding-bottom: max(8px, env(safe-area-inset-bottom)). 홈 인디케이터가 있는 기기에서 입력창이 그 막대에 가리지 않게 아래 여백을 확보한다. env()는 브라우저가 기기별로 채워 주는 값이고, max()는 그런 영역이 없는 기기에서도 최소 8px을 보장한다.
@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
기기 설정에서 동작 줄이기를 켠 사용자에게 모든 애니메이션을 사실상 없앤다. 완전히 0으로 두지 않고 .01ms로 두는 이유는, 애니메이션 종료 이벤트에 기대는 코드가 있어도 깨지지 않게 하기 위한 관용적 처리다. 전정기관이 예민한 사용자에게 화면 움직임은 실제로 불편을 준다.
실제로 확인하는 방법
| 확인 | 방법 | 보이는 것 |
|---|---|---|
min-width: 0의 이유 | 개발자 도구에서 .chat-main의 그 줄을 끈다 | 긴 메시지를 넣으면 가로로 삐져나간다 |
| 스크롤 영역 | 페이지 전체를 스크롤해 본다 | 문서는 움직이지 않고 .conversation만 스크롤된다 |
| 190px의 이유 | .conversation의 padding-bottom을 0으로 | 마지막 메시지가 입력창 뒤로 가려진다 |
| 사이드바 전환 | 기기 도구 모음에서 폭을 840px 아래로 | 사이드바가 밀려나고 메뉴 버튼과 scrim이 나타난다 |
pre-wrap | Shift+Enter로 여러 줄 입력 후 전송 | 줄바꿈이 그대로 보인다. 규칙을 끄면 한 줄로 붙는다 |
| 동작 줄이기 | macOS 손쉬운 사용 → 동작 줄이기 | 환영 화면의 rise 애니메이션이 사라진다 |
자주 발생하는 오류
| 증상 | 원인 | 진단 |
|---|---|---|
| 긴 메시지에 가로 스크롤 | .chat-main의 min-width: 0 누락 | flex 항목의 최소 크기가 auto라 내용만큼 늘어난다 |
| 문서 전체가 스크롤됨 | .conversation의 min-height: 0 누락 | 세로 방향의 같은 함정이다 |
| 마지막 메시지가 가려짐 | padding-bottom: 190px를 줄임 | 입력창이 absolute라 자리를 차지하지 않는다 |
| 입력창이 사이드바까지 덮음 | .chat-main의 position: relative 누락 | absolute의 기준이 화면 전체가 된다 |
| 줄바꿈이 사라짐 | white-space: pre-wrap 누락 | HTML 기본값이 공백을 합친다 |
| 스타일이 적용 안 됨 | JSX의 className 오타 | CSS 클래스는 문자열이라 TypeScript가 잡지 않는다 |
| 사이드바가 안 닫힘 | .sidebar--open 이름 불일치 | App.tsx의 템플릿 문자열과 선택자를 대조한다 |
설계 선택과 대안
왜 flex인가, grid인가?
이 파일은 둘을 섞어 쓰며 기준이 분명하다. 한 방향으로 늘어나고 줄어드는 관계(전체 2칸, 세로 3단)에는 flex를, 칸의 크기 관계가 고정된 배치(아바타 30px + 본문, 제안 카드 3열)에는 grid를 쓴다. 전체를 grid로 짜면 .composer-wrap을 떠 있게 만들기 어렵고, 전부 flex로 짜면 아바타 정렬이 번거롭다.
왜 CSS Module이나 styled-components가 아닌가?
컴포넌트가 하나뿐이라 클래스 이름 충돌이 없다. 파일이 나뉘면 .button 같은 이름이 겹칠 위험이 생기고 그때 CSS Module이 값을 한다. 지금은 브라우저가 받는 CSS를 그대로 읽을 수 있다는 이점이 더 크다.
이 파일이 앞으로 어떻게 바뀌는가
streaming이 들어오면 생성 중 표시, 오류 말풍선, 자동 스크롤이 필요해진다. 특히 새 토큰마다 아래로 따라가는 스크롤은 CSS만으로는 되지 않아 .conversation의 스크롤 위치를 React가 직접 다뤄야 한다. 지금 스크롤 주체를 한 요소로 좁혀 둔 것이 그때 도움이 된다.
이전 단계와 다음 파일
여기서 쓰는 var(--accent)·var(--line) 같은 이름은 index.css가 정의한다. 클래스 이름을 붙이는 JSX는 App.tsx, 두 CSS를 하나로 묶어 dist/에 내보내는 과정은 vite.config.ts에서 이어진다.