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 가 같은 내용을 이미 알린다. */} +
- {connLabel[connection]} + + {connLabel[connection]} + @@ -136,6 +141,7 @@ export function InterviewStage({
+
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 ( +

+ + 화면이 좁아 일부 요소가 겹칠 수 있어요. 데스크탑 환경을 권장합니다. + + +
+ ) +}