From fdf3c9876d2b2e5c7106604e6a97f745222d6d7d Mon Sep 17 00:00:00 2001
From: jmj
Date: Thu, 20 Aug 2026 14:33:28 +0900
Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=EC=A2=81=EC=9D=80=20=ED=99=94?=
=?UTF-8?q?=EB=A9=B4=20=EB=A9=B4=EC=A0=91=20=EC=A7=84=EC=9E=85=20=EC=95=88?=
=?UTF-8?q?=EB=82=B4=20+=20=ED=97=A4=EB=8D=94=20=EC=B6=95=EC=86=8C=20?=
=?UTF-8?q?=EB=B0=A9=EC=96=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
UI/UX 점검. 면접 스테이지는 반응형 클래스가 `sm:bottom-28` **하나뿐**이고,
`docs/ui-patterns.md §9` 에 규약만 있던 모바일 안내는 구현돼 있지 않았다.
- `SmallScreenNotice` — 768px 미만 진입 시 1회성 안내(dismiss 는 localStorage 영속).
막지 않고 기대치만 맞춘다. 좁은 화면에서 요소가 겹칠 때 이유를 모르면 사용자는
제품이 고장난 줄 안다. 마운트 시점 폭만 보고 리사이즈에는 반응하지 않는다 —
한 번 안내하는 힌트이지 창을 줄일 때마다 끼어들 이유가 없다
- 헤더 우측 컨트롤 묶음에 `shrink-0` — 없으면 좁은 화면에서 버튼 글자가 눌려 읽히지 않는다
(모드 토글·연결 배지·기록·종료 4개가 축소 방어 없이 들어 있었다)
- 연결 배지는 좁은 화면에서 숨긴다. `ConnectionBanner` 가 같은 내용을 이미 알리고 있어
좁은 폭에서 중복으로 자리만 차지했다
`hover:bg-info-100` 을 쓰려다 발견 — info 토큰은 50/500/700 만 있다. 없는 토큰은 조용히
무효가 되므로 있는 것으로 바꿨다.
테스트 `SmallScreenNotice.test.tsx` (3) — 좁은 화면 노출, 넓은 화면 미노출, 닫으면 재노출 안 함.
---
docs/ui-patterns.md | 3 +-
.../interview/ui/live/InterviewStage.tsx | 10 +++-
.../ui/live/SmallScreenNotice.test.tsx | 44 ++++++++++++++
.../interview/ui/live/SmallScreenNotice.tsx | 60 +++++++++++++++++++
4 files changed, 114 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx
create mode 100644 frontend/src/features/interview/ui/live/SmallScreenNotice.tsx
diff --git a/docs/ui-patterns.md b/docs/ui-patterns.md
index aeb62c6..873596b 100644
--- a/docs/ui-patterns.md
+++ b/docs/ui-patterns.md
@@ -146,7 +146,8 @@ UI 표현:
## 9. 모바일 대응
-- 면접 세션은 데스크탑 우선. 모바일 진입 시 "데스크탑 환경 권장" 배너 (dismissable)
+- 면접 세션은 데스크탑 우선. 좁은 화면(<768px) 진입 시 1회성 안내 배너 — `SmallScreenNotice`
+ (dismiss 상태는 localStorage 영속, 마운트 시점 폭만 판정해 리사이즈마다 끼어들지 않음)
- 외 페이지: 768px 이하에서 SideNav → Drawer로 전환, TopNav 햄버거 메뉴
---
diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx
index dba1717..043bfc3 100644
--- a/frontend/src/features/interview/ui/live/InterviewStage.tsx
+++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx
@@ -8,6 +8,7 @@ import type { Session } from '@/domain/session'
import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview'
import type { DeliveryMode } from '../../model/useDeliveryMode'
import { ConnectionBanner } from './ConnectionBanner'
+import { SmallScreenNotice } from './SmallScreenNotice'
import { AnswerComposer } from './AnswerComposer'
import { StageQuestion } from './StageQuestion'
import { DeliveryModeToggle } from './DeliveryModeToggle'
@@ -124,9 +125,13 @@ export function InterviewStage({
)}
-
+ {/* shrink-0 이 없으면 좁은 화면에서 버튼 글자가 눌려 읽히지 않는다.
+ 연결 배지는 좁은 화면에서 숨긴다 — ConnectionBanner 가 같은 내용을 이미 알린다. */}
+
diff --git a/frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx b/frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx
new file mode 100644
index 0000000..47d2382
--- /dev/null
+++ b/frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx
@@ -0,0 +1,44 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { SmallScreenNotice } from './SmallScreenNotice'
+
+const originalWidth = window.innerWidth
+
+function setWidth(px: number) {
+ Object.defineProperty(window, 'innerWidth', { value: px, configurable: true, writable: true })
+}
+
+beforeEach(() => window.localStorage.clear())
+afterEach(() => setWidth(originalWidth))
+
+describe('SmallScreenNotice', () => {
+ it('좁은 화면에서 안내를 보여준다', () => {
+ setWidth(375)
+
+ render()
+
+ expect(screen.getByText(/데스크탑 환경을 권장/)).toBeInTheDocument()
+ })
+
+ it('넓은 화면에서는 끼어들지 않는다', () => {
+ setWidth(1280)
+
+ render()
+
+ expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument()
+ })
+
+ // 매 진입마다 같은 안내가 뜨면 그게 더 방해가 된다.
+ it('한 번 닫으면 다시 뜨지 않는다', async () => {
+ setWidth(375)
+ const { unmount } = render()
+
+ await userEvent.click(screen.getByRole('button', { name: '안내 닫기' }))
+ expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument()
+ unmount()
+
+ render()
+ expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/features/interview/ui/live/SmallScreenNotice.tsx b/frontend/src/features/interview/ui/live/SmallScreenNotice.tsx
new file mode 100644
index 0000000..28fe7e0
--- /dev/null
+++ b/frontend/src/features/interview/ui/live/SmallScreenNotice.tsx
@@ -0,0 +1,60 @@
+import { useState } from 'react'
+
+const STORAGE_KEY = 'stackup:interview-small-screen-notice-dismissed'
+// 아래로는 면접 스테이지(질문 카드 + 컴포저 + 셀프뷰)가 서로 겹치기 시작한다.
+const MIN_COMFORTABLE_WIDTH = 768
+
+/**
+ * 좁은 화면에서 한 번만 뜨는 안내 (`docs/ui-patterns.md §9`).
+ *
+ *
면접 화면은 데스크탑 우선으로 만들어졌다 — 웹캠 셀프뷰·질문 카드·컴포저가 한 화면에
+ * 있어야 하고 마이크 권한도 필요하다. 좁은 화면에서 뭔가 어긋나 보일 때 이유를 모르면
+ * 사용자는 제품이 고장난 줄 안다. 막지는 않고(진행은 가능하다) 기대치만 맞춘다.
+ */
+// 마운트 시점의 폭만 본다. 리사이즈에 반응하지 않는 건 의도다 — 한 번 안내하는 힌트이지
+// 창을 줄일 때마다 다시 끼어들 이유가 없다. 이펙트가 아니라 lazy 초기값으로 계산한다
+// (이펙트에서 setState 하면 렌더가 한 번 더 돈다 — react-hooks/set-state-in-effect).
+function shouldShow(): boolean {
+ if (typeof window === 'undefined') return false
+ if (window.innerWidth >= MIN_COMFORTABLE_WIDTH) return false
+ try {
+ return window.localStorage.getItem(STORAGE_KEY) !== '1'
+ } catch {
+ // 프라이빗 모드 등 저장이 막힌 경우 — 안내는 보여준다.
+ return true
+ }
+}
+
+export function SmallScreenNotice() {
+ const [visible, setVisible] = useState(shouldShow)
+
+ if (!visible) return null
+
+ const dismiss = () => {
+ setVisible(false)
+ try {
+ window.localStorage.setItem(STORAGE_KEY, '1')
+ } catch {
+ /* 무시 */
+ }
+ }
+
+ return (
+