[fix] negosium·negodata·landing: 채팅 진입·부가정보 저장 오류 수정, 모바일 여백·스크롤·노치 대응

- 채팅 링크로 바로 진입하면 세션이 협상생성(1)에 머물러 오프닝 메시지가 seed 되지 않던 문제:
  chat init/messages 에서 participate 와 동일하게 협상중(2)으로 전이(마감 견적·마감시간 초과는 제외)
- 부가정보 저장이 1301 로 실패하던 문제: '협상완료' 요약은 chat_end=false 라 세션이 아직 협상중이므로,
  마지막 말풍선이 summaryRSP/CM 이면 협상중 세션도 저장 허용
- 채팅 언마운트 시 messages 캐시도 제거 — staleTime:Infinity 라 빈 목록이 남아 재진입해도 빈 화면이던 문제
- 채팅 스크롤을 컨테이너 기준 scrollTo 로 변경(rAF 후 실행) — 요약·폼이 뒤늦게 레이아웃되면 중간에 멈추던 문제
- 모바일 채팅 상단 여백 제거(pt-[64px] 는 우측 협상절차 카드가 있는 lg 이상만)
- 노치 대응: viewport-fit=cover + safe-t/b/x 유틸 신설, 헤더·하단 액션 덱·드로어·랜딩 헤더에 적용, h-screen→100dvh
This commit is contained in:
Mina Choi 2026-07-23 12:50:40 +09:00
parent 0f10b6a70f
commit db07a60cfa
16 changed files with 144 additions and 30 deletions

View File

@ -209,6 +209,8 @@ class ChatService:
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res return res
await self._ensure_in_progress(sess, quote)
res.session_id = str(sess.session_id) res.session_id = str(sess.session_id)
res.session_status = sess.status res.session_status = sess.status
res.quotation_id = str(sess.quotation_id) res.quotation_id = str(sess.quotation_id)
@ -229,6 +231,33 @@ class ChatService:
res.custom = sess.custom or {} res.custom = sess.custom or {}
return res return res
async def _ensure_in_progress(self, sess, quote) -> None:
"""협상생성(1) 세션을 채팅 진입만으로 협상중(2)으로 전이한다(participate 와 동일 전이).
negodata 안내 메일/링크는 목록의 참여 버튼을 거치지 않고 chat 으로 바로 들어오는데,
오프닝 메시지는 협상중일 때만 seed 되므로 전이가 없으면 빈 채팅으로 멈춘다.
마감된 견적은 진입해도 대화가 불가하므로 전이하지 않는다."""
if sess.status != SessionStatus.CREATED.value:
return
if quote is None or quote.status == QuotationStatus.CLOSED.value:
return
end = quote.end_time
if end is not None and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if end is not None and end < datetime.now(timezone.utc):
return
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[
lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.IN_PROGRESS.value),
lambda s: self.session_crud.update_quotation_status(s, sess.quotation_id, QuotationStatus.IN_PROGRESS.value),
],
)
if err_type != ErrorType.SUCCESS:
return
sess.status = SessionStatus.IN_PROGRESS.value
# ---- messages ------------------------------------------------------- # ---- messages -------------------------------------------------------
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages: async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
res = Res_ChatMessages() res = Res_ChatMessages()
@ -245,6 +274,15 @@ class ChatService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
# 협상생성 상태로 바로 진입한 경우(메일 링크) 여기서도 전이한다 —
# init 과 병렬로 호출돼 init 의 전이를 못 본 채 읽었을 수 있다.
if not rows and sess.status == SessionStatus.CREATED.value:
_, quote = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
await self._ensure_in_progress(sess, quote)
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존) # 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
if not rows and sess.status == SessionStatus.IN_PROGRESS.value: if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
opening = await self._seed_opening(sess) opening = await self._seed_opening(sess)

View File

@ -4,9 +4,10 @@ from datetime import datetime, timezone
from fastapi import Depends from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import sessions from common.database.model.models import chats, sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD from crud.session_crud import ISessionCRUD, SessionCRUD
from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
from services.auth_service import AuthService from services.auth_service import AuthService
@ -18,9 +19,18 @@ class NegotiationService:
- 목록은 로그인 유저의 supplier_id 로만 조회한다. - 목록은 로그인 유저의 supplier_id 로만 조회한다.
""" """
def __init__(self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD)): # 부가정보 입력 폼을 띄우는 요약 말풍선 종류. 이 말풍선이 나온 뒤면 협상은 타결된 것으로 본다.
_SUMMARY_BOT_TYPES = ("summaryRSP", "summaryCM")
def __init__(
self,
auth: AuthService = Depends(AuthService),
session_crud: ISessionCRUD = Depends(SessionCRUD),
chat_crud: IChatCRUD = Depends(ChatCRUD),
):
self.auth = auth self.auth = auth
self.session_crud = session_crud self.session_crud = session_crud
self.chat_crud = chat_crud
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList: async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList:
res = Res_SessionList() res = Res_SessionList()
@ -104,10 +114,13 @@ class NegotiationService:
res.result.SetResult(ErrorType.NEGO_FORBIDDEN) res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
return res return res
# 3) 협상완료(타결) 세션만 부가정보 입력 허용 # 3) 협상완료(타결) 세션만 부가정보 입력 허용.
# 단 '협상완료' 요약 말풍선은 chat_end=false 라 세션이 아직 협상중(2)이다
# (동의 → '협상종료' 턴에서야 완료로 전이). 폼은 요약 시점에 뜨므로 그 구간도 허용한다.
if sess.status != SessionStatus.DONE.value: if sess.status != SessionStatus.DONE.value:
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) if sess.status != SessionStatus.IN_PROGRESS.value or not await self._is_after_summary(sess.session_id):
return res res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
# 4) 저장(supplier_id 가드 crud) # 4) 저장(supplier_id 가드 crud)
err_type = await DB_SESSION_MNG.execute_lambda_run( err_type = await DB_SESSION_MNG.execute_lambda_run(
@ -121,6 +134,16 @@ class NegotiationService:
res.session_id = str(session_id) res.session_id = str(session_id)
return res return res
async def _is_after_summary(self, session_id) -> bool:
"""마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지."""
err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_last(s, session_id),
)
if err_type != ErrorType.SUCCESS or not last_meta:
return False
return last_meta.get("bot_chat_type") in self._SUMMARY_BOT_TYPES
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple): async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증. """참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다. 성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bot.png" /> <link rel="icon" type="image/png" href="/bot.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>AI 가격 협상 솔루션</title> <title>AI 가격 협상 솔루션</title>
</head> </head>
<body> <body>

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, memo } from 'react' import { useEffect, useRef, memo, type RefObject } from 'react'
import { useMeQuery } from '@/apis' import { useMeQuery } from '@/apis'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
@ -14,24 +14,40 @@ import { RejectCM } from '@/features/chat/components/templates/RejectCM'
const AI_LABEL = '아이마켓코리아 (구매 MD)' const AI_LABEL = '아이마켓코리아 (구매 MD)'
export function ChatMessage() { export function ChatMessage() {
const scrollRef = useRef<HTMLDivElement | null>(null)
return ( return (
<div className="flex-1 w-full min-h-0"> <div className="flex-1 w-full min-h-0">
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */} {/* lg:pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추는 값 — 그 카드가 없는 lg 미만에선
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"> 스텝바 바로 아래 여백으로만 남아 첫 메시지가 위로 안 붙으므로 뺀다.
<ChatList /> 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
<div
ref={scrollRef}
className="chat-scroll h-full overflow-y-auto flex flex-col pt-4 lg:pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"
>
<ChatList scrollRef={scrollRef} />
</div> </div>
</div> </div>
) )
} }
function ChatList() { function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }) {
const bottomRef = useRef<HTMLDivElement | null>(null) const isFirstRef = useRef(true)
const chats = useChatStore((s) => s.messages) const chats = useChatStore((s) => s.messages)
const isLoading = useChatStore((s) => s.isLoading) const isLoading = useChatStore((s) => s.isLoading)
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤 // 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤.
// 스크롤 컨테이너를 직접 내린다 — scrollIntoView 는 요약/부가정보 폼처럼 큰 블록이 뒤늦게
// 레이아웃되면 애니메이션 도중 목표 위치가 밀려 중간에 멈춘다.
// 진입 첫 렌더는 즉시(auto) — 히스토리를 복원하며 위에서부터 훑어 내려가는 연출을 막는다.
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) const scroller = scrollRef.current
if (!scroller) return
const behavior: ScrollBehavior = isFirstRef.current ? 'auto' : 'smooth'
isFirstRef.current = false
const id = requestAnimationFrame(() => {
scroller.scrollTo({ top: scroller.scrollHeight, behavior })
})
return () => cancelAnimationFrame(id)
}, [chats, isLoading]) }, [chats, isLoading])
if (!chats || chats.length === 0) { if (!chats || chats.length === 0) {
@ -48,7 +64,6 @@ function ChatList() {
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} /> <MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
))} ))}
{isLoading && <TypingBubble />} {isLoading && <TypingBubble />}
<div ref={bottomRef} />
</div> </div>
) )
} }

View File

@ -10,7 +10,8 @@ export function ChatSection() {
<MobileStepBar /> <MobileStepBar />
<ChatMessage /> <ChatMessage />
{/* 하단 액션 덱 */} {/* 하단 액션 덱 */}
<div className="shrink-0 border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]"> {/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
<UserButton {...userButtonConfig} /> <UserButton {...userButtonConfig} />
</div> </div>
</div> </div>

View File

@ -31,7 +31,7 @@ export function MobileDrawer({ open, onClose, side = 'left', title, children }:
side === 'left' ? 'left-0' : 'right-0', side === 'left' ? 'left-0' : 'right-0',
)} )}
> >
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-5"> <div className="flex h-14 box-content safe-t shrink-0 items-center justify-between border-b border-border px-5">
<span className="text-base font-bold text-neutral-90">{title}</span> <span className="text-base font-bold text-neutral-90">{title}</span>
<button <button
type="button" type="button"
@ -42,7 +42,7 @@ export function MobileDrawer({ open, onClose, side = 'left', title, children }:
<X className="size-4" /> <X className="size-4" />
</button> </button>
</div> </div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto safe-b">{children}</div>
</div> </div>
</div>, </div>,
document.body, document.body,

View File

@ -160,6 +160,9 @@ export function useChatController(sessionId: string) {
useChatInitStore.getState().reset() useChatInitStore.getState().reset()
// init 은 진입 메타 캐시 — 비워 두어 재진입이 항상 최신 status 로 진입 게이트(1301)를 다시 타게 한다. // init 은 진입 메타 캐시 — 비워 두어 재진입이 항상 최신 status 로 진입 게이트(1301)를 다시 타게 한다.
queryClient.removeQueries({ queryKey: chatKeys.init(sessionId) }) queryClient.removeQueries({ queryKey: chatKeys.init(sessionId) })
// messages 는 staleTime:Infinity 라 캐시가 남으면 재진입해도 다시 안 부른다 —
// 오프닝 생성 전(협상생성 상태)에 받은 빈 목록이 눌러앉아 채팅이 빈 화면으로 굳는다.
queryClient.removeQueries({ queryKey: chatKeys.messages(sessionId) })
} }
}, [sessionId, sendMutate, navigate, queryClient]) }, [sessionId, sendMutate, navigate, queryClient])

View File

@ -162,6 +162,17 @@
} }
} }
/* 노치/홈 인디케이터 회피 (viewport-fit=cover 와 짝). 안전영역이 0 인 기기에선 아무 영향 없다.
화면 맨 위/맨 아래에 붙는 바에만 붙인다 — 스크롤되는 본문에는 쓰지 않는다. */
@layer components {
.safe-t { padding-top: env(safe-area-inset-top); }
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
/* 타이포그래피 스케일 (headline / title / body / reject) */ /* 타이포그래피 스케일 (headline / title / body / reject) */
@layer components { @layer components {
.headline-3 { font-size: 24px; font-weight: 700; line-height: 30px; letter-spacing: -0.48px; } .headline-3 { font-size: 24px; font-weight: 700; line-height: 30px; letter-spacing: -0.48px; }

View File

@ -1,8 +1,9 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { cn } from '@/lib' import { cn } from '@/lib'
// safe-t: 노치 기기에서 헤더가 상태바 밑으로 파고들지 않도록 안전영역만큼 키운다(h-[56px] 은 콘텐츠 높이).
const HEADER_BASE = const HEADER_BASE =
'flex w-full h-[56px] items-center bg-white text-foreground border-b border-border' 'flex w-full h-[56px] box-content safe-t items-center bg-white text-foreground border-b border-border'
const HEADER_ALIGN = { const HEADER_ALIGN = {
left: 'justify-start', left: 'justify-start',

View File

@ -13,14 +13,14 @@ const SIDEBAR_WIDTH = {
} as const } as const
const styles = { const styles = {
root: 'flex w-full min-h-screen max-h-screen bg-background', root: 'flex w-full min-h-[100dvh] max-h-[100dvh] bg-background safe-x',
scrollX: 'flex w-full min-h-screen max-h-screen overflow-x-auto overflow-y-hidden', scrollX: 'flex w-full min-h-[100dvh] max-h-[100dvh] overflow-x-auto overflow-y-hidden safe-x',
scrollXInner: 'flex w-full min-h-screen max-h-screen min-w-[1024px] bg-background', scrollXInner: 'flex w-full min-h-[100dvh] max-h-[100dvh] min-w-[1024px] bg-background',
// 좌측 레일은 lg 미만에서 숨기고(드로어로 대체), main 이 전체 폭을 차지한다. 보고서식 풀블리드 + 경계선. // 좌측 레일은 lg 미만에서 숨기고(드로어로 대체), main 이 전체 폭을 차지한다. 보고서식 풀블리드 + 경계선.
sidebar: 'hidden lg:flex flex-col h-screen bg-white border-r border-border', sidebar: 'hidden lg:flex flex-col h-[100dvh] bg-white border-r border-border',
logoHeader: logoHeader:
'flex w-full h-[56px] pl-[24px] pr-[16px] items-center justify-between shrink-0 border-b border-border', 'flex w-full h-[56px] pl-[24px] pr-[16px] items-center justify-between shrink-0 border-b border-border',
main: 'flex flex-1 flex-col h-screen overflow-hidden', main: 'flex flex-1 flex-col h-[100dvh] overflow-hidden',
content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden bg-surface', content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden bg-surface',
} as const } as const

View File

@ -75,3 +75,14 @@ body {
filter: blur(100px); filter: blur(100px);
pointer-events: none; pointer-events: none;
} }
/* 노치/홈 인디케이터 회피 (viewport-fit=cover 와 짝). 안전영역이 0 인 기기에선 아무 영향 없다.
화면 맨 위/맨 아래에 붙는 바에만 붙인다 — 스크롤되는 본문에는 쓰지 않는다. */
@layer components {
.safe-t { padding-top: env(safe-area-inset-top); }
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}

View File

@ -17,7 +17,7 @@ export function Header() {
return ( return (
<header <header
className={`fixed top-0 left-0 right-0 z-40 transition-all duration-300 ${ className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${
scrolled ? "py-4 bg-white/80 backdrop-blur-xl border-b border-line" : "py-6 bg-transparent border-b border-transparent" scrolled ? "py-4 bg-white/80 backdrop-blur-xl border-b border-line" : "py-6 bg-transparent border-b border-transparent"
}`} }`}
> >

View File

@ -15,7 +15,7 @@ export function Layout({ children }: { children: ReactNode }) {
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<Meta /> <Meta />
<Links /> <Links />
</head> </head>

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>NegoData</title> <title>NegoData</title>
</head> </head>
<body> <body>

View File

@ -124,7 +124,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
}; };
return ( return (
<div className="min-h-screen bg-background text-foreground flex"> <div className="min-h-[100dvh] safe-x bg-background text-foreground flex">
{/* 토스트는 sonner <Toaster/>(main.tsx)가 전역으로 처리한다 */} {/* 토스트는 sonner <Toaster/>(main.tsx)가 전역으로 처리한다 */}
{/* 모바일 드로어 백드롭 */} {/* 모바일 드로어 백드롭 */}
@ -273,7 +273,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
)} )}
> >
{/* Global Header — 좌: 페이지명 / 중: 빠른 이동(⌘K) / 우: 알림·계정 메타 */} {/* Global Header — 좌: 페이지명 / 중: 빠른 이동(⌘K) / 우: 알림·계정 메타 */}
<header className="h-14 bg-card border-b border-border grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 md:px-6 sticky top-0 z-20"> <header className="h-14 box-content safe-t bg-card border-b border-border grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 md:px-6 sticky top-0 z-20">
<div className="flex items-center gap-2 md:gap-3 min-w-0"> <div className="flex items-center gap-2 md:gap-3 min-w-0">
{/* 모바일 햄버거 */} {/* 모바일 햄버거 */}
<Button <Button

View File

@ -63,4 +63,15 @@ body {
0% { opacity: 0; transform: rotate(-12deg) scale(1.8); } 0% { opacity: 0; transform: rotate(-12deg) scale(1.8); }
55% { opacity: 0.95; transform: rotate(-12deg) scale(0.9); } 55% { opacity: 0.95; transform: rotate(-12deg) scale(0.9); }
100% { opacity: 0.75; transform: rotate(-12deg) scale(1); } 100% { opacity: 0.75; transform: rotate(-12deg) scale(1); }
} }
/* 노치/홈 인디케이터 회피 (viewport-fit=cover 와 짝). 안전영역이 0 인 기기에선 아무 영향 없다.
화면 맨 위/맨 아래에 붙는 바에만 붙인다 — 스크롤되는 본문에는 쓰지 않는다. */
@layer components {
.safe-t { padding-top: env(safe-area-inset-top); }
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}